# 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](./web/setup/environment.md). Docker: [docker.md](./docker.md). SAM: [infra.md](./infra.md).

**Portable templates for other projects / AI agents:**

- CI/CD: [templates/nuxt-ssr-cicd/](./templates/nuxt-ssr-cicd/) · [`.agents/skills/nuxt-ssr-cicd-template/`](../.agents/skills/nuxt-ssr-cicd-template/)
- Commit / PR / rulesets: [templates/commit-pr-rules/](./templates/commit-pr-rules/) · [`.agents/skills/commit-pr-rules-template/`](../.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](#skip-ci-trap-on-main-squash) 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](https://github.com/tamaramack/portfolio/blob/development/docker/README.md#topology-map-docker--aws).

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

1. **none** — only `chore`/`docs`/`ci`/`test`/`style`
2. **patch** — `fix`, `perf`, `refactor`, `build`, `deps`
3. **minor** — `feat`
4. **major** — `BREAKING 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-gate` — `scripts/ci-is-deployable.mjs` (Nx affected + path allowlist); `workflow_dispatch` forces `deployable=true`
2. Job `deploy-test` — `needs: 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 `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:

```bash
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](https://cloud.nx.app) 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), root `package.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 `development` for Actions (historical GHCR workflow removed).
- On push to `development`, when `deployable=true`, **SAM dry-run** only (`cd-development.yml` → `scripts/sam-dry-run.mjs`: `sam validate` + `sam build` + local public→S3 sync plan) — no `sam 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

1. **Release path** — `cd-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 path** — `workflow_dispatch` requires input `confirm=deploy`
3. **GitHub Environment `production`** — required reviewers (`PROD_REVIEWERS`), `prevent_self_review` when 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

```bash
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`.

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

```bash
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](https://github.com/tamaramack/portfolio/blob/development/docker/README.md).

## Test / Production (SAM)

Canonical AWS region: **`us-west-2`** (samconfig, CD workflows, `.env.*.example`).

```bash
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](https://github.com/aws/aws-sam-cli/releases/latest/download/AWS_SAM_CLI_64_PY3.msi)); enable long paths; see [infra/sam/README.md](https://github.com/tamaramack/portfolio/blob/development/infra/sam/README.md#windows-setup-recommended).

### 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](https://github.com/tamaramack/portfolio/blob/development/infra/sam/README.md).

## GitHub Environments + Rulesets setup

```bash
# 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 for `main`
- `github-pages` — add deployment branches `development` and `main` (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)

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:

```json
{
  "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.
