fix: sweep D3 amendment through the specs; correct inverted DKIM table
The re-audit of the deploy-guard change surfaced defects well outside the diff, including one that would have broken production mail. docs/05-backend-spec.md had the two SES DKIM sets exactly inverted, labelling the three records that resolve as "orphans" and the three NXDOMAIN records as "Live. Never delete". Entry (j) corrected this in AGENTS.md §7 and the correction never reached docs/05. Since SES has no custom MAIL FROM, DKIM is the only thing satisfying DMARC, so acting on that table would have silently broken intake mail authentication. Also in this change: - .gitea/workflows/deploy.yml gains a guard as steps[0] that fails the run, naming the variable, if AWS_REGION, S3_BUCKET or CLOUDFRONT_DISTRIBUTION_ID is empty — how a Gitea too old for the vars context manifests. Verified fail-closed under bash -e, sh -e and bash -euo pipefail. - AGENTS.md Current Truth: SPF and DMARC recorded as present (Q20), the matching §10 High risk row retired, three duplicate Q rows removed. - docs/reference/AWS-Hosting-Guide.md tracked and given a do-not-execute banner; it was an executable procedure for the architecture D1/D3 replace. - Copy decks: "a working litigator" and "an active litigation practice" replaced with the register's own wording; LegalService JSON-LD replaced with ProfessionalService; tribunal-secretary offers removed per D14; nine stale question blockers swept. - astro.config.mjs: prefetchAll disabled — it injected JS into every page against the zero-JS convention with no decision recorded. - src/data/site.ts: unregistered response-time commitment nulled (Q27); OBA section names downgraded to [assumed] (Q28). - s3:AbortMultipartUpload reasoning corrected to measure ./dist, not the repo. Opens Q27, Q28, Q29. AGENTS.md entry (q) records the full resolution, including the findings declined and why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
This commit is contained in:
co-authored by
Claude Opus 5
parent
e6abdf42e8
commit
6bf1167624
+132
-66
@@ -1,19 +1,22 @@
|
||||
# 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`.
|
||||
Authority: `AGENTS.md` §3 **D3 as amended 2026-08-26** (git + **Gitea Actions**
|
||||
→ existing S3/CloudFront) and D11 (build everything, one clean cutover).
|
||||
Existing infrastructure: **`AGENTS.md` §7 is authoritative.**
|
||||
`docs/reference/AWS-Hosting-Guide.md` records how that infrastructure was
|
||||
originally built — it is a historical record carrying a do-not-execute banner,
|
||||
not a procedure, and §7 wins wherever the two disagree (Q24).
|
||||
|
||||
---
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
GitHub push to main
|
||||
└─ GitHub Actions
|
||||
Gitea push to main
|
||||
└─ Gitea Actions (act_runner)
|
||||
├─ npm ci && npm run build → ./dist
|
||||
├─ assume AWS role via OIDC (no stored keys)
|
||||
├─ aws s3 sync ./dist s3://<bucket>
|
||||
├─ static scoped IAM user key (from Gitea secrets — NOT OIDC)
|
||||
├─ aws s3 sync ./dist s3://<bucket> (three passes, see Cache policy)
|
||||
└─ cloudfront create-invalidation
|
||||
Namecheap DNS → CloudFront → S3 (OAC)
|
||||
API Gateway → Lambda → DynamoDB / SES (intake, unchanged path)
|
||||
@@ -30,8 +33,11 @@ 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.
|
||||
three-pass sync, and the cache headers are unchanged. The GitHub Actions original,
|
||||
with its OIDC role assumption, stays in the repo as
|
||||
`docs/reference/github-actions-oidc.yml.example` — deliberately outside
|
||||
`.github/workflows/`, because Gitea falls back to that directory when
|
||||
`.gitea/workflows` is absent.
|
||||
|
||||
### The one real difference: no OIDC
|
||||
|
||||
@@ -39,8 +45,9 @@ 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.
|
||||
This is a genuine step down in security from an OIDC setup — which was designed
|
||||
here but never built — and it should be treated as one. The mitigations are the
|
||||
policy scope and the rotation schedule.
|
||||
|
||||
**Create the user:**
|
||||
|
||||
@@ -62,7 +69,7 @@ treated as one. The mitigations are the policy scope and the rotation schedule.
|
||||
{
|
||||
"Sid": "WriteSiteObjects",
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:PutObject", "s3:PutObjectAcl", "s3:DeleteObject"],
|
||||
"Action": ["s3:PutObject", "s3:DeleteObject"],
|
||||
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
|
||||
},
|
||||
{
|
||||
@@ -75,12 +82,34 @@ treated as one. The mitigations are the policy scope and the rotation schedule.
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
Four actions on one bucket and one distribution. No `Action: "*"`, no
|
||||
`Resource: "*"` — the only wildcard is `BUCKET_NAME/*`, which scopes to the
|
||||
objects of that one bucket. `s3:PutObjectAcl` was dropped on 2026-08-26:
|
||||
`aws s3 sync` does not use it without `--acl`, and it is inert under Origin
|
||||
Access Control with ACLs disabled. 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.
|
||||
|
||||
**`s3:AbortMultipartUpload` is deliberately absent, and here is the actual
|
||||
reason.** `aws s3 sync` switches to multipart above its 8 MB
|
||||
`multipart_threshold`; an interrupted multipart upload then cannot clean up its
|
||||
own parts, and orphaned parts accrue storage charges that do not appear in the
|
||||
bucket listing. What makes that safe today is simply that **nothing here comes
|
||||
close to 8 MB** — the largest file the pipeline
|
||||
uploads is well under it. The biggest source asset is
|
||||
`src/assets/pouya-lajevardi.jpg` at 357,627 bytes `[verified 2026-08-26 — stat]`,
|
||||
Astro emits it smaller still after AVIF/WebP conversion, and the self-hosted font
|
||||
files are smaller again. **Re-measure `./dist` after the first successful build**
|
||||
— that, not the repository, is what gets synced. No lifecycle rule exists; do not describe one
|
||||
as the mitigation, because it is not there.
|
||||
|
||||
**Revisit if any single asset approaches 8 MB** — a video, a large PDF, an
|
||||
un-optimised photograph. At that point either add an S3 lifecycle rule aborting
|
||||
incomplete multipart uploads after 7 days (preferred — it costs no IAM
|
||||
permission), or grant `s3:AbortMultipartUpload` on `BUCKET_NAME/*`.
|
||||
|
||||
### Gitea configuration
|
||||
|
||||
**Repository → Settings → Actions → Secrets:**
|
||||
@@ -90,7 +119,10 @@ to widen the policy.
|
||||
| `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`):
|
||||
**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. Values captured 2026-08-26 by
|
||||
`scripts/aws-discover.sh`:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
@@ -105,23 +137,13 @@ IAM policy substitutions: `BUCKET_NAME` = `adr-smlcompany-site`,
|
||||
|
||||
> **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
|
||||
> `mlp-clientdb-prod-backups` — which **by its name** holds another business's
|
||||
> production client-database backups; the contents were never inspected, only
|
||||
> the bucket name observed. 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
|
||||
@@ -130,8 +152,25 @@ organisation, and Actions enabled both site-wide in `app.ini`
|
||||
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.
|
||||
`aws sts get-caller-identity` before touching anything. **That check is
|
||||
narrower than it looks:** `sts:GetCallerIdentity` requires no IAM permission at
|
||||
all, so it succeeds for any valid key regardless of policy. It catches a
|
||||
missing, malformed, or revoked key; it does **not** catch an under-scoped
|
||||
policy, which still fails halfway through a sync and leaves the bucket
|
||||
partially updated. Read it as a key check, not a permissions check.
|
||||
|
||||
### The variable guard runs first
|
||||
|
||||
The workflow's first step — before checkout, before the build, before any AWS
|
||||
call — fails the run if `AWS_REGION`, `S3_BUCKET`, or
|
||||
`CLOUDFRONT_DISTRIBUTION_ID` is empty.
|
||||
|
||||
This exists because Gitea only added the `vars` context in 1.21. On an older
|
||||
instance every `${{ vars.* }}` interpolates to an empty string with no warning,
|
||||
the sync target becomes `s3://`, and the run dies halfway through with an error
|
||||
that names nothing useful. The guard converts that into a clean failure that
|
||||
says which variable is missing — **on every Gitea version**. A recorded version
|
||||
number would have gone stale; the guard does not.
|
||||
|
||||
### Key rotation — an operational obligation
|
||||
|
||||
@@ -149,9 +188,9 @@ whole section exists to bound.
|
||||
|
||||
## Finding the AWS identifiers
|
||||
|
||||
`scripts/aws-discover.sh` collects everything Q10 needs — bucket, distribution
|
||||
`scripts/aws-discover.sh` re-collects the inventory — bucket, distribution
|
||||
ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning
|
||||
is on. Read-only; every call is a list or describe.
|
||||
is on. Read-only; no call creates or mutates anything.
|
||||
|
||||
```bash
|
||||
chmod +x scripts/aws-discover.sh
|
||||
@@ -160,25 +199,28 @@ chmod +x scripts/aws-discover.sh
|
||||
|
||||
The output contains resource names and IDs but no secrets.
|
||||
|
||||
## Why OIDC and not access keys
|
||||
## Why OIDC would have been better — and why it is unavailable
|
||||
|
||||
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.
|
||||
> **Do not execute this section.** It describes the design that was rejected
|
||||
> because Gitea cannot support it. The live procedure is *Create the user* above.
|
||||
> Nothing here should be created in AWS. Following it would add an unused GitHub
|
||||
> federation trust to account `327082975128`.
|
||||
|
||||
One-time setup:
|
||||
A static `AWS_ACCESS_KEY_ID` 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 one repository and one branch — strictly better, and the
|
||||
reason the rotation schedule above is not optional here.
|
||||
|
||||
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:<org>/<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.
|
||||
It needs an identity provider AWS will federate with. GitHub and GitLab both
|
||||
publish one; **Gitea and Forgejo do not**, so there is nothing for AWS to trust
|
||||
and no role to assume. That is the whole of the constraint (D3 as amended).
|
||||
|
||||
If the project ever moves to GitHub, the workflow to adopt is
|
||||
`docs/reference/github-actions-oidc.yml.example`, and the setup is: register
|
||||
`token.actions.githubusercontent.com` as an IAM OIDC provider with audience
|
||||
`sts.amazonaws.com`; create a role trusting it, conditioned on the `sub` claim
|
||||
matching the repository and `refs/heads/main`; attach the same four-action policy
|
||||
given above; then delete `adr-sml-deploy` and its key.
|
||||
|
||||
## Cache policy
|
||||
|
||||
@@ -191,19 +233,33 @@ that does not update.
|
||||
| `/_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` |
|
||||
| `robots.txt`, `sitemap*.xml` | `public, max-age=0, must-revalidate` |
|
||||
|
||||
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.
|
||||
Sync in **three** passes, in this order: hashed assets and fonts with the long
|
||||
TTL, then images, then everything else. Uploading HTML last means a user never
|
||||
fetches a new page whose assets have not landed yet.
|
||||
|
||||
Two ordering dependencies are load-bearing and easy to break:
|
||||
|
||||
- Pass 3 re-walks the whole tree; the image headers from pass 2 survive only
|
||||
because `aws s3 sync` skips objects it has just uploaded. Reordering the
|
||||
passes silently overwrites them with the HTML header.
|
||||
- Pass 3's `--exclude "_astro/*" --exclude "fonts/*"` also excludes those
|
||||
prefixes from `--delete`, so hashed assets from previous deploys are kept
|
||||
deliberately — pages still in a browser cache need them. Do not "fix" it.
|
||||
|
||||
`robots.txt` and `sitemap*.xml` fall through to pass 3 and get the HTML header.
|
||||
That is the intended behaviour: both should be re-fetched, and the table above
|
||||
records what the pipeline actually does rather than an unimplemented ideal.
|
||||
|
||||
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.
|
||||
- Origin: S3 with **Origin Access Control**, bucket not public. Verify the
|
||||
bucket policy grants access only to the CloudFront distribution's OAC
|
||||
principal and to nothing else, and that public access is still blocked.
|
||||
- Redirect HTTP → HTTPS. TLS 1.2 minimum.
|
||||
- Default root object `index.html`.
|
||||
- **Custom error response:** 404 → `/404.html` with **response code 404**, not
|
||||
@@ -215,11 +271,20 @@ invalidation paths are a reliable source of confusing bugs.
|
||||
|
||||
## Branch model
|
||||
|
||||
`main` is production; every push deploys. Work on short-lived branches, open a
|
||||
PR, let CI build and run Lighthouse, merge.
|
||||
`main` is production; a push to `main` is what triggers a deploy. Work on
|
||||
short-lived branches, open a PR, merge.
|
||||
|
||||
**Pull request checks (blocking):** `npm run build` · `astro check` · lint ·
|
||||
Lighthouse CI against the budgets in `04-seo-spec.md` · link check.
|
||||
**The pipeline has never completed a run.** There is no `package-lock.json`, so
|
||||
`npm ci` exits at step one; `src/pages/` is empty, so there is nothing to build;
|
||||
and no record exists of an `act_runner` being registered. Treat "every push
|
||||
deploys" as the design, not as current behaviour.
|
||||
|
||||
**Pull request checks — planned, not implemented:** `npm run build` ·
|
||||
`astro check` · lint · Lighthouse CI against the budgets in `04-seo-spec.md` ·
|
||||
link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger**
|
||||
(only `push` on `main` and `workflow_dispatch`), and neither `npm run lint` nor
|
||||
`npm run lighthouse` is wired — there is no ESLint config and no `lighthouserc`.
|
||||
Nothing gates a merge today.
|
||||
|
||||
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
||||
|
||||
@@ -227,8 +292,9 @@ Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
||||
|
||||
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.
|
||||
3. Restore from S3 object versioning — **already Enabled** on
|
||||
`adr-smlcompany-site` `[verified 2026-08-26 — AGENTS.md §7]`. It is the
|
||||
difference between a rollback and a rebuild; do not turn it off.
|
||||
|
||||
Then invalidate `/*`.
|
||||
|
||||
@@ -239,7 +305,7 @@ Then invalidate `/*`.
|
||||
- [ ] 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
|
||||
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship
|
||||
- [ ] Privacy policy matches the backend as actually built
|
||||
|
||||
**Technical**
|
||||
@@ -251,10 +317,10 @@ Then invalidate `/*`.
|
||||
- [ ] 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 identities verified for sending** — confirmed 2026-08-26, re-check at cutover: `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
|
||||
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `BOOKING_URL` is empty, so this passes vacuously until a tool is chosen
|
||||
- [ ] Favicon set complete
|
||||
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
|
||||
- [ ] Tested at 320 px and at 200% zoom
|
||||
@@ -264,7 +330,7 @@ Then invalidate `/*`.
|
||||
- [ ] 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)
|
||||
- [ ] Billing budget/alarm still active — `aws budgets describe-budgets --account-id 327082975128`. `docs/reference/AWS-Hosting-Guide.md` set up an **AWS Budget**, which `cloudwatch describe-alarms` will never return. Whether one was actually created is not recorded anywhere: confirm, do not assume
|
||||
|
||||
**Post-cutover, same day**
|
||||
- [ ] Sitemap submitted to Google Search Console and Bing Webmaster Tools
|
||||
|
||||
Reference in New Issue
Block a user