Files
adr-sml/.gitea/workflows/deploy.yml
T
Pouya LajevardiandClaude Opus 5 bd282aa47d
Build and deploy / build-and-deploy (push) Failing after 4s
feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
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
2026-09-02 06:52:20 -04:00

225 lines
11 KiB
YAML

# Gitea Actions — the live pipeline for this repository.
#
# Gitea Actions speaks GitHub Actions syntax, so this is a near-direct port of
# docs/reference/github-actions-oidc.yml.example (kept as the OIDC reference in
# case the repo ever moves to GitHub; it lives under docs/ rather than
# .github/workflows/ so Gitea can never fall back to it).
#
# ONE REAL DIFFERENCE: Gitea is not an AWS OIDC provider, so there is no role to
# assume. Deploys are designed to authenticate with a SCOPED IAM USER whose key
# lives only in this repository's Gitea secrets. Whether that user and key have
# actually been created is AGENTS.md Q22 — unanswered as of 2026-08-26.
#
# See docs/06-deployment.md for the exact IAM policy — it grants four actions on
# one bucket and one distribution, nothing more. Rotate the key quarterly; OIDC
# would have made that unnecessary.
#
# Requires a Gitea Actions runner registered to this repo or its organisation.
name: Build and deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
# NO INTAKE_ENDPOINT. Build step 8 moved the intake form to the
# same-origin path /api/intake, after which nothing in src/ read this
# value - `git grep PUBLIC_INTAKE_ENDPOINT -- src/` returned nothing - and
# the guard below was blocking a deploy on it. The comment that stood here
# said an empty value "ships a live contact form posting to nothing",
# which became false in both directions: the form posts to /api/intake
# regardless, and what decides whether it works is the CloudFront /api/*
# behaviour, which nothing guarded. See scripts/deploy-local.sh, which
# carries the post-deploy route check that replaced it.
# Found by `adversarial-reviewer`, 2026-08-31.
steps:
# Runs first, before checkout and before any AWS call, so a
# misconfiguration costs one second instead of a full build.
#
# Repository variables live at Settings -> Actions -> Variables. Gitea
# only added the `vars` context in 1.21; this instance reports 1.27.2
# [verified 2026-08-26 - /api/v1/version, AGENTS.md §7], so the guard is
# belt-and-braces rather than load-bearing. It stays because an unset or
# mistyped variable degrades the sync target to "s3://" and the run dies
# obscurely somewhere in the middle, whatever the Gitea version.
#
# Covers the deploy-target variables, the intake endpoint, AND the two
# secrets. The secrets matter most: AGENTS.md Q22 records that nobody has
# confirmed the IAM user or its key exists, so an unset key is the single
# likeliest first-run failure - and without this it would burn a whole
# build before dying at `aws sts get-caller-identity`.
#
# Only emptiness is ever tested. No value is echoed, so nothing here can
# leak a secret into the run log.
- name: Guard - required variables and secrets are set
run: |
missing=''
[ -n "$AWS_DEFAULT_REGION" ] || missing="$missing AWS_REGION(var)"
[ -n "$S3_BUCKET" ] || missing="$missing S3_BUCKET(var)"
[ -n "$CLOUDFRONT_DISTRIBUTION_ID" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID(var)"
[ -n "$AWS_ACCESS_KEY_ID" ] || missing="$missing AWS_ACCESS_KEY_ID(secret)"
[ -n "$AWS_SECRET_ACCESS_KEY" ] || missing="$missing AWS_SECRET_ACCESS_KEY(secret)"
if [ -n "$missing" ]; then
echo "Not set:$missing"
echo
echo 'Variables: Settings -> Actions -> Variables.'
echo 'Secrets: Settings -> Actions -> Secrets.'
echo 'See docs/06-deployment.md.'
echo 'If the variables ARE set, this Gitea predates the vars context (1.21+).'
exit 1
fi
echo 'All required variables and secrets are set.'
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci
- name: Type and template check
run: npm run check
- name: Build
run: npm run build
env:
# PUBLIC_SITE_URL only, because it is the one variable
# astro.config.mjs reads. PUBLIC_INTAKE_ENDPOINT and
# PUBLIC_BOOKING_URL were set here and consumed by nothing;
# `CONTACT.bookingUrl` is null in source while R6 keeps booking parked.
PUBLIC_SITE_URL: https://adr.smlcompany.ca
# AGENTS.md §4 Forbidden, enforced on the built output before a single
# byte is uploaded. Runs here rather than in `npm run check` because it
# reads dist/, and it refuses a stale or empty dist for the same reason
# this workflow guards its variables: an empty sweep reads exactly like a
# clean one. Mirrored in scripts/deploy-local.sh.
- name: Claim check
run: npm run check:claims
# Some Gitea runner images ship without the AWS CLI. Install if missing.
- name: Ensure AWS CLI
run: |
if ! command -v aws >/dev/null 2>&1; then
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp
sudo /tmp/aws/install --update
fi
aws --version
- name: Verify credentials
run: aws sts get-caller-identity
# Three passes: hashed immutable assets first, then images, HTML last.
# A visitor must never fetch a new page whose assets have not landed yet.
- name: Sync hashed assets
run: |
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "*" \
--include "_astro/*" --include "fonts/*" \
--cache-control "public, max-age=31536000, immutable" \
--no-progress
- name: Sync images
run: |
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
- name: Sync HTML and the rest
run: |
aws s3 sync ./dist "s3://${S3_BUCKET}" \
--exclude "_astro/*" --exclude "fonts/*" \
--cache-control "public, max-age=0, must-revalidate" \
--delete --no-progress
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/*"
# Mirrors the same step in scripts/deploy-local.sh, because that script's
# header requires the two paths to match on everything that determines
# what gets published - and this replaced the INTAKE_ENDPOINT guard.
#
# It ASSERTS A POSITIVE. The first version excluded one status code and
# passed on everything else; `adversarial-reviewer` round 2 measured it
# passing on a refused connection (curl -w already prints 000, so the
# `|| echo 000` double-appended and made $code "000000") and on a real 501.
# It would also have passed the case that matters most: with the /api/*
# behaviour MISSING, the POST falls 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 this check
# exists to draw.
#
# With the correct Origin and an empty submission the handler validates,
# rejects, and redirects 303 to /contact/could-not-send/ - BEFORE any
# DynamoDB write and before any email, which is what makes it safe against
# production. Probed on four cases: refused, 501, 403, and the real 303.
#
# It warns rather than failing: the site is already deployed by this point,
# and failing the job would not un-deploy it.
- name: Intake route check
run: |
url="https://adr.smlcompany.ca/api/intake"
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' "$url")
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' "$url" || true)
if [ "$rc" -ne 0 ]; then
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)."
echo "The site is deployed and the contact form is unverified."
echo "See docs/06-deployment.md's cutover checklist."
elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
echo "POST /api/intake -> 303 -> $location (routed, validating)"
else
echo "WARNING: POST /api/intake returned $code, expected 303 to"
echo "/contact/could-not-send/; redirect was '${location:-none}'."
# Kept in step with scripts/deploy-local.sh — the two are one
# artefact in two places. 404 is ambiguous between three causes and
# the distribution's 404 mapping hides API Gateway's own body.
echo "404: /api/* behaviour missing (docs/09 Part 3), OR the POST"
echo "/api/intake route missing (Part 6.2), OR the route exists and"
echo "the 404 mapping replaced the API's body. Separate them with"
echo "aws apigatewayv2 get-routes --api-id <id> --query"
echo "'Items[].RouteKey' — the --api-id is required; without it the"
echo "CLI exits 252 on ParamValidation."
echo "403: method rejected, or the handler refused the Origin —"
echo "check Managed-AllViewerExceptHostHeader is on the behaviour."
echo "500: the invoke permission for this route is missing (6.1)."
echo "See docs/09-cutover-runbook.md Part 7.1."
fi
- name: Summary
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA:0:7}"