# 06 — Deployment and cutover Authority: `AGENTS.md` §3 D3 (git + GitHub Actions → existing S3/CloudFront) and D11 (build everything, one clean cutover). Existing infrastructure: `AWS-Hosting-Guide.md`. --- ## Topology ``` GitHub push to main └─ GitHub Actions ├─ npm ci && npm run build → ./dist ├─ assume AWS role via OIDC (no stored keys) ├─ aws s3 sync ./dist s3:// └─ cloudfront create-invalidation Namecheap DNS → CloudFront → S3 (OAC) API Gateway → Lambda → DynamoDB / SES (intake, unchanged path) ``` DNS is at **Namecheap, not Route 53** `[verified 2026-08-25]`. Nothing in the pipeline touches DNS. Certificate renewal is ACM-automatic as long as the validation CNAME stays in place at Namecheap — **do not delete it.** ## CI runs on Gitea, not GitHub `AGENTS.md` D3 as amended, 2026-08-26: self-hosted **Gitea**, repo `adr-sml`, local clone at `/Users/pouya/Dev/Websites/adr-sml`. **The live pipeline is `.gitea/workflows/deploy.yml`.** Gitea Actions speaks GitHub Actions syntax, so it is a near-direct port — the build steps, the two-pass sync, and the cache headers are unchanged. `.github/workflows/deploy.yml` stays in the repo as the OIDC reference in case the project ever moves. ### The one real difference: no OIDC Gitea is not an AWS OIDC provider. There is no role to assume, so deploys authenticate with a **scoped IAM user** whose access key lives only in the repository's Gitea secrets. This is a genuine step down in security from the GitHub setup, and it should be treated as one. The mitigations are the policy scope and the rotation schedule. **Create the user:** 1. IAM → Users → `adr-sml-deploy`. **Programmatic access only** — no console password, no MFA device, no group membership. 2. Attach this inline policy and nothing else. Substitute the real bucket name, account ID, and distribution ID from `scripts/aws-discover.sh`: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "ListSiteBucket", "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::BUCKET_NAME" }, { "Sid": "WriteSiteObjects", "Effect": "Allow", "Action": ["s3:PutObject", "s3:PutObjectAcl", "s3:DeleteObject"], "Resource": "arn:aws:s3:::BUCKET_NAME/*" }, { "Sid": "InvalidateOneDistribution", "Effect": "Allow", "Action": "cloudfront:CreateInvalidation", "Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID" } ] } ``` No `s3:*`. No `cloudfront:*`. No wildcard resources. If a deploy step needs a permission this policy lacks, the correct response is to question the step, not to widen the policy. 3. Create an access key. **Copy it once** — AWS will not show the secret again. ### Gitea configuration **Repository → Settings → Actions → Secrets:** | Name | Value | |---|---| | `AWS_ACCESS_KEY_ID` | from the IAM user | | `AWS_SECRET_ACCESS_KEY` | from the IAM user | **The real values** (captured 2026-08-26, `aws-inventory.txt`): | Variable | Value | |---|---| | `AWS_REGION` | `ca-central-1` | | `S3_BUCKET` | `adr-smlcompany-site` | | `CLOUDFRONT_DISTRIBUTION_ID` | `E1OK7G98KNKUTA` | | `INTAKE_ENDPOINT` | `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com` | | `BOOKING_URL` | *(empty — parked, R6)* | IAM policy substitutions: `BUCKET_NAME` = `adr-smlcompany-site`, `ACCOUNT_ID` = `327082975128`, `DISTRIBUTION_ID` = `E1OK7G98KNKUTA`. > **Read this before creating the key.** Account `327082975128` is shared across > `meshkinilaw.ca`, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and > `mlp-clientdb-prod-backups` — a law firm's client-database backups. A static > deploy key for a marketing site lives in the same account. The scoped policy is > what keeps a compromised Gitea runner from reaching any of that. Do not widen > it, and never put the `user/pouya` credentials in CI. **Repository → Settings → Actions → Variables** (not secrets — these are not sensitive, and keeping them as variables means they appear in run logs where they are useful for debugging): | Name | Value | |---|---| | `AWS_REGION` | e.g. `ca-central-1` | | `S3_BUCKET` | the site bucket | | `CLOUDFRONT_DISTRIBUTION_ID` | the `E...` ID | | `INTAKE_ENDPOINT` | API Gateway invoke URL | | `BOOKING_URL` | once chosen (Q5) | ### A runner must exist Gitea Actions needs `act_runner` registered to this repository or its organisation, and Actions enabled both site-wide in `app.ini` (`[actions] ENABLED = true`) and per-repository. Without a runner the workflow queues silently and never runs — which looks exactly like a broken pipeline. The workflow installs the AWS CLI if the runner image lacks it, and runs `aws sts get-caller-identity` before touching anything, so a credential problem fails loudly and early rather than halfway through a sync. ### Key rotation — an operational obligation **Rotate `adr-sml-deploy` quarterly.** OIDC would have made this unnecessary; with a static key it is a standing task: 1. Create a second access key on the same user. 2. Update the Gitea secrets. 3. Run the workflow and confirm it succeeds. 4. **Delete the old key.** Rotation that leaves the old key active is not rotation. Set a calendar reminder. A key that is never rotated is the failure mode this whole section exists to bound. ## Finding the AWS identifiers `scripts/aws-discover.sh` collects everything Q10 needs — bucket, distribution ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning is on. Read-only; every call is a list or describe. ```bash chmod +x scripts/aws-discover.sh ./scripts/aws-discover.sh > aws-inventory.txt ``` The output contains resource names and IDs but no secrets. ## Why OIDC and not access keys The alternative is a long-lived `AWS_ACCESS_KEY_ID` in GitHub secrets: a credential that never expires, is invisible once set, and grants its permissions to anyone who can reach the repository. OIDC issues a short-lived token per run, scoped to this repository and this branch. One-time setup: 1. IAM → Identity providers → add OIDC provider `token.actions.githubusercontent.com`, audience `sts.amazonaws.com`. 2. Create role `adr-site-deploy` trusting that provider, with a condition on `token.actions.githubusercontent.com:sub` equal to `repo:/:ref:refs/heads/main` (**Q9**). 3. Attach a policy granting **only**: `s3:PutObject`, `s3:DeleteObject`, `s3:ListBucket` on the site bucket, and `cloudfront:CreateInvalidation` on the one distribution. Nothing else. No `s3:*`, no `cloudfront:*`. 4. Store the role ARN, bucket name, and distribution ID as repository **variables** (they are not secrets), and reference them in the workflow. ## Cache policy The mistake to avoid is caching HTML aggressively — a stale index page is a site that does not update. | Pattern | `Cache-Control` | |---|---| | `*.html` | `public, max-age=0, must-revalidate` | | `/_astro/*` (hashed) | `public, max-age=31536000, immutable` | | Fonts | `public, max-age=31536000, immutable` | | Images | `public, max-age=604800` | | `robots.txt`, `sitemap*.xml` | `public, max-age=3600` | Sync in two passes: hashed assets first with the long TTL, then HTML with the short one. Uploading HTML last means a user never fetches a new page whose assets have not landed yet. Invalidate `/*` on deploy. At this traffic volume the cost is nil, and partial invalidation paths are a reliable source of confusing bugs. ## CloudFront configuration - Origin: S3 with **Origin Access Control**, bucket not public. The guide's Part 2.2 bucket policy already does this — verify it was not loosened. - Redirect HTTP → HTTPS. TLS 1.2 minimum. - Default root object `index.html`. - **Custom error response:** 404 → `/404.html` with **response code 404**, not 200. Returning 200 for a missing page tells crawlers every bad URL is real content, and it is the single most common misconfiguration in this stack. - Compression on. Response-headers policy from `05-backend-spec.md`. - A CloudFront Function for trailing-slash normalisation, so `/about` and `/about/` do not both resolve as separate indexable URLs. ## Branch model `main` is production; every push deploys. Work on short-lived branches, open a PR, let CI build and run Lighthouse, merge. **Pull request checks (blocking):** `npm run build` · `astro check` · lint · Lighthouse CI against the budgets in `04-seo-spec.md` · link check. Tag every production deploy `v.` so a rollback has something to name. ## Rollback 1. Re-run the workflow at the last good tag, or 2. `git revert` and push, or 3. Restore from S3 object versioning — **enable versioning on the bucket if it is off**; it is the difference between a rollback and a rebuild. Then invalidate `/*`. ## Cutover checklist — D11 is a single shot, so run all of it **Content and compliance** - [ ] Every claim traced to `AGENTS.md` §4 Verified - [ ] No `TODO(pouya)` remains in any shipped page - [ ] No matter counts, rates, dollar figures, or testimonials anywhere - [ ] Q.Arb described as in progress everywhere it appears - [ ] `/fees/` carries real numbers (Q4) or the page does not ship - [ ] Privacy policy matches the backend as actually built **Technical** - [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, a practice page, an article - [ ] Every page renders fully with JavaScript disabled - [ ] `curl` of each URL returns real content, not a shell - [ ] All internal links resolve; no orphan pages - [ ] Sitemap generated and correct; `robots.txt` served, not 403 - [ ] Rich Results Test passes; OG previews render in LinkedIn and Slack - [ ] 404 returns a 404 status - [ ] Security headers present (`securityheaders.com` A or better) - [ ] **SES identities verified for sending** (Q18) — `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true` - [ ] **SES out of the sandbox** (Q19) — `aws sesv2 get-account --query 'ProductionAccessEnabled'`. In sandbox, mail reaches only pre-verified addresses and the inquirer's confirmation silently fails - [ ] Intake form tested end to end: DynamoDB record written to `adr-intake-submissions`, both emails delivered to a real inbox, TTL set - [ ] Booking link works, including the no-JavaScript fallback - [ ] Favicon set complete - [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox - [ ] Tested at 320 px and at 200% zoom **Infrastructure** - [ ] S3 versioning enabled - [ ] Bucket not publicly readable; OAC in force - [ ] ACM certificate valid; Namecheap validation CNAME still present - [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate - [ ] Billing alarm still active (guide Part 0.3) **Post-cutover, same day** - [ ] Sitemap submitted to Google Search Console and Bing Webmaster Tools - [ ] Live site fetched as an anonymous crawler to confirm indexable content - [ ] LinkedIn profile and ADRIC/ADRIO listings updated to point here - [ ] Archive the old single-file build to `_archive/` — do not delete it - [ ] `AGENTS.md` Change Log entry recording the cutover