Skip to content

Spec 003 — CI wiring ​

Status: implemented Branch: 003-ci-wiring

Status is set by the human, never by the agent. It moves draft → approved → implemented.

Problem ​

docs/architecture/stack.md commits the project to "CI: GitHub Actions — lint + typecheck + test + build" and to "CI green before merge", but no .github/ exists. Specs 001 and 002 built the local workbench — one command each for lint, typecheck, test, plus a VitePress docs build — yet nothing runs them automatically on a change, and nothing stops a red branch from reaching main. Every check is a thing the human must remember to run.

This spec wires the first continuous-integration pipeline: one GitHub Actions workflow that runs the checks that already exist locally, on every pull request and every push to main, and the enforcement that makes a green run a precondition for merge. It is verification plumbing, not product — the checks it runs were built by 001 and 002; this spec makes a machine run them at the right moment and record the verdict.

User stories ​

Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.

P1 — Every change is verified automatically, before it reaches main ​

As the developer, I want each pull request (and each push to main) to automatically run lint, typecheck, test, and the docs build on a fresh machine — stopping at the first failure — so that I catch a broken change from the PR page instead of remembering to run four commands by hand, and so that main itself is re-checked after every merge.

Independent test: push a branch that breaks one check (e.g. a lint violation), open a PR against main, and observe a red CI run on the PR whose failing step names that check; push a fix and observe the run go green.

Acceptance scenarios

  1. Given the workflow on main, when a pull request is opened or updated against main, then a CI run starts that installs dependencies once and runs lint, typecheck, test, and docs:build in that order on a single fresh runner.
  2. Given a pull request whose lint step fails, when CI runs, then the run is red, the lint step is marked failed, and the later steps (typecheck, test, docs:build) do not run — fail-fast.
  3. Given a pull request where every check passes, when CI runs, then the run is green.
  4. Given a commit pushed directly to main (e.g. the result of a merge), when CI runs, then the same four checks run against main and report a pass/fail status on that commit.
  5. Given a pull request that is updated twice in quick succession, when the second update arrives, then the run for the superseded commit is cancelled rather than left running to completion.

P2 — CI's toolchain cannot silently drift from what the repo declares ​

As the developer, I want CI's pnpm and Node versions tied to what the repo already declares — pnpm read straight from package.json's packageManager, Node pinned to a version that satisfies engines.node — and guarded by a test, so that "passes on my machine, passes in CI" holds for the toolchain and a forgotten bump fails the suite loudly rather than quietly testing a different runtime. Full Node↔local parity is out of scope here; it arrives when mise pins one Node version for every environment (see Out of scope).

Independent test: with the workflow committed, run pnpm test; a test parses .github/workflows/ci.yml and asserts (a) it does not hardcode a pnpm version that conflicts with packageManager, and (b) the Node version it pins satisfies engines.node. Break either and the test fails.

Acceptance scenarios

  1. Given the committed workflow, when the CI structural test runs, then it confirms the workflow triggers on pull_request and on push to main, runs exactly the four checks in order, and contains the dependency cache and the concurrency-cancellation block.
  2. Given the committed workflow, when the CI structural test runs, then it confirms the pnpm version comes from packageManager (not a conflicting hardcoded pin) and the Node version CI pins satisfies engines.node.
  3. Given the workflow file is malformed or a check is removed, when pnpm test runs, then the CI structural test fails and names what is missing.

P3 — A red run cannot be merged ​

As the developer, I want a red CI run to block the merge button on a pull request into main, so that "CI green before merge" is enforced by the platform rather than by my discipline.

Independent test: with branch protection configured on main, open a PR with a failing check and confirm GitHub disables merging until the check passes; this is a one-time human verification, recorded with its date, because the setting lives in GitHub, not in the repo.

Acceptance scenarios

  1. Given a branch protection rule on main that requires the CI check, when a pull request has a red or pending CI run, then the merge is blocked until the check reports success.
  2. Given the same rule, when a pull request's CI run is green, then the merge is permitted.

