CI/CD topology
Four environments: Local (SQLite) → Development (Docker + Postgres) → Test (SAM) → Production (SAM, manual).
Catalog: docs/README.md. Env vars: docs/web/setup/environment.md. Docker: docker.md. SAM: infra.md.
Portable templates for other projects / AI agents:
- CI/CD: templates/nuxt-ssr-cicd/ ·
.agents/skills/nuxt-ssr-cicd-template/ - Commit / PR / rulesets: templates/commit-pr-rules/ ·
.agents/skills/commit-pr-rules-template/
Flow
PR → development → CI (local parity); skip version-bump-only PRs
PR → main → CI (local parity) + full regression (e2e)
→ version bump minor|major via PR into PR head (channel=main)
push → development → CD dry-run (SAM validate+build) + patch version-bump PR
(no full CI — already ran on the PR)
squash-merge → main → CD AWS Test (SAM deploy → test); no full CI re-run
release published → CD AWS Production (tagged commit + Environment approval)
workflow_dispatch → CD AWS Production (confirm=deploy + Environment approval)
Merge policy
| Branch | How to land changes | Merge method |
|---|---|---|
development |
Pull request only (admin bypass allowed) | Merge or squash |
main |
Pull request only (admin bypass allowed) | Squash only (workflows assume it) |
Configure via Rulesets (npm run gh:setup-rulesets). Squash commit text must be PR title only (npm run gh:setup-merge-settings) — see Skip-CI trap below. Do not treat unprotected direct push as the default path.
Matrix
| Environment | Runtime | Data | Trigger | GitHub Environment |
|---|---|---|---|---|
| Local | Nitro node-server |
SQLite data/local.sqlite |
Developer machine | — |
| Development | Docker web + api + Postgres (local Compose) |
PostgreSQL | Local npm run docker:dev only |
— |
| Test | SAM: Lambda + HTTP API + S3 + DynamoDB | DynamoDB | Squash-merge to main (after PR CI) |
test |
| Production | Same SAM topology | DynamoDB (prod stack) | GitHub Release published (non-prerelease) or manual workflow_dispatch |
production |
Docker Compose under docker/ maps to this matrix as local stand-ins: local ≈ docker-compose.local.yml (SQLite ./data + web); development ≈ docker-compose.dev.yml (Postgres + web/api); test ≈ docker-compose.test.yml (DynamoDB Local + Lambda RIE). Neither is identical to AWS (e.g. no S3/CDN in Compose; split web/api only in Docker dev). docker:local and docker:dev both bind host 4200 — run only one. Crosswalk table and # aws: service comments: docker/README.md.
Nitro output (SYS_ENV)
Every Nuxt build writes to workspace .output/<sysEnv> (scripts/nitro-output-dir.mjs). Do not mix trees.
SYS_ENV |
Preset | Directory | Consumers |
|---|---|---|---|
local |
node-server |
.output/local |
npm run build / CI Local parity |
development |
node-server |
.output/development |
Docker Dockerfile.app (COPY flattened to /app/.output) |
test |
aws_lambda (e2e: node-server) |
.output/test |
sam:build test, docker:test, CI e2e |
production |
aws_lambda |
.output/production |
sam:build production / CD prod |
E2E keeps SYS_ENV=test identity and still lands in .output/test/ when NITRO_PRESET=node-server. @tgmc/web build inputs include SYS_ENV and NITRO_PRESET so Nx caches do not collide.
Workflows
| Workflow | Purpose |
|---|---|
.github/workflows/ci.yml |
PR-only local parity (Nx affected + Nx Cloud, Node 24, SYS_ENV=local → .output/local). Skips version-bump-only PRs. On PRs to development, when web/docker are affected, also runs SYS_ENV=development npx nx run @tgmc/web:build (.output/development). On PRs to main, also runs Full regression (e2e) via npm run e2e:regression with SYS_ENV=test identity on node-server (serves .output/test; no Docker in GHA; SQLite via E2E_STORE_SYS_ENV=local); job timeout-minutes: 35. Exposes deployable via workflow_call. |
.github/workflows/cd-development.yml |
On push to development: lightweight deployable gate + SAM dry-run only (no full CI, no deploy). |
.github/workflows/version-bump.yml |
Push → development: patch-only bump PR (auto-merge squash). PR → main: minor|major bump PR into the release PR head (auto-merge squash; no direct push to protected branches). |
.github/workflows/cd-aws.yml |
On main (or manual): deployable gate → SAM deploy to test + S3 sync + smoke. Does not re-run full CI (CI already ran on the PR). Manual workflow_dispatch always deploys. |
.github/workflows/cd-aws-prod.yml |
On Release published (non-prerelease): SAM deploy production from the release tag + Environment reviewers. Manual workflow_dispatch still requires confirm=deploy. |
.github/workflows/update-agents.yml |
Path-filtered AGENTS.md regen on development/main (Node 24, contents: write) |
.github/workflows/pages.yml |
Publishes docs/ to GitHub Pages (Jekyll Cayman) on push to development/main. The github-pages environment must allow both branches (npm run gh:setup-environments); otherwise the deploy job fails immediately with empty logs. |
Retired (outside four-env topology; files removed):
.github/workflows/ci-github-pages.yml— staging → GitHub Pages.github/workflows/cd.yml— generic artifact pack (superseded by CI local-parity + SAM CD).github/workflows/docker-build.yml— GHCR image publish (Compose is local-only)
CI skip rules (version-bump PRs)
ci.yml short-circuits (job still succeeds — required status checks stay green) when any of:
- Head branch starts with
ci/version-bump- - PR title contains
[skip ci]or[ci skip] - PR has label
skip-ci
Automated bump PRs use the ci/version-bump-* branch + skip-ci label. Do not put [skip ci] in the commit message: GitHub skips the whole workflow for that HEAD commit, so required checks never report and auto-merge stays blocked.
Version-bump PRs enable gh pr merge --auto --squash after create so they land once Local parity (short-circuit) is green.
Automatic semver bumps
scripts/resolve-version-bump.mjs scans conventional commits and applies a channel policy:
| Channel | When | Policy |
|---|---|---|
development |
Push/merge to development |
Any releasable change → patch only; chore/docs/… → none |
main |
PR targeting main |
major if breaking; otherwise minor (patch elevated to minor) |
full |
Default / tests | Raw conventional level (patch/minor/major/none) |
Raw conventional mapping (before channel policy):
- none — only
chore/docs/ci/test/style - patch —
fix,perf,refactor,build,deps - minor —
feat - major —
BREAKING CHANGEfooter or!in the conventional header
development path: open PR ci/version-bump-{version} → base development, enable auto-merge (squash).
main path: open PR ci/version-bump-{version} → base = release PR head (usually development), enable auto-merge (squash). Same-repo PRs only; rulesets block direct pushes to development/main.
Not bumped: theme packages (@tgmc/theme*), packages/*, and web-e2e — they keep independent versions.
Loop prevention: github-actions[bot] guards, ci/version-bump-* + skip-ci label for CI short-circuit. Commit messages omit GitHub’s [skip ci] token so required workflows still run (needed for auto-merge).
How test CD works (no CI re-run)
cd-aws.yml on push to main:
- Job
deployable-gate—scripts/ci-is-deployable.mjs(Nx affected + path allowlist);workflow_dispatchforcesdeployable=true - Job
deploy-test—needs: deployable-gate,environment: test, runs whendeployable=true
Full lint/test/build already ran on the PR. Require status checks on the main ruleset (Local parity, Full regression (e2e)) so a squash merge cannot land without green PR CI. Squash creates a new SHA on main, so CD intentionally does not look up check-runs by commit.
Skip-CI trap on main squash
GitHub skips every workflow on a push when any commit message in that push contains [skip ci] / [ci skip] / [no ci] / [skip actions] / [actions skip].
main is squash-only. If the repository squash setting is Commit messages (COMMIT_MESSAGES), GitHub pastes every commit subject from the PR into the squash body. Old version-bump commits still say chore: bump version to … [skip ci], so a development → main release squash silently suppresses CD AWS Test (and Pages / Update AGENTS). That is why merges after mid-August 2026 produced no Actions runs on main.
Required repo setting (apply with npm run gh:setup-merge-settings):
| Field | Value |
|---|---|
squash_merge_commit_title |
PR_TITLE |
squash_merge_commit_message |
BLANK |
Never put GitHub’s [skip ci] token in commit subjects (version-bump already uses the skip-ci label + branch short-circuit instead). Keep release PR titles free of those tokens.
Manual recovery when CD was skipped: gh workflow run "CD AWS Test" --ref main.
Nx Cloud and affected CI
CI uses Nx Cloud remote cache (nxCloudId in nx.json) and:
npx nx affected -t lint,test,build --parallel=3
after nrwl/nx-set-shas (full git history via fetch-depth: 0). CI also runs node --test on scripts/ci-is-deployable.test.mjs, scripts/resolve-version-bump.test.mjs, scripts/cicd-workflow-policy.test.mjs, scripts/nitro-output-dir.test.mjs, scripts/e2e-regression.test.mjs, scripts/sam-build.test.mjs, scripts/sam-deploy-flow.test.mjs, and scripts/sam-aws-helpers.test.mjs. Lint excludes @tgmc/utilities, @tgmc/web-e2e, @tgmc/theme-foundation, and @tgmc/media-player (pre-existing lint debt / vendor wrappers); their test/build still run when affected.
Repo secret (required): NX_CLOUD_ACCESS_TOKEN — create in Nx Cloud for this workspace and add under GitHub → Settings → Secrets and variables → Actions. CI fails fast if unset. Do not set NX_NO_CLOUD in workflows.
Deployable gate
scripts/ci-is-deployable.mjs sets deployable=true|false from:
- Affected Nx projects in the allowlist (
@tgmc/web, utilities, media-player, likwidlibs, theme packages, web-layer-admin,tgmc-web-layer-base) - Or path changes under
infra/sam/**,docker/**,.github/workflows/**,scripts/sam-*.mjs,scripts/docker-compose.mjs,scripts/ci-is-deployable.mjs(+ its test), rootpackage.json/package-lock.json
Docs/AGENTS/prd-only changes → deployable=false → development SAM dry-run and cd-aws.yml skip. workflow_dispatch always deploys. Production CD: GitHub Release published (non-prerelease) or manual cd-aws-prod.yml with confirm=deploy.
development branch — dry-run only
- No GitHub Environment named
developmentfor Actions (historical GHCR workflow removed). - On push to
development, whendeployable=true, SAM dry-run only (cd-development.yml→scripts/sam-dry-run.mjs:sam validate+sam build+ local public→S3 sync plan) — nosam deploy, no deployment records. - Full CI does not re-run on push (it ran on the PR).
- Real deploys: test on
main(cd-aws.yml), production on Release published or manual (cd-aws-prod.yml).
E2E-only affection (@tgmc/web-e2e) does not force a deploy.
How production approval is enforced
- Release path —
cd-aws-prod.ymlruns onrelease: published(skips prereleases). Deploys the tagged commit (github.event.release.tag_name). Still requires GitHub Environmentproductionreviewers. - Manual path —
workflow_dispatchrequires inputconfirm=deploy - GitHub Environment
production— required reviewers (PROD_REVIEWERS),prevent_self_reviewwhen supported
Recommended release flow
feature → PR → development (CI) → merge
→ CD dry-run + patch version-bump PR → merge bump
→ PR development → main (CI + e2e + minor|major bump PR) → merge bump → squash merge
→ CD AWS Test (auto)
→ tag v{version} on main (match package.json) → GitHub Release (Publish)
→ CD AWS Production (Environment approval) → SAM portfolio-prod
Create the release from a commit on main that has already passed test CD. Tag name convention: v1.0.10 (root package.json version). Prereleases do not auto-deploy; use workflow_dispatch with confirm=deploy if needed.
Local
cp .env.example .env # SYS_ENV=local
npm run db:migrate:local
npm run dev # or npm run build
Development (Docker — local only)
Docker Compose is for local Postgres + web/api stacks and Lambda test parity. Compose images are not built or used on GitHub Actions (no Docker in GHA). Full regression (e2e) on PRs to main uses npm run e2e:regression: SYS_ENV=test identity, NITRO_PRESET=node-server, SQLite stores, Cypress on :4200, server entry .output/test/server/index.mjs. Do not set job-level NODE_ENV=production before npm ci — that omits root devDependencies (e.g. @nx/vite) and breaks postinstall → nuxt prepare.
cp .env.development.example .env.development
npm run docker:build:dev # ensureNitroOutput(development) then web + api images
npm run docker:dev # postgres + api (:4100) + web (:4200); exclusive with docker:local on :4200
npm run docker:dev:down
npm run docker:local # SQLite via ./data; exclusive with docker:dev on :4200
npm run docker:local:down
# Full regression (same path as CI — no Docker required)
npm run e2e:regression
Optional hot-reload profile (does not replace the built stack):
docker compose -f docker/docker-compose.dev.yml --project-directory . --profile hotreload up
Build images locally with npm run docker:build:dev (docker/Dockerfile.app with Compose args for api :4100 / web :4200). See docker/README.md.
Test / Production (SAM)
Canonical AWS region: us-west-2 (samconfig, CD workflows, .env.*.example).
npm run sam:build # SYS_ENV=test → stage + sam build (fails hard unless SAM_BUILD_SOFT=1)
npm run sam:deploy:test
node scripts/sam-build.mjs production
npm run sam:deploy:prod
Local machines without the SAM CLI can set SAM_BUILD_SOFT=1 to stage Nitro output and continue; CI/CD must never set this. Windows: install SAM via winget install -e --id Amazon.SAM-CLI (or the 64-bit MSI); enable long paths; see infra/sam/README.md.
Static assets (CloudFront + private S3)
Test (portfolio-test): hashed assets publish to the shared HyperActivity/portfolio CDN (shared-cdn-test stack → s3://shared-cdn-test-assets-305052780274/ + https://d3sr1gndi209fc.cloudfront.net). Bucket root only (no portfolio/ prefix). Sync never uses --delete (would remove HyperActivity objects; publisher IAM also omits s3:DeleteObject). Do not invalidate /* on that distribution. Stack-owned AssetsBucket / AssetsDistribution remain until cutover is fully verified; Lambda NUXT_*_CDN_URL imports shared-cdn-test-url.
Production (portfolio-prod): exclusive private S3 + CloudFront on the portfolio stack; sync uses --delete.
CD (scripts/sam-cd-deploy.mjs) and local npm run sam:deploy:*:
- Resolves
NUXT_APP_CDN_URL: GitHub var → else shared-cdn-test export (test) / stackAssetsCdnUrl(prod) → else empty on pass 1 - Builds Nuxt with that CDN (if known), deploys the stack, then on first deploy rebuilds when CDN was unknown and redeploys
- Syncs
infra/sam/.aws-sam/build-src/public→ the CDN bucket root (no prefix). Test: shared bucket without--delete. Prod: stack assets bucket with--delete. Source is Nitro.output/publicfromscripts/sam-build.mjs. - Lambda runtime receives the same CDN URL from the template (
NUXT_APP_CDN_URL/NUXT_PUBLIC_CDN_URL)
Local sync plan: npm run sam:sync:dryrun or node scripts/sam-sync-public.mjs --dryrun --shared. Docker docker:test does not emulate S3/CDN.
Set GitHub Environment test vars NUXT_APP_CDN_URL / NUXT_PUBLIC_CDN_URL to https://d3sr1gndi209fc.cloudfront.net so builds skip bootstrap guessing. Production still uses stack AssetsCdnUrl until a shared-cdn-production cutover is requested.
Post-deploy smoke: CD curls GET {HttpApiUrl}/api/greet (HttpApiUrl uses the HTTP API $default stage — no /test or /prod path prefix).
Admin token
GitHub Environment secret NUXT_ADMIN_TOKEN is passed into the Nuxt build and as SAM parameter AdminToken → Lambda NUXT_ADMIN_TOKEN / ADMIN_TOKEN. Empty = admin writes fail closed.
CORS
SAM parameter CorsAllowOrigin (default *). For production, set GitHub Environment var CORS_ALLOW_ORIGIN to the front-door origin(s) you serve from (single origin string). Leaving * in prod widens blast radius if a browser client ever carries Authorization.
Details: infra/sam/README.md.
GitHub Environments + Rulesets setup
# Environments (test + production)
PROD_REVIEWERS=your-github-user npm run gh:setup-environments
# Branch Rulesets (PR-only development/main; print checklist, optional apply)
npm run gh:setup-rulesets
APPLY_RULESETS=1 npm run gh:setup-rulesets
Environments
Creates/updates:
test— deployment branch policy formaingithub-pages— add deployment branchesdevelopmentandmain(environment is GitHub-managed; script does not PUT it)production— required reviewers (PROD_REVIEWERS),prevent_self_review, no branch deploys
Required reviewers need a GitHub plan that supports Environment protection rules (Team/Enterprise for private repos). On plans that return HTTP 422 for reviewers, keep the production branch/tag policy and gate prod with workflow approval in the Actions UI (and/or confirm=deploy on workflow_dispatch). The release: published path still runs under the production Environment.
Rulesets (branch protection)
Scripts: scripts/setup-github-rulesets.mjs. Recommended:
| Ruleset | Refs | Key rules |
|---|---|---|
devenv |
refs/heads/develop* |
Require PR; block force-push/delete; required check Local parity |
main |
refs/heads/main |
Require PR; squash only; conversation resolution; checks Local parity + Full regression (e2e) |
Admin/RepositoryRole bypass remains available for emergencies. If APPLY_RULESETS=1 returns 403/422, apply the checklist printed by the script in Settings → Rules → Rulesets.
Also create label skip-ci (the ruleset script attempts this) for version-bump PR detection.
Secrets / vars (per environment)
Repository-level (Actions):
| Name | Where | Notes |
|---|---|---|
NX_CLOUD_ACCESS_TOKEN |
Repository Actions secret | Nx Cloud access token for CI remote cache |
Prefer OIDC (no long-lived keys) for AWS deploy roles:
| Name | Where | Notes |
|---|---|---|
AWS_ROLE_ARN |
Environment secret | IAM role trusted by repo:OWNER/REPO:environment:NAME |
AWS_REGION |
Environment variable (preferred) or secret | Default us-west-2 if unset |
NUXT_ADMIN_TOKEN |
Environment secret | Blog admin Bearer secret (test + production) |
OPENAI_API_KEY |
Environment secret (optional) | Required only when AI_LAB_LIVE_ENABLED=1 |
AI_LAB_SIGNING_SECRET |
Environment secret (optional) | HMAC for AI Lab continuation tokens; required with live mode |
NUXT_APP_CDN_URL |
Environment variable | Test: https://d3sr1gndi209fc.cloudfront.net (shared-cdn-test). Prod: stack AssetsCdnUrl or override. |
CORS_ALLOW_ORIGIN |
Environment variable (optional) | HTTP API CORS origin; default *. Set for production. |
OIDC bootstrap (account once)
- Create the GitHub OIDC provider:
https://token.actions.githubusercontent.com(client IDsts.amazonaws.com). - Create two roles with
sts:AssumeRoleWithWebIdentity, conditioned on:repo:OWNER/REPO:environment:testrepo:OWNER/REPO:environment:production
- Attach a deploy policy covering CloudFormation, SAM-managed services (Lambda, API Gateway, S3, DynamoDB, CloudFront, Logs), and IAM role management for
CAPABILITY_IAM. - Set each Environment secret
AWS_ROLE_ARNto the matching role ARN. CD workflows already requestid-token: write.
Trust policy sketch for the deploy role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::ACCOUNT:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:OWNER/REPO:environment:test"
}
}
}
]
}
Use separate roles (or separate sub conditions) for environment:test and environment:production.
Node version
Floor: Node.js 24+. Local and tooling may use Node 24 through Current. Deploy/CI stay on 24 for Lambda and image parity.
| Layer | Pin |
|---|---|
GitHub Actions (ci, cd-*, version-bump, update-agents) |
node-version: 24 |
Docker (Dockerfile.app / dev / production) |
node:24-bookworm-slim |
| SAM Lambda | nodejs24.x |
| Local tip | .nvmrc → 24 (or any >=24) |
package.json engines + .npmrc |
>=24 + engine-strict=true |
Node 20 is not supported. Installs fail under engine-strict if the runtime is below 24.
Why CI/Docker/Lambda stay on 24: Node 24 is Active LTS and available as nodejs24.x on Lambda. Newer Current releases (e.g. 26) are fine locally when engines allows >=24, but are not an AWS Lambda managed runtime yet. Node 20 Lambda runtime reached deprecation Phase 1 on 2026-04-30.
Nitro’s aws_lambda handler is async — compatible with Lambda’s Node 24 removal of callback-style handlers.