#!/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. AGENTS.md Q22 records that it does # NOT yet exist. 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. # # 404 means not routed. 403 means routed and REFUSED, which is the correct answer # to this request: the handler checks the Origin header and this curl sends none, # so it is rejected before any DynamoDB write or any email. That makes 403 a pass # and is why this probe is safe to run against production. echo "==> Intake route check" code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \ --max-time 15 \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data 'deploy-route-probe=1' \ "https://adr.smlcompany.ca/api/intake" || echo 000) case "$code" in 404|000) echo >&2 echo "WARNING: POST /api/intake returned $code." >&2 echo "The contact form posts there. 404 means the CloudFront /api/* behaviour" >&2 echo "is missing; 000 means the request did not complete. The site is" >&2 echo "deployed and the form is not wired — see docs/06's cutover checklist." >&2 ;; *) echo " POST /api/intake -> $code (routed; 403 is the Origin check refusing a probe)" ;; esac echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"