Requirements ​

  • R1 — The pipeline is a single GitHub Actions workflow file at .github/workflows/ci.yml. It is the only workflow this spec adds.
  • R2 — The workflow triggers on pull_request (any branch targeting main) and on push to main, and on no other events. push is scoped to main so an in-repo feature branch fires only the pull_request run, not a duplicate push run.
  • R3 — The checks run as a single job of sequential steps on one fresh Linux runner, in this order: checkout → set up pnpm and Node → install dependencies → lint → typecheck → test → docs:build. A failing step stops the job — fail-fast, no parallel jobs.
  • R4 — Each check invokes the existing root pnpm script (pnpm lint, pnpm typecheck, pnpm test, pnpm docs:build) rather than reimplementing the command inline, so CI runs exactly what the developer runs locally and the two cannot diverge in behaviour.
  • R5 — Dependencies are installed with a frozen lockfile (pnpm install --frozen-lockfile), so CI fails on a lockfile that is out of date rather than silently resolving new versions. Confirmed — research.md V1: allowBuilds is the valid pnpm 11 setting, pnpm has no interactive build prompt, and the install passes because esbuild is allowlisted. strictDepBuilds defaults true, so any future build-script dependency must be added to allowBuilds or the install fails — a standing guard.
  • R6 — The pnpm store is cached across runs, keyed on pnpm-lock.yaml, so an unchanged lockfile yields a near-instant install and a changed one refreshes the cache automatically. Confirmed — research.md V2: install pnpm via pnpm/action-setup before actions/setup-node, which does the caching through cache: 'pnpm'. The pnpm version is not pinned in the workflow — action-setup reads it from packageManager (see R8).
  • R7 — The workflow cancels superseded runs: a concurrency group keyed on the workflow and the ref, with cancel-in-progress: true, so a newer push to the same PR stops the older run.
  • R8 — The pnpm version is not pinned in the workflow: pnpm/action-setup reads it from packageManager (pnpm 11.15.1), making that field the single source of truth so pnpm cannot drift. The Node version is pinned to a concrete major that satisfies engines.node — the floor line, 22 — which verifies the project's published floor; CI↔local Node parity is deferred to a future mise spec (see Out of scope). An automated test asserts (a) the workflow does not hardcode a pnpm version that conflicts with packageManager, and (b) the pinned Node version satisfies engines.node (see R11). Decided — research.md V2 (pnpm) and V3 (Node).
  • R9 — Dependencies for esbuild's build step are approved non-interactively in CI, consistent with pnpm-workspace.yaml's existing allowBuilds entry, so the VitePress docs:build — which depends on esbuild — succeeds on a clean runner. Confirmed — research.md V1: docs:build ran green locally and every @esbuild/linux-* binary is in the lockfile; the Linux-runner build is the one residual left for the first CI run.
  • R10 — "CI green before merge" is enforced by a branch protection rule (or repository ruleset) on main requiring the CI status check. This rule is a GitHub-side setting, set by the human, not a committed file; the exact setting and the reason it cannot be a repo artifact are documented so the decision is captured (constitution step 6), and it is verified once by hand and recorded with a date.
  • R11 — A test suite under tests/ci/ parses .github/workflows/ci.yml and asserts its shape, mirroring the existing config-parsing tests under tests/workflow/: valid YAML; the two triggers of R2; the four checks of R3 present and in order; the cache (R6) and concurrency (R7) blocks present; and the Node/pnpm consistency of R8. The genuinely observational criteria — a failing check turns the run red, a red run blocks merge — are not faked as unit tests; they map to a dated manual verification (see Traceability).
  • R12 — No artifact is written under any docs/superpowers/ path, and the conformance suites from specs 001 and 002 continue to pass unchanged.

Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:

  • [NEEDS CLARIFICATION: specific question] — only the human can answer. A product decision, a scope boundary, a preference. Blocks step 1.5.
  • [NEEDS VERIFICATION: specific question] — only reality can answer. Whether the codebase works that way, whether an endpoint returns that field, whether that number is achievable. Answered in research.md with cited evidence, never by assumption. Blocks the approval gate.

Any success criterion stating a number carries a [NEEDS VERIFICATION] until a measurement in research.md backs it. An unbacked number is a guess wearing a criterion's clothes.

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation. CI is inherently GitHub Actions here (see Assumptions), so the criteria name the outcome — a run's colour, a blocked merge, a passing test — rather than the YAML that produces it.

  • SC1 — A pull request in which any of lint, typecheck, test, or docs-build fails produces a red CI run whose failed step identifies the failing check, and the steps after it do not run. (observational — verified on the first real run, recorded with a date)
  • SC2 — A pull request in which every check passes produces a green CI run. (observational — verified on the first real run, recorded with a date)
  • SC3 — A push to main runs the same four checks and reports a status on that commit. (observational — verified on the first real run, recorded with a date)
  • SC4 — The workflow triggers on exactly pull_request and push-to-main, runs exactly the four checks in the specified order, and contains the cache and concurrency blocks — asserted by an automated test over the committed workflow file.
  • SC5 — CI's pnpm version comes from packageManager (not a conflicting hardcoded pin), so a pnpm bump is picked up automatically; and CI's Node pin satisfies engines.node, so a floor raised above the pin fails the suite — both asserted by an automated test.
  • SC6 — With branch protection configured, a pull request whose CI run is red or pending cannot be merged to main, and one whose run is green can. (observational — one-time human verification after the rule is set, recorded with a date)
  • SC7 — The count of files under any docs/superpowers/ path stays zero, and specs 001 and 002 suites still pass — asserted by the existing invariants.
  • SC8 — Every acceptance scenario and success criterion above maps either to a named automated test or to a dated manual-verification record, with no gap; the automated portion passes.

