Skip to content

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.

  1. The workflow — .github/workflows/ci.yml. One workflow, two triggers (pull_request and push to main), one job on ubuntu-latest, sequential fail-fast steps. pnpm is installed before Node so actions/setup-node's cache: 'pnpm' can find the store (research V2). The pnpm version is not written in the workflow — pnpm/action-setup reads it from packageManager, so it cannot drift (research V2). Node is pinned to 22, the engines.node floor line (research V3). A concurrency block cancels superseded runs. The full intended file:

    yaml
    name: 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:build
  2. The structural guard — tests/ci/workflow.test.ts. A Vitest test that parses ci.yml with the yaml package and asserts its shape, mirroring how tests/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 new tests/ci/ directory sits beside tests/workflow/ and tests/monorepo/.

  3. The enforcement record — docs/architecture/ci.md. Captures the CI shape, the exact branch-protection setting on main and 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 how monorepo.md/stack.md are 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.md

Direction: 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 22 in CI (the engines.node floor line); pnpm 11.15.1 via packageManager (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 the on:→true boolean gotcha of YAML 1.1 (see Risks). No semver dep 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.ts is already in include). (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. Use unknown plus 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-error without 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, 429 on 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.

PathChangeResponsibility
.github/workflows/ci.ymlnewThe 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.tsnewParses 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.jsonmodifiedAdd devDependency yaml. No script changes — CI calls the existing lint/typecheck/test/docs:build scripts.
docs/architecture/ci.mdnewCaptures 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.mdmodifiedReference section: add a link to docs/architecture/ci.md.
specs/003-ci-wiring/spec.mdmodifiedFill 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 order

Test 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. Parses ci.yml once and asserts:
    • triggers pull_request and push:[main] (SC4; P1 #1);
    • the four checks lint → typecheck → test → docs:build present and in that order — extracted as the ordered list of run commands in the job (SC4; P1 #1);
    • concurrency present with cancel-in-progress: true (P1 #5; SC4);
    • cache: 'pnpm' on the setup-node step (SC4);
    • pnpm no-drift: the pnpm/action-setup step has no version input, or one equal to the pnpm version in packageManager (P2 #2; SC5);
    • Node satisfies the floor: the node-version pin's major ≥ the major of engines.node's floor. A plain integer comparison (parse 22 from the pin, 22 from ">=22.18"), so no semver dependency — 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 run step and the ordered-checks assertion fails.
  • Observational — dated manual verification. SC1 (a failing check → red run, later steps skipped), SC2 (all pass → green), SC3 (push to main reports 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 in docs/architecture/ci.md with 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:

CriterionTest / verification
P1 #1tests/ci/workflow.test.ts — triggers + four ordered checks
P1 #2, #3, #4manual: first-run observation, dated in docs/architecture/ci.md
P1 #5tests/ci/workflow.test.ts — concurrency cancel-in-progress
P2 #1, #2, #3tests/ci/workflow.test.ts — structure, pins, missing-check
P3 #1, #2manual: post branch-protection, dated in docs/architecture/ci.md
SC1, SC2, SC3, SC6manual: dated in docs/architecture/ci.md
SC4, SC5tests/ci/workflow.test.ts
SC7existing repo-invariants / spec-001 invariants
SC8this 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 yaml parser — rejected: brittle against reformatting and nesting; the repo parses structured config properly everywhere else. A one-line devDep buys robustness.
  • nektos/act to 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.node via setup-node's node-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 semver for 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.md with 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 key on parses as boolean true; GitHub tolerates it, but a parser could surprise the test. Mitigation: the yaml package defaults to YAML 1.2 where on is the string key "on" — the test reads doc.on directly; 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: true means a future build-script dependency breaks CI's install (research V1). Mitigation: recorded as a standing guard in ci.md; the failure mode is explicit and understood.

Open questions ​

  • Exact action major tags (checkout v4 vs v5, setup-node v4 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 to 24/26 by 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.