Skip to the content.

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:

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: localdocker-compose.local.yml (SQLite ./data + web); developmentdocker-compose.dev.yml (Postgres + web/api); testdocker-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):

CI skip rules (version-bump PRs)

ci.yml short-circuits (job still succeeds — required status checks stay green) when any of:

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):

  1. none — only chore/docs/ci/test/style
  2. patchfix, perf, refactor, build, deps
  3. minorfeat
  4. majorBREAKING CHANGE footer 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:

  1. Job deployable-gatescripts/ci-is-deployable.mjs (Nx affected + path allowlist); workflow_dispatch forces deployable=true
  2. Job deploy-testneeds: deployable-gate, environment: test, runs when deployable=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 developmentmain 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:

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

E2E-only affection (@tgmc/web-e2e) does not force a deploy.

How production approval is enforced

  1. Release pathcd-aws-prod.yml runs on release: published (skips prereleases). Deploys the tagged commit (github.event.release.tag_name). Still requires GitHub Environment production reviewers.
  2. Manual pathworkflow_dispatch requires input confirm=deploy
  3. GitHub Environment production — required reviewers (PROD_REVIEWERS), prevent_self_review when supported
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 postinstallnuxt 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:*:

  1. Resolves NUXT_APP_CDN_URL: GitHub var → else shared-cdn-test export (test) / stack AssetsCdnUrl (prod) → else empty on pass 1
  2. Builds Nuxt with that CDN (if known), deploys the stack, then on first deploy rebuilds when CDN was unknown and redeploys
  3. 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/public from scripts/sam-build.mjs.
  4. 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:

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)

  1. Create the GitHub OIDC provider: https://token.actions.githubusercontent.com (client ID sts.amazonaws.com).
  2. Create two roles with sts:AssumeRoleWithWebIdentity, conditioned on:
    • repo:OWNER/REPO:environment:test
    • repo:OWNER/REPO:environment:production
  3. Attach a deploy policy covering CloudFormation, SAM-managed services (Lambda, API Gateway, S3, DynamoDB, CloudFront, Logs), and IAM role management for CAPABILITY_IAM.
  4. Set each Environment secret AWS_ROLE_ARN to the matching role ARN. CD workflows already request id-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 .nvmrc24 (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.