# 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 --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 "read which origin request policy /api/* carries. Since" echo "2026-09-04 it may be the custom whitelist" echo "adr-sml-api-viewer-address rather than the managed" echo "AllViewerExceptHostHeader; a policy that does not forward" echo "Origin 403s every real submission. Rollback id:" echo "b689b0a8-53d0-40ab-baf2-68738e2966ac." 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}"