#!/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))"