Build and deploy / build-and-deploy (push) Failing after 4s
Five items of Pouya's production run, 2026-09-01.
Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.
Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.
Four launch blockers found by reading the running system:
- handler.mjs wrote pk/sk; the live table's key is submissionId with no
sort key, so every submission would have failed validation silently
- the Lambda invoke permission is scoped to the old route path
- 22 of 23 pages 403 without the router function
- there was no 404 page; src/pages/404.astro adds it
Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.
Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
194 lines
9.2 KiB
Bash
Executable File
194 lines
9.2 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, adr-sml-deploy — PROVISIONED, AGENTS.md §7." >&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" || 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
|
|
# 404 IS AMBIGUOUS BETWEEN THREE CAUSES and the distribution's custom error
|
|
# response hides the one string that would separate them: API Gateway's
|
|
# {"message":"Not Found"} is replaced by /404.html, because custom error
|
|
# responses are distribution-wide. So name the causes and the one command that
|
|
# tells them apart. Corrected 2026-09-01 by `adversarial-reviewer`; the earlier
|
|
# text named only the CloudFront behaviour.
|
|
echo "404 means one of three things, and \`aws apigatewayv2 get-routes" >&2
|
|
echo "--api-id <id> --query 'Items[].RouteKey'\` separates them in one call:" >&2
|
|
echo " - the CloudFront /api/* behaviour is missing (docs/09 Part 3);" >&2
|
|
echo " - the POST /api/intake route is missing or misspelled (Part 6.2);" >&2
|
|
echo " - the route exists and the distribution's 404 mapping is showing you" >&2
|
|
echo " /404.html instead of the API's own body." >&2
|
|
echo "403 means CloudFront rejected the method, or the handler refused the" >&2
|
|
echo "Origin — check the behaviour uses Managed-AllViewerExceptHostHeader," >&2
|
|
echo "because a policy that drops Origin turns every real submission into a" >&2
|
|
echo "403. 500 means the Lambda invoke permission for this route is missing" >&2
|
|
echo "(Part 6.1) — the function is never entered, so CloudWatch is silent." >&2
|
|
echo "Either way the form is not verified working. See docs/09-cutover-" >&2
|
|
echo "runbook.md Part 7.1 and docs/06's cutover checklist." >&2
|
|
fi
|
|
|
|
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
|