Build and deploy / build-and-deploy (push) Failing after 4s
D19 caps the loop at two rounds, and this is what the second round is for. BLOCKING. Round 1 made NO_RETAINER_NOTICE a requireEnv and added it to no document, while the fix's own comment claimed docs/06 named it. The deployment list said five variables for a handler that needs six, so an operator following the cutover checklist would have deployed a function that throws at cold start on every invocation — 5xx from API Gateway, every inquiry lost from the moment /api/* was wired, loud in CloudWatch and silent to Pouya. docs/05 and docs/06 now name all six, and the comment that asserted the documentation existed is corrected rather than deleted. The intake route check added in round 1 could not fail: curl -w already prints 000 on a failed transfer, so `|| echo 000` double-appended and the failure arm was unreachable, and the pass arm accepted anything that was not literally 404 — including the 403 CloudFront returns when the /api/* behaviour is missing, which is the one distinction the check exists to draw. It now sends the correct Origin and asserts a positive: 303 to /contact/could-not-send/, which the handler returns before any DynamoDB write or email. Probed on refused/501/403/303; the old version passed the first three. Fixed in both deploy paths. Removing priceRange left three statements saying it was present or pending, one of them the stated reason /fees/ emits no Offer node. Deleting overtimeStartsAfterSessionHours left AGENTS.md §9 naming it and left Q59 recorded as open. The Google-as-processor fix was applied to the privacy policy's "Where it is stored" and not to "Who can see it", which still read "Nobody else has access". And the variable removal was justified with a path-scoped git grep — which also cannot see untracked files. The unscoped sweep found docs/06's variable table, the OIDC example, and .env.example still carrying them; .env.example also restates the execute-api hostname, falsifying a live claim in intake.ts that has been corrected. That file is not edited here: this environment denies read access to it, and nothing may edit a file it cannot read. It is in the batched list. Also: og:image:alt was the page title rather than the card's headline on 20 pages; og-card.ts documented the wrong path and invocation for the contact sheet; deploy-local.sh still said Q22's deploy credential "does NOT yet exist"; and the round-1 fix comments were trimmed per D19, though the ratio held at 0.44. Round 2 also confirmed the round-1 fixes by measurement: all 56 .btn instances across 22 pages, the consent checkbox's computed accessible name, the radio labels hit-tested at 44px, and og:proof exercised against synthetic article pages in a sandbox. Verified: check/build/check:claims/og:proof/check:intake/lint/bio:pdf all exit 0 on a clean build; 22 pages; Lighthouse 99-100 / 100 / 100 / 100, CLS 0.000. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
179 lines
8.1 KiB
Bash
Executable File
179 lines
8.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Local deploy — the fallback while Gitea Actions is unavailable.
|
|
#
|
|
# Gitea Actions needs `[actions] ENABLED = true` in app.ini and a registered
|
|
# act_runner. The instance is jointly administered, so both depend on a second
|
|
# administrator (AGENTS.md Q23). Until that lands, this script is how the site
|
|
# ships.
|
|
#
|
|
# It matches .gitea/workflows/deploy.yml on everything that determines what gets
|
|
# published: the same guard coverage, `npm run check` before the build,
|
|
# `npm run check:claims` after it, the same three sync passes in the same order
|
|
# with the same cache headers, and the same invalidation. Any change to one must
|
|
# be made to the other.
|
|
#
|
|
# Two deliberate differences: it does not run `npm ci` (your node_modules is
|
|
# already installed, and CI starts empty), and it refuses to run as user/pouya,
|
|
# which CI cannot do because CI has no such credential.
|
|
#
|
|
# Required environment (values are in AGENTS.md §7 — deliberately not restated
|
|
# here; §7 is the single source of truth for operational facts):
|
|
#
|
|
# AWS_REGION S3_BUCKET CLOUDFRONT_DISTRIBUTION_ID
|
|
#
|
|
# ⚠️ INTAKE_ENDPOINT IS NO LONGER ONE OF THEM, AND THE GUARD THAT DEMANDED IT
|
|
# WAS BLOCKING A DEPLOY ON A VALUE NOTHING READ. Build step 8 moved the intake
|
|
# form to the same-origin path /api/intake (see src/data/intake.ts for the four
|
|
# reasons). After that, `git grep PUBLIC_INTAKE_ENDPOINT -- src/` returned
|
|
# nothing — the value exported into the build below was consumed by no page —
|
|
# and the guard's own message was false in both directions: the form posts to
|
|
# /api/intake whatever that variable holds, and the thing that actually decides
|
|
# whether it works, the CloudFront /api/* behaviour, was guarded nowhere.
|
|
#
|
|
# So the guard now checks the thing that matters, after the deploy, at the
|
|
# bottom of this script. Found by `adversarial-reviewer`, 2026-08-31.
|
|
# PUBLIC_BOOKING_URL went with it: `CONTACT.bookingUrl` is `null` in source while
|
|
# R6 keeps booking parked, and nothing read that variable either.
|
|
#
|
|
# Credentials: use the scoped deploy user, `adr-sml-deploy`. AGENTS.md §7 records
|
|
# it as PROVISIONED, with one inline policy verified by nine
|
|
# simulate-principal-policy checks; Q22 closed on execution 2026-08-28.
|
|
# (This comment said it "does NOT yet exist" for three days after it did —
|
|
# found by `adversarial-reviewer` round 2.)
|
|
# NEVER run this as user/pouya — see AGENTS.md §10.
|
|
|
|
set -euo pipefail
|
|
|
|
# Same five values the workflow guards. Emptiness only — no value is echoed.
|
|
missing=''
|
|
[ -n "${AWS_REGION:-}" ] || missing="$missing AWS_REGION"
|
|
[ -n "${S3_BUCKET:-}" ] || missing="$missing S3_BUCKET"
|
|
[ -n "${CLOUDFRONT_DISTRIBUTION_ID:-}" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID"
|
|
[ -n "${AWS_ACCESS_KEY_ID:-}" ] || missing="$missing AWS_ACCESS_KEY_ID"
|
|
[ -n "${AWS_SECRET_ACCESS_KEY:-}" ] || missing="$missing AWS_SECRET_ACCESS_KEY"
|
|
if [ -n "$missing" ]; then
|
|
echo "Not set:$missing" >&2
|
|
echo >&2
|
|
echo "Values are in AGENTS.md §7." >&2
|
|
exit 1
|
|
fi
|
|
|
|
export AWS_DEFAULT_REGION="$AWS_REGION"
|
|
|
|
echo "==> Identity check"
|
|
caller=$(aws sts get-caller-identity --query Arn --output text)
|
|
echo " $caller"
|
|
case "$caller" in
|
|
*:user/pouya)
|
|
echo >&2
|
|
echo "REFUSING: that is the broadly-permissioned personal user." >&2
|
|
echo "AGENTS.md §10 — never use user/pouya to deploy. Use the scoped" >&2
|
|
echo "deploy user (Q22: not yet created)." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
echo "==> Type and template check"
|
|
npm run check
|
|
|
|
echo "==> Build"
|
|
# Only PUBLIC_SITE_URL, because it is the only one astro.config.mjs reads.
|
|
# PUBLIC_INTAKE_ENDPOINT and PUBLIC_BOOKING_URL were exported here and consumed
|
|
# by nothing — see the header.
|
|
PUBLIC_SITE_URL="https://adr.smlcompany.ca" \
|
|
npm run build
|
|
|
|
# AFTER the build and BEFORE anything is uploaded. AGENTS.md §4 Forbidden,
|
|
# enforced mechanically on the output rather than by a reviewer reading it.
|
|
# Pouya's ruling 2026-08-29: "prose in a comment does not govern the writing
|
|
# that follows it." It also refuses to run against a stale or empty dist, so a
|
|
# pass here is a pass on the bytes about to be published.
|
|
echo "==> Claim check"
|
|
npm run check:claims
|
|
|
|
echo "==> Pass 1/3 — hashed assets and fonts (immutable)"
|
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
|
--exclude "*" \
|
|
--include "_astro/*" --include "fonts/*" \
|
|
--cache-control "public, max-age=31536000, immutable" \
|
|
--no-progress
|
|
|
|
echo "==> Pass 2/3 — images"
|
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
|
--exclude "*" \
|
|
--include "*.avif" --include "*.webp" --include "*.jpg" \
|
|
--include "*.png" --include "*.svg" \
|
|
--cache-control "public, max-age=604800" \
|
|
--no-progress
|
|
|
|
echo "==> Pass 3/3 — HTML and the rest (must-revalidate, --delete)"
|
|
aws s3 sync ./dist "s3://${S3_BUCKET}" \
|
|
--exclude "_astro/*" --exclude "fonts/*" \
|
|
--cache-control "public, max-age=0, must-revalidate" \
|
|
--delete --no-progress
|
|
|
|
echo "==> Invalidate CloudFront"
|
|
aws cloudfront create-invalidation \
|
|
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
|
|
--paths "/*" >/dev/null
|
|
|
|
# THE CHECK THAT REPLACES THE INTAKE_ENDPOINT GUARD, and it runs AFTER the
|
|
# deploy because it tests the deployed thing rather than a variable.
|
|
#
|
|
# The intake form posts to the same-origin path /api/intake, which only works if
|
|
# a CloudFront behaviour routes /api/* to the HTTP API origin AGENTS.md §7
|
|
# records. Nothing in the build can know whether that behaviour exists, and a
|
|
# deploy that succeeds while the form posts into a 404 is the failure the old
|
|
# guard was reaching for and could not see.
|
|
#
|
|
# ⚠️ IT ASSERTS A POSITIVE, AND THE FIRST VERSION ASSERTED THE ABSENCE OF ONE
|
|
# CODE. That version was `code=$(curl ... || echo 000)` and passed on anything
|
|
# that was not literally 404. Two defects, both measured by
|
|
# `adversarial-reviewer` round 2:
|
|
#
|
|
# - `curl -w '%{http_code}'` ALREADY prints 000 on a failed transfer, so
|
|
# `|| echo 000` double-appended and $code became `000000` — the 000 arm was
|
|
# unreachable and a connection failure reported success.
|
|
# - If the /api/* behaviour is MISSING, the POST falls through to the S3
|
|
# default behaviour and CloudFront answers 403 for a disallowed method —
|
|
# indistinguishable from the handler's Origin refusal, which is the one
|
|
# distinction the check exists to draw. It also passed on a real 501.
|
|
#
|
|
# So it now sends the correct Origin and asserts the answer it should get:
|
|
# the handler validates, finds an empty submission, and redirects 303 to
|
|
# /contact/could-not-send/. That happens BEFORE any DynamoDB write and before
|
|
# any email, which is what makes the probe safe against production.
|
|
echo "==> Intake route check"
|
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
|
--max-time 15 \
|
|
-H "Origin: https://adr.smlcompany.ca" \
|
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
|
--data 'deploy-route-probe=1' \
|
|
"https://adr.smlcompany.ca/api/intake")
|
|
rc=$?
|
|
location=$(curl -sS -o /dev/null -w '%{redirect_url}' -X POST \
|
|
--max-time 15 \
|
|
-H "Origin: https://adr.smlcompany.ca" \
|
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
|
--data 'deploy-route-probe=1' \
|
|
"https://adr.smlcompany.ca/api/intake" 2>/dev/null || true)
|
|
if [ "$rc" -ne 0 ]; then
|
|
echo >&2
|
|
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)." >&2
|
|
echo "The contact form posts there. The site is deployed and the form is" >&2
|
|
echo "unverified — see docs/06-deployment.md's cutover checklist." >&2
|
|
elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
|
|
echo " POST /api/intake -> 303 -> $location (routed, validating, rejecting an empty probe)"
|
|
else
|
|
echo >&2
|
|
echo "WARNING: POST /api/intake returned $code (expected 303 to" >&2
|
|
echo "/contact/could-not-send/); redirect was '${location:-none}'." >&2
|
|
echo "404 means the CloudFront /api/* behaviour is missing. 403 can mean the" >&2
|
|
echo "same thing — CloudFront rejecting a method the default behaviour does" >&2
|
|
echo "not allow — or the handler refusing the Origin. Either way the form is" >&2
|
|
echo "not verified working. See docs/06-deployment.md's cutover checklist." >&2
|
|
fi
|
|
|
|
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
|