Skip to content

Research 003 — CI wiring ​

Status: complete

Step 1.5 output, written between the spec draft and the approval gate. All three [NEEDS VERIFICATION] markers in spec.md (V1 on R5/R9, V2 on R6, V3 on R8) have a verdict here, so this file is complete. The Node-version question (V3) also carried a human decision, recorded below. No [NEEDS CLARIFICATION] markers remained open in the spec.

Verified against, on 2026-07-24: Node v26.5.0 (local dev runtime), pnpm 11.15.1, @esbuild/* 0.21.5, VitePress 1.6.4, Vitest 4.1.10, Biome 2.5.5. Outside-world claims cite pnpm's and GitHub's own docs as of that date. Local evidence is from running the repo's own scripts on this machine (darwin-arm64); where a claim depends on the Linux CI runner, that is called out as a residual, since the first real CI run is the only place it is finally observable (which is why the spec treats SC1–SC3 as observational).

V1 — Does pnpm install --frozen-lockfile complete non-interactively in CI, and does esbuild's build script run so docs:build works? ​

Question. R5/R9 assume a frozen-lockfile install neither hangs on a build-consent prompt nor fails, given the esbuild entry under allowBuilds in pnpm-workspace.yaml, and that esbuild is consequently usable so the VitePress docs:build succeeds on a clean runner.

Verdict. Confirmed. The install is non-interactive by construction, esbuild is correctly allowlisted, and the build works. One consequence becomes a standing guard (see Caveat).

Evidence.

  • allowBuilds is the current, valid pnpm 11 setting. It replaced onlyBuiltDependencies, neverBuiltDependencies, ignoredBuiltDependencies, onlyBuiltDependenciesFile, and ignoreDepScripts, all removed in pnpm 11 (pnpm.io/settings; pnpm.io/blog/releases/11.0). So pnpm-workspace.yaml's allowBuilds: { esbuild: true } is correct, not stale — it approves esbuild's postinstall.
  • pnpm has no interactive build prompt during install. Approval is a separate command (pnpm approve-builds), so a non-TTY CI run cannot block on consent (pnpm.io/cli/approve-builds).
  • strictDepBuilds defaults to true: an un-allowlisted build script makes the install exit non-zero (ERR_PNPM_IGNORED_BUILDS), not merely warn (pnpm.io/settings). Because esbuild is allowlisted, the install passes.
  • Empirical, this machine: pnpm install --frozen-lockfile → Already up to date / Done in 163ms, exit 0, no prompt, no ERR_PNPM_IGNORED_BUILDS.
  • Empirical: pnpm docs:build → build complete in 3.90s, exit 0 — esbuild ran and VitePress built.
  • The lockfile carries every Linux platform binary esbuild needs — @esbuild/linux-x64@0.21.5, @esbuild/linux-arm64@0.21.5, and the rest of @esbuild/linux-*@0.21.5 (pnpm-lock.yaml) — so the runner installs the correct binary for its architecture.

Caveat. Two residuals. (1) The build was observed on darwin-arm64, not on the Linux runner; the lockfile evidence makes the Linux binary a near-certainty, but the first CI run is the final proof (SC1–SC3 are observational for exactly this reason). (2) strictDepBuilds: true is a standing guard, not just a one-time pass: any future dependency that needs a build script must be added to allowBuilds or CI's --frozen-lockfile install will fail. This is desirable (a strict gate against unreviewed postinstalls) but the plan and docs/ must record it so a future red install is understood, not mistaken for a flake.

V2 — What is the correct wiring for pnpm plus actions/setup-node caching? ​

Question. R6 assumes the pnpm store can be cached via actions/setup-node's cache: 'pnpm', and rests on getting the action order and version pins right rather than guessing.

Verdict. Confirmed, and better than assumed: the pnpm version need not be pinned in the workflow at all — it is read from package.json, which removes a whole class of drift.

Evidence.

  • pnpm's official CI guide (pnpm.io/continuous-integration) shows the canonical order: install pnpm first with pnpm/action-setup, then actions/setup-node with cache: 'pnpm', which caches the store keyed on pnpm-lock.yaml. Putting setup-node first breaks the cache (pnpm not yet on PATH).
  • pnpm/action-setup reads the pnpm version from the packageManager field in package.json when its version input is omitted (github.com/pnpm/action-setup README). The repo declares "packageManager": "pnpm@11.15.1" (package.json), so omitting version makes that field the single source of truth — the workflow and local dev cannot drift on pnpm.
  • Current action majors, verified against the registries on 2026-07-24: actions/checkout@v7.0.1, pnpm/action-setup@v6, actions/setup-node@v7.0.0. The workflow pins these majors; pinning choice (major tag vs commit SHA) is a plan detail, not a spec claim.

Caveat. cache: 'pnpm' caches the store; it does not skip installation. The saved time is the download, not the link step — real but modest at this repo's size, which is consistent with keeping the cache anyway because CI minutes are exactly where caching earns its place (contrast the app-runtime "no Redis yet" posture in stack.md).

V3 — What concrete Node version should CI pin, given engines.node is only a floor? ​

Question. R8 assumed CI would "pin a node-version and test it satisfies engines.node". Discovery had to establish what value to pin and how a floor is tested against a concrete pin.

Verdict. Resolved — partly by reality, partly by a human decision. Reality: the repo pins no concrete Node version anywhere, so true CI↔local Node parity is not achievable as the spec implied. Decision (the human, this session): defer unified Node pinning to a future mise adoption; for now CI pins a concrete major that satisfies the floor, and Node parity with local is explicitly not a promise of this spec. This revises spec P2/R8/SC5 (see Refuted claims).

Evidence.

  • The only Node version declared in the repo is engines.node: ">=22.18" — a floor, not a version (package.json). The local dev runtime is Node v26.5.0 (node --version; also recorded in docs/architecture/monorepo.md). No .nvmrc, .node-version, .tool-versions, or volta/ devEngines field exists to name a concrete version.
  • actions/setup-node can read package.json via node-version-file, resolving in order volta.node → devEngines.runtime → engines.node (github.com/actions/setup-node advanced-usage). But a range like ">=22.18" resolves to the newest satisfying version at run time — non-deterministic over calendar time (26.x today, higher later) and it tests "latest", not a pinned version. So reading the range does not give parity or reproducibility.
  • The human's rationale: mise will define one Node version for every environment (local and CI) in a later spec, so investing in a .node-version single-source-of-truth now is throwaway. Chosen pin for the interim: the floor line 22, so CI verifies the project's published floor (catching accidental use of a newer-than-supported Node API that would pass on local 26 but break the contract).

Caveat. With CI on 22 and local on 26, the two genuinely differ until mise unifies them — that gap is accepted, not hidden. The pnpm half of the anti-drift story (V2) stands regardless; only the Node half is deferred.

F1 — The workflow-gate invariant turned CI red from the moment any spec is scaffolded (root-caused and fixed) ​

Not asked about, but load-bearing — it is why pnpm test was red on this very branch, and it would have made "CI green before merge" hollow.

What happened. On the freshly scaffolded 003 branch, pnpm test failed one test: repo-invariants.test.ts > SC7 — no artifact runs ahead of its gate, reporting plan.md is written but spec.md is "draft" and tasks.md is written but plan.md is "proposed".

Root cause. Two spec-001 components disagreed on a baseline. scripts/new-spec.ts substitutes NNN/[slug] into every scaffolded artifact (tested, intended), so a pristine plan.md heading is already # Plan 003. But isFilledIn measured "written" as divergence from the raw template, its own comment asserting the false premise that the scaffold "leaves them identical to the template." It does not — it leaves them substituted. So every pristine scaffolded plan.md/tasks.md read as "written" while its gate was still closed, turning the repo red for the entire authoring phase of every spec. 001 and 002 carried this silently because there was no CI to notice; wiring CI is what surfaced it.

Resolution. Fixed outside this spec under the constitution's bug-fix exception (systematic debugging → failing regression test → fix → verify; no spec directory). Commit 2b41bbb on main: a pure divergesFromScaffold(content, template, number, slug) beside substitutePlaceholders measures divergence from the substituted baseline, and isFilledIn derives number/slug from the directory name and delegates to it. RED (3 unit tests failed: function missing) → GREEN → REFACTOR (rewired the invariant). Integration-proven: with the fix plus a pristine scaffolded 003 present, all 6 repo-invariants tests pass. main fast-forwarded to the fix; 003 rebased onto it.

Why it matters. It makes this spec's central promise real: a spec branch is now green from scaffold onward, so a red CI run means a genuine failure, not the workflow tax. It also closes a latent SC8 false-pass (a substitution-only research.md would have counted as "step 1.5 done"). The interaction it resolves is recorded so the CI spec can assume a green baseline.

F2 — "All checks pass locally" was false at discovery, and is true only because of F1 ​

What happened. The spec's Assumptions stated "the checks already exist and pass locally." At discovery that was false: lint ✅, typecheck ✅, docs:build ✅, but test ❌ (the F1 gate red).

Resolution. After F1's fix, all four are green on the 003 branch, verified together with the scaffolded artifacts present: pnpm typecheck exit 0; pnpm lint exit 0; pnpm test → 72 passed, 1 expected-fail. The spec Assumption is corrected to state this and to reference the F1 fix as the reason it holds — the claim was made true by fixing reality, not by patching the spec (see Refuted claims).

Why it matters. It is the precondition for SC2 ("a PR where every check passes produces a green run"): the first green CI run is now demonstrable on 003's own merge-ready state rather than being structurally blocked.

F3 — pnpm lint passes with warnings; CI will not fail on them ​

What happened. biome check . exits 0 while reporting 6 warnings and 2 infos (pre-existing, in .vitepress/config.mts and tests/workflow/settings.test.ts). So the CI lint step will be green despite them.

Why it matters. If the team ever wants warnings to block CI, that is an explicit --error-on-warnings decision, not the default. This spec keeps CI's lint identical to local pnpm lint (green on warnings), per R4 ("CI runs exactly what the developer runs"). Recorded so the choice is deliberate, not accidental. (One newly added long line in scaffold.test.ts was reflowed by biome check --write during the F1 fix so it, too, stays green.)

Refuted claims ​

Two, both refined rather than the spec being scrapped — the false premises were narrow and their corrections are localised.

  1. Assumption "all checks pass locally" — refuted at discovery (F2: test was red). Made true by fixing reality (F1, commit 2b41bbb), then the Assumption is reworded to state it holds because of that fix and that CI is green from scaffold onward. This is the honest form of the constitution's "fix reality, don't paper over it."
  2. P2 / R8 / SC5 Node-parity — the spec implied CI would run "the same versions I develop against" for both pnpm and Node. True for pnpm (V2, via packageManager); not achievable for Node as written (V3: no concrete Node pin exists; local is 26, floor is 22). Corrected: P2's anti-drift promise is scoped to pnpm (enforced) plus a Node pin that satisfies the floor; Node↔local parity is deferred to the future mise spec. These are the step-1 revisions this discovery sends back (proposed to the human before editing spec.md).

Graduation ​

Candidates for docs/architecture/ at step 6 (a new docs/architecture/ci.md, or an addition to workflow-tooling.md), so spec 004+ does not re-derive them:

  • The CI shape — one workflow, pull_request + push:main, single fail-fast job running lint / typecheck / test / docs:build; pnpm cached via setup-node; superseded runs cancelled (V2).
  • pnpm version is drift-proof via packageManager; pnpm/action-setup reads it (V2). Node version is pinned to the floor line until mise owns it (V3) — a forward pointer for the mise spec.
  • strictDepBuilds: true is a standing gate — new build-script deps must be added to allowBuilds or CI install fails (V1 caveat).
  • "CI green before merge" is now green-from-scaffold, because F1's fix removed the authoring-phase false-red — the property that makes the CI signal trustworthy (F1).

The F1 fix itself already graduated as a code change on main; it needs no doc entry beyond the note that CI is green from scaffold onward.