chore: project scaffold, specs, and working record
Build and deploy / build-and-deploy (push) Failing after 5s
Build and deploy / build-and-deploy (push) Failing after 5s
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
# 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://<bucket>
|
||||
└─ 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:<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.
|
||||
|
||||
## 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<year>.<n>` 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
|
||||
Reference in New Issue
Block a user