Out of scope ​

  • A live PR preview environment for the docs. This spec builds the docs as a check (docs:build must succeed) but hosts nothing — no per-PR URL, no deploy, no PR bot comment. A browsable preview environment is a deliberately separate, later spec, because it introduces hosting, secrets, and PR commenting into what is otherwise pure verification. Decided during brainstorming (option C, deferred).
  • App build steps. apps/web (React) and apps/api (NestJS) do not exist yet; building them is not a check this workflow can run. Each app's build enters CI with that app's own spec. The "+ build" in the stack doc's CI line is satisfied here by the docs build; the app builds arrive with the apps.
  • Deploy / release automation. Deploying to the managed PaaS (Fly.io / Railway, per the stack doc) is a separate concern from integration; no deploy job, environment, or secret is added here.
  • A Node version matrix. CI runs one Node version — the project's pinned one — not a matrix. The project targets a single runtime; testing several is speculative until there is a reason to support more than one.
  • Unified Node version pinning (mise). A single pinned Node version shared by local and CI — via mise — is deferred to its own spec. Until then CI pins the engines.node floor line and makes no Node↔local parity claim (research.md V3). This is what lets P2 promise pnpm parity but not yet Node parity.
  • Parallel jobs / per-check fan-out. One sequential fail-fast job is deliberate for a repo this size (brainstorming Q4-A). Splitting checks into parallel jobs is a well-understood later refactor once checks grow slow enough to earn it — not this spec's work.
  • Managing branch protection as code. The enforcement rule (R10) is set by hand and documented, not provisioned by a committed script or tool. Declarative branch protection is deferred; the tension with "all artifacts are committed" is acknowledged and captured rather than engineered around now.

Assumptions ​

Like specs 001 and 002, this is a workbench spec: it names its tooling (GitHub Actions, pnpm, actions/setup-node) because choosing that tooling is the point, not an implementation leak. The following are fixed by prior decisions and taken as given.

  • GitHub Actions is the CI platform, and the repo is hosted on GitHub — both stated in docs/architecture/stack.md. The workflow-file location .github/workflows/ and the branch-protection enforcement mechanism follow from that host.
  • The checks exist and pass locally — now. lint, typecheck, test (specs 001/002) and docs:build (VitePress) are root pnpm scripts; this spec runs them remotely, it does not create or change them. At discovery test was red — the workflow-gate invariant misfired on every freshly scaffolded spec — so that was fixed on main first (research.md F1/F2, commit 2b41bbb); all four are green together as of 2026-07-24, so CI can assume a green baseline from scaffold onward.
  • The toolchain is pinned, and its two behavioural unknowns are resolved: Node ≥ 22.18 (engines.node), pnpm 11.15.1 (packageManager), and pnpm-workspace.yaml allows esbuild to run its build script. How pnpm caching wires into setup-node (V2) and whether a frozen-lockfile install clears esbuild's build-approval non-interactively (V1) are confirmed in research.md. One standing consequence: because strictDepBuilds defaults true, any future build-script dependency must be added to allowBuilds or CI's install fails.
  • mise will own Node version pinning later. The project will adopt mise to pin one Node version for every environment; until then this spec pins CI's Node to the engines.node floor and makes no Node↔local parity claim (research.md V3).
  • The in-repo test pattern is established. tests/workflow/* already parse committed config files (constitution.md, settings.json, the spec templates) and assert invariants; the CI structural suite (R11) is the same technique applied to ci.yml, not a new kind of test.
  • No [NEEDS CLARIFICATION] remains. Every product decision — scope of checks, triggers, job shape, caching, enforcement approach, testing depth — was settled in brainstorming. The only open questions are the behavioural verifications above, which reality answers.

Traceability ​

Each acceptance scenario and success criterion maps to a named test or to a dated manual-verification record — the latter only for the inherently observational criteria (a run's colour, a blocked merge) that no in-repo test can prove. SC8 asserts this table has no empty cell: every row resolves to an existing automated test or a recorded manual check. Filled in during implementation.

CriterionTest / verification
P1 #1tests/ci/workflow.test.ts — triggers + four ordered checks
P1 #2manual — docs/architecture/ci.md log: red on a broken check, later steps skipped
P1 #3manual — docs/architecture/ci.md log: green when all checks pass
P1 #4manual — docs/architecture/ci.md log: push to main reports a status
P1 #5tests/ci/workflow.test.ts — concurrency cancel-in-progress
P2 #1tests/ci/workflow.test.ts — triggers, ordered checks, cache + concurrency
P2 #2tests/ci/workflow.test.ts — Node satisfies engines.node; pnpm from packageManager
P2 #3tests/ci/workflow.test.ts — a removed check fails the ordered-checks assertion
P3 #1manual — docs/architecture/ci.md log: branch protection blocks a red/pending merge
P3 #2manual — docs/architecture/ci.md log: branch protection permits a green merge
SC1manual — docs/architecture/ci.md verification log
SC2manual — docs/architecture/ci.md verification log
SC3manual — docs/architecture/ci.md verification log
SC4tests/ci/workflow.test.ts — triggers, ordered checks, cache, concurrency
SC5tests/ci/workflow.test.ts — Node/pnpm pins
SC6manual — docs/architecture/ci.md verification log
SC7tests/workflow/repo-invariants.test.ts — docs/superpowers count + specs 001/002
SC8tests/ci/workflow.test.ts — this table complete + named tests exist