Skip to content

Harden the deploy-assembly CI gate: Node-major skew, unpinned engines.node, non-frozen install, staged path untested #816

Description

@bpowers

A pre-deploy audit of the GAE deploy pipeline found several CI/build-gate gaps. PR #810 only pinned engines.pnpm in the staged manifest and did not address any of these. They share a root cause: the CI gate that is supposed to validate what actually deploys does not faithfully reproduce the production runtime or the staged-deploy path, so whole classes of deploy-time breakage are invisible until gcloud app deploy.

These are distinct from the existing deploy tech-debt items (#39 install bloat / staged-deploy implementation, #40 mutating tracked public/) and from the Rust feature-matrix gap (tech-debt.md item 60). This issue is specifically about the CI gate fidelity for the web deploy.

Gap 1 -- Node major skew between the deploy gate and prod

The frontend job that runs the deploy-assembly verification installs Node 22 (.github/workflows/ci.yaml:178, node-version: '22'), but production runs nodejs24 (app.yaml:1 runtime: nodejs24, mirrored by .app.prod.yaml). The artifacts that actually deploy are never built or tested on Node 24. The serve-smoke job in the same workflow already uses Node 24 (ci.yaml:362), so the deploy gate is the odd one out.

Fix: pin the deploy-assembly job to node-version: '24'.

Gap 2 -- No Node version is pinned anywhere (engine-strict is a no-op)

.npmrc sets engine-strict=true, but no package.json declares engines.node -- not the root and not any src/* package. The root only declares packageManager: pnpm@10.6.0 (package.json:12). With no engines.node field, engine-strict has nothing to enforce, so a contributor on the wrong Node major gets no signal.

Fix: add engines.node (e.g. ">=24 <25") to the root package.json and/or add a .nvmrc, so engine-strict actually gates and local/CI Node majors converge on prod's.

Gap 3 -- deploy-assembly CI job installs with a non-frozen lockfile

The frontend job runs pnpm install (ci.yaml:205) WITHOUT --frozen-lockfile, so lockfile drift is silently auto-corrected in the very gate meant to validate the deploy. By contrast serve-smoke uses pnpm install --frozen-lockfile (ci.yaml:378). Lockfile drift that would fail a frozen install at deploy time (or under the GAE buildpack) passes this gate.

Fix: add --frozen-lockfile to the deploy-assembly install.

Gap 4 -- The staged-bundle deploy path is never exercised in CI

The new staged path (scripts/build-deploy-staging.mjs: file copy, pnpm-lock.yaml seed+generate, drift guard, self-verify) is the intended replacement for the old deploy:assemble path, but CI only runs the OLD path: pnpm --filter @simlin/app run deploy:assemble + scripts/verify-deploy-build.sh (ci.yaml:227-231). Only the PURE manifest transforms are unit-tested (scripts/tests/deploy-staging-manifests.test.mjs); the orchestration in build-deploy-staging.mjs (copy, lockfile generation, drift guard, self-verify) has no CI coverage. A regression there surfaces only at deploy time.

Fix: add a CI step that runs build-deploy-staging.mjs and asserts the staged dir is well-formed (expected package.json prod closure, generated pnpm-lock.yaml, vendored @simlin/{core,engine} rewritten to file: deps, app.yaml present).

Why it matters

Production deploys are a manual local command with no gcloud in CI (see docs/dev/deploy.md); the assembly smoke test is the only gate. If that gate runs a different Node major, an auto-healed lockfile, and the wrong (old) assembly path, it provides false confidence -- exactly the failure mode this gate exists to prevent.

Components affected

  • .github/workflows/ci.yaml (deploy-assembly / frontend job ~178, ~205, ~227)
  • .npmrc, root package.json (engines.node)
  • app.yaml / .app.prod.yaml (nodejs24, the source of truth for the target major)
  • scripts/build-deploy-staging.mjs, scripts/deploy-staging-manifests.mjs

Discovery

Identified during a pre-deploy audit of the GAE deploy pipeline, while reviewing the deploy/staged-server-bundle branch (PR #810 scope did not cover these gate gaps).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions