# 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. # # The contact 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 it exists. # # 404 means not routed. 403 means routed and REFUSED, which is the correct # answer here: the handler checks the Origin header and this request sends # none, so it is rejected before any DynamoDB write or any email. That is # why the probe is safe to run against production. # # 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: | 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 "WARNING: POST /api/intake returned $code." echo "The contact form posts there. 404 means the CloudFront /api/*" echo "behaviour is missing; 000 means the request did not complete." echo "See docs/06-deployment.md's cutover checklist." ;; *) echo "POST /api/intake -> $code (routed; 403 is the Origin check)" ;; esac - name: Summary run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA:0:7}"