Plan 003 — CI wiring
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
Make a machine run the checks that already pass locally, at the moment a change is proposed. After this plan, opening a pull request (or pushing to main) starts a GitHub Actions run that installs once and runs lint, typecheck, test, and docs:build in order, stopping at the first failure; a repo test guards the workflow's shape and its toolchain pins against drift; and a documented branch-protection rule makes a green run a precondition for merge. The plumbing is new; the checks it runs are not.
Approach
Three deliverables, in dependency order.
The workflow —
.github/workflows/ci.yml. One workflow, two triggers (pull_requestandpushtomain), one job onubuntu-latest, sequential fail-fast steps. pnpm is installed before Node soactions/setup-node'scache: 'pnpm'can find the store (research V2). The pnpm version is not written in the workflow —pnpm/action-setupreads it frompackageManager, so it cannot drift (research V2). Node is pinned to22, theengines.nodefloor line (research V3). Aconcurrencyblock cancels superseded runs. The full intended file:yamlname: CI on: pull_request: push: branches: [main] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v6 # version read from packageManager - uses: actions/setup-node@v4 with: node-version: '22' cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm typecheck - run: pnpm test - run: pnpm docs:buildThe structural guard —
tests/ci/workflow.test.ts. A Vitest test that parsesci.ymlwith theyamlpackage and asserts its shape, mirroring howtests/workflow/*parse committed config. It covers the automatable criteria (SC4, SC5, and the P1/P2 structural scenarios); the observational criteria (a red run, a green run, a blocked merge) are recorded as dated human verifications, not faked as unit tests (spec R11, Test strategy below). A newtests/ci/directory sits besidetests/workflow/andtests/monorepo/.The enforcement record —
docs/architecture/ci.md. Captures the CI shape, the exact branch-protection setting onmainand why it cannot be a committed artifact (spec R10), and the standing guards discovery surfaced (strictDepBuilds; mise as the future Node-version owner). The human applies the branch-protection rule by hand and records the one-time verification with its date.CLAUDE.md's Reference section gains a link, matching howmonorepo.md/stack.mdare linked.
The two triggers are deliberately non-overlapping: push is scoped to main, so an in-repo feature branch fires only the pull_request run, never a duplicate (spec R2).
Architecture
.github/workflows/ci.yml the pipeline: 2 triggers → 1 job → 4 checks (fail-fast)
│ consumed/verified by
▼
tests/ci/workflow.test.ts parses ci.yml (yaml), asserts triggers, ordered checks,
│ cache, concurrency, and toolchain pins (SC4/SC5)
│ reads for consistency
▼
package.json engines.node (floor) + packageManager (pnpm) — the pins CI derives from
│
docs/architecture/ci.md captures the shape + branch-protection (R10); linked from CLAUDE.mdDirection: the test reads the workflow and package.json; the workflow runs the scripts those files define. Nothing the workflow does feeds back into the repo. The doc records decisions; nothing depends on it at runtime.
Tech stack
- GitHub Actions — the CI platform (
docs/architecture/stack.md). Workflow lives at.github/workflows/ci.yml. - Actions, pinned to major tags for a first, readable workflow:
actions/checkout@v4,pnpm/action-setup@v6,actions/setup-node@v4. (SHA-pinning is the later hardening option — see Risks. Exact current majors confirmed in research V2; the implementer pins to whichever major is current, these are the floor.) - Node
22in CI (theengines.nodefloor line); pnpm11.15.1viapackageManager(research V2/V3). yaml(npm) — NEW devDependency, justified: the structural test parses the workflow as structured data rather than regex-matching raw YAML, which is exactly the brittleness this repo avoids elsewhere. It is the only dependency this plan adds. Its default YAML 1.2 mode also sidesteps theon:→trueboolean gotcha of YAML 1.1 (see Risks). Nosemverdep is added — the Node-floor check is an integer major-version comparison (Test strategy).- Vitest 4.1.10 — the structural test runs under the existing single config (
tests/**/*.test.tsis already ininclude). (Existing dep.)
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: encrypted at rest, never logged, never returned to the client.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
.github/workflows/ci.yml | new | The CI workflow: pull_request + push:main triggers; concurrency cancel-in-progress; one ubuntu-latest job; checkout → pnpm/action-setup → setup-node (node-version: '22', cache: 'pnpm') → install --frozen-lockfile → lint → typecheck → test → docs:build. |
tests/ci/workflow.test.ts | new | Parses ci.yml with yaml; asserts the two triggers, the four checks present and in order, the cache and concurrency blocks, that pnpm's version is not hardcoded to conflict with packageManager, and that the Node pin satisfies engines.node (SC4, SC5, P1 #1, P1 #5, P2 #1–#3). |
package.json | modified | Add devDependency yaml. No script changes — CI calls the existing lint/typecheck/test/docs:build scripts. |
docs/architecture/ci.md | new | Captures the CI shape; the exact main branch-protection setting and why it is a GitHub-side, human-set, non-committed artifact (R10); and the standing guards (strictDepBuilds, mise-owns-Node-later). |
CLAUDE.md | modified | Reference section: add a link to docs/architecture/ci.md. |
specs/003-ci-wiring/spec.md | modified | Fill the traceability table's Test/verification column with the names below (done during implementation, per the template). |
Not changed, and why: the four npm scripts already exist (specs 001/002) and are called as-is (R4); pnpm-lock.yaml already carries every @esbuild/linux-* binary (research V1); .gitignore already ignores .vitepress/dist.
Data & contracts
No runtime types or schemas. The one "contract" is the workflow's parsed shape that the test asserts on — the keys it reads from ci.yml:
on.pull_request defined
on.push.branches includes 'main'
concurrency.cancel-in-progress true
jobs.<job>.steps[].uses includes actions/checkout, pnpm/action-setup, actions/setup-node
jobs.<job>.steps[] (setup-node).with.cache === 'pnpm'
jobs.<job>.steps[] (setup-node).with.node-version major ≥ engines.node floor major
jobs.<job>.steps[] (action-setup).with.version absent, or === packageManager's pnpm version
jobs.<job>.steps[].run the four `pnpm <script>` commands, in orderTest strategy
Each acceptance scenario and success criterion maps to a named test or a dated manual verification; the spec's traceability table records which. By kind:
- Structural, automated —
tests/ci/workflow.test.ts. Parsesci.ymlonce and asserts:- triggers
pull_requestandpush:[main](SC4; P1 #1); - the four checks
lint → typecheck → test → docs:buildpresent and in that order — extracted as the ordered list ofruncommands in the job (SC4; P1 #1); concurrencypresent withcancel-in-progress: true(P1 #5; SC4);cache: 'pnpm'on the setup-node step (SC4);- pnpm no-drift: the
pnpm/action-setupstep has noversioninput, or one equal to the pnpm version inpackageManager(P2 #2; SC5); - Node satisfies the floor: the
node-versionpin's major ≥ the major ofengines.node's floor. A plain integer comparison (parse22from the pin,22from">=22.18"), so nosemverdependency — the check is major-granularity by design, which is enough to catch a raised floor that CI was not updated for (P2 #2; SC5); - a removed check or malformed file makes the relevant assertion fail and name what is missing (P2 #3). No separate negative fixture — the positive assertions above are the guard: delete a
runstep and the ordered-checks assertion fails.
- triggers
- Observational — dated manual verification. SC1 (a failing check → red run, later steps skipped), SC2 (all pass → green), SC3 (push to
mainreports status), and SC6/P3 (a red or pending run cannot be merged once branch protection is set) are only provable on the real GitHub run and in GitHub's settings. Each is recorded indocs/architecture/ci.mdwith the date it was observed, and the traceability table cites that record rather than a test. This is the honest treatment the spec chose — pretending a repo test proves a blocked merge would be a lie (spec R11, SC8). - Non-regression. The existing suites stay green, and the
docs/superpowers/file count stays zero (SC7) — spec 001 already owns that invariant; this plan writes nothing under that path.
Deliberately not tested in-repo: that the Linux runner builds esbuild correctly (research V1 residual — the lockfile makes it near-certain; the first CI run is the proof), and anything requiring GitHub's platform (the observational set above).
Traceability the implementation will fill into spec.md:
| Criterion | Test / verification |
|---|---|
| P1 #1 | tests/ci/workflow.test.ts — triggers + four ordered checks |
| P1 #2, #3, #4 | manual: first-run observation, dated in docs/architecture/ci.md |
| P1 #5 | tests/ci/workflow.test.ts — concurrency cancel-in-progress |
| P2 #1, #2, #3 | tests/ci/workflow.test.ts — structure, pins, missing-check |
| P3 #1, #2 | manual: post branch-protection, dated in docs/architecture/ci.md |
| SC1, SC2, SC3, SC6 | manual: dated in docs/architecture/ci.md |
| SC4, SC5 | tests/ci/workflow.test.ts |
| SC7 | existing repo-invariants / spec-001 invariants |
| SC8 | this table complete + tests/ci/workflow.test.ts names exist |
Alternatives considered
- Parallel jobs, one per check — rejected: the spec chose a single fail-fast job for a repo this size (Q4-A); splitting is a well-understood later refactor once checks are slow enough to earn it.
- Regex over the raw YAML instead of the
yamlparser — rejected: brittle against reformatting and nesting; the repo parses structured config properly everywhere else. A one-line devDep buys robustness. nektos/actto execute the workflow inside the test suite — rejected: pulls Docker into the tests and is heavy for four scripts already runnable directly (spec Q7-B).- Reading
engines.nodeviasetup-node'snode-version-file— rejected: a range (>=22.18) resolves to the newest satisfying version at run time, non-deterministic and untethered from a pin (research V3). - SHA-pinning the actions now — deferred: major-tag pins keep a first workflow readable; SHA pinning is the supply-chain hardening step, noted in Risks for when it earns its place.
- Adding
semverfor the Node-floor check — rejected: an integer major comparison is enough and keeps the new-dependency count at one.
Risks
- Only the first real run proves SC1–SC3 (Linux runner). Mitigation: research V1/V2 confirmed the mechanics and all four checks pass locally green; the spec already treats these as observational, to be recorded with dates.
- Branch protection is a GitHub-side setting, not a committed artifact (R10). Mitigation: documented in
ci.mdwith the exact setting and a one-time dated human verification; the tension with "all artifacts committed" is acknowledged, not engineered around (spec Out of scope). - The YAML
on:gotcha. In YAML 1.1, the keyonparses as booleantrue; GitHub tolerates it, but a parser could surprise the test. Mitigation: theyamlpackage defaults to YAML 1.2 whereonis the string key"on"— the test readsdoc.ondirectly; if a future parser bump changes this, the test fails loudly rather than silently passing. - Action versions drift or deprecate over time. Mitigation: pin major tags now; SHA-pinning is the documented hardening path. A deprecated action surfaces as a real (not silent) CI failure.
strictDepBuilds: truemeans a future build-script dependency breaks CI's install (research V1). Mitigation: recorded as a standing guard inci.md; the failure mode is explicit and understood.
Open questions
- Exact action major tags (
checkoutv4 vs v5,setup-nodev4 vs v6/v7) — the implementer pins to whatever major is current at implementation; research V2 gives the landscape and the majors shown are a safe floor. Not blocking. - Node pin value — set to
22(the floor line) per the human's decision and research V3; revisable to24/26by a one-line change if desired, with the Test-strategy floor check unchanged. - Nothing left open by
research.md: V1 and V2 are confirmed, V3 is decided.