Skip to content

Wave 1 — stop the bleeding: three CRITICALs, the cost cap, and ADR-0073/0074 #756

Wave 1 — stop the bleeding: three CRITICALs, the cost cap, and ADR-0073/0074

Wave 1 — stop the bleeding: three CRITICALs, the cost cap, and ADR-0073/0074 #756

Workflow file for this run

# Relavium CI — the green gate every push and PR must pass (Phase 0 · 0.G).
#
# Runs lint + typecheck + test across every workspace in dependency order with the
# Turborepo cache, plus the formatting check and the no-vendor-type-across-the-seam fence.
#
# Branch protection (set in the GitHub repo settings, not here): `ci` and `coverage` are the REQUIRED
# status checks to merge into `main`. Every other job self-labels `(advisory)` in its `name:`, which is
# what shows in the PR checks list — `floor-check`, `peer-dep-gate` and `windows-concurrency`.
#
# `coverage` enforces the testing.md >=90% line+branch floor for `@relavium/llm` and `@relavium/mcp` only.
# `packages/core` is measured and printed by the same run but does not fail it: its branch margin is +0.83
# (90.83 measured 2026-07-29) and Phase 2.5.5 Waves 1-3 edit `core` heavily, so blocking merges on a
# sub-1-point margin would red-CI real work for no defect. That is a scoped, dated ruling with a promotion
# trigger — Wave 3's test-coverage items — not an open-ended exemption. A local `pnpm coverage` still
# enforces all three, so the floor never silently relaxes for a developer.
#
# Caching: the always-on layer is the GitHub Actions `.turbo` cache (restored/saved below),
# which makes a no-change re-run a Turborepo cache hit — the M0 "demonstrably hitting"
# criterion. The Vercel-style Turborepo REMOTE cache (cross-runner sharing) is opt-in: add
# repo secrets `TURBO_TOKEN` and `TURBO_TEAM` and Turbo picks them up automatically below.
#
# Third-party actions are pinned to a full commit SHA (the `# vX.Y.Z` comment tracks the
# human-readable release) so a moved tag can't inject unreviewed code; bumps are deliberate.
#
# Install-script posture: `pnpm install` runs WITHOUT `--ignore-scripts` deliberately. Supply-chain risk is
# handled more precisely one level up, by `pnpm.onlyBuiltDependencies` in the root package.json, which
# allowlists the ONLY two packages permitted to run lifecycle scripts (`better-sqlite3` for its native
# prebuild, `esbuild` for its platform binary). Every other transitive dependency is already blocked.
# Passing `--ignore-scripts` here would break both of those and gain nothing — it is a weaker, blunter form
# of a control the repo already applies.
name: CI
on:
push:
branches: [main, development]
pull_request:
# Cancel superseded runs so CI tracks the latest push — but never cancel a run on `main` (its
# history is the merge record and must complete). The group key: a PR run is keyed by its PR
# number (`github.event.number` — so two PRs open from the SAME head branch stay independent and
# can't cancel each other), a push run by its short branch (`ref_name`, e.g. `development`).
# `event_name` keeps a push and its same-SHA PR in separate groups (otherwise one run cancels the
# other and a cancelled required check can block merge): `ci-CI-push-development` vs
# `ci-CI-pull_request-10`.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.number || github.ref_name }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
contents: read
env:
# Picked up by Turbo for remote caching when the secrets are configured; harmless if empty.
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
ci:
name: lint · typecheck · test
runs-on: ubuntu-latest
timeout-minutes: 15 # a hung step fails fast instead of inheriting the 360-min default
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up pnpm
# pnpm version comes from the root package.json `packageManager`.
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Restore Turborepo cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
# The required gate: lint, typecheck, and test every workspace, then build.
- name: Lint, typecheck, test
run: pnpm turbo run lint typecheck test
# Typecheck the root/tooling *.config.ts that sit outside every package's tsc program.
- name: Typecheck tooling configs
run: pnpm typecheck:tools
# Not redundant with typecheck: `build` runs each package's emit (tsconfig.build.json
# → dist), which `typecheck` (tsconfig.json --noEmit) does not — it catches
# emit-only failures and proves the published artifact compiles.
- name: Build
run: pnpm turbo run build
# Drift gate (2.L · ADR-0051): the published CLI bundle inlines the @relavium/* engine and externalizes
# every third-party dep — its external import closure must equal the declared `dependencies`, or a global
# `npm i -g relavium` would fail to resolve a missing dep. Needs the build above.
- name: CLI bundle closure matches its declared dependencies
run: pnpm lint:bundle-closure
# Drift gate: the committed migration must match src/schema.ts. If a schema change
# (or an upstream @relavium/shared enum change the CHECKs derive from) wasn't
# regenerated via `pnpm --filter @relavium/db db:generate`, regenerating here produces
# an uncommitted diff and fails — turning a silent stale-migration into a red check.
- name: DB migration is in sync with the schema
run: |
pnpm --filter @relavium/db db:generate
if [ -n "$(git status --porcelain packages/db/drizzle)" ]; then
echo "::error::The committed @relavium/db migration is out of sync with src/schema.ts (or an upstream @relavium/shared enum the CHECKs derive from). Regenerate and commit: pnpm --filter @relavium/db db:generate"
git status --porcelain packages/db/drizzle
exit 1
fi
# Formatting is run, not argued (code-style-typescript.md). Via turbo so it caches.
- name: Format check
run: pnpm turbo run format:check
# Prove the no-vendor-type-across-the-@relavium/llm-seam fence is live (0.F).
- name: Seam fence is enforced
run: pnpm lint:fence-check
# The size half of the fence: engine packages' runtime dependency graphs must stay
# within their reviewed allowlists (a new engine dep = an ADR + a deliberate edit to
# tools/engine-deps/check.mjs in the same change).
- name: Engine dependency allowlist
run: pnpm lint:engine-deps
# `tools/` is real code that gates real things (the seam fence, the bundle-closure guard, the
# models.dev sync). The root `ci` script has always linted it; this job never did, so a lint error in
# a guard script could merge while `pnpm run ci` was red locally (#312).
# NB: it is `pnpm run ci`, never `pnpm ci` — pnpm reserves `ci` as a builtin and answers
# `ERR_PNPM_CI_NOT_IMPLEMENTED`, so the script is unreachable by the name everyone types.
- name: Lint the tooling scripts
run: pnpm lint:tools
# RUN the artifact this job just built, through the SAME `pnpm smoke:cli` script the root `ci` script
# calls — a check that exists in only one of the two is exactly the #312 divergence this change closes.
# Until now nothing in the required gate executed
# `apps/cli/dist/index.js` — only the advisory Windows leg and the tag-gated release smoke did (#294) —
# so a bundle that builds but cannot boot merged green. The `run --json` leg additionally proves the
# migrations resolve beside the bundle, which is the failure `apps/cli/drizzle/**` becoming a declared
# turbo output (#315) exists to prevent: a cache-hit replay used to leave `dist/` fresh next to a
# missing `drizzle/`, crashing on first DB touch with nothing red anywhere.
- name: Smoke the compiled binary
run: pnpm smoke:cli
# Supported-floor gate (ADR-0067). TWO floors, deliberately different:
# * the PUBLISHED floor is `apps/cli` `engines.node` = `>=22` — the max constraint in the CLI's RUNTIME
# dependency closure (`ink@7` / `cli-truncate` / `slice-ansi` → `>=22`); nothing runtime needs more.
# * the DEV-INSTALL floor is the root `engines.node` = `>=22.13.0` — forced ONLY by devDependencies
# (`vite` via vitest → `>=22.12.0`; `eslint-visitor-keys` via eslint → `^22.13.0`). `.npmrc` sets
# `engine-strict=true`, so `pnpm install` on the whole workspace HARD-FAILS below it.
# A workspace install therefore cannot run at 22.0.0, so this leg pins the LOWEST Node the repo installs
# on (22.13.0). It still exercises the published 22 line end-to-end and proves better-sqlite3 resolves its
# Node-22 prebuild (ABI 127 — one binary for ALL of 22.x, so 22.13.0 loads the same artifact 22.0.0 would;
# no node-gyp C++ source build). What it does NOT prove is that no RUNTIME dep requires >22.0.0 — that is
# a property of the prod closure, verified at the ADR (see ADR-0067's amendment note) rather than here.
# A SEPARATE job (the required check stays the ubuntu `ci` job on Node 24); promote once confirmed stable.
floor-check:
name: node 22-line floor (22.13.0) · typecheck · test · build (advisory)
runs-on: ubuntu-latest
timeout-minutes: 15
# Fully isolate this job from the shared Turbo remote cache. Turbo's task hash does NOT include the
# runtime Node version, so floor-check (Node 22) and the required `ci` job (Node 24) share a hash —
# either could replay the other's cached result and mask a version-specific failure. `--force` (below)
# stops floor-check READING the shared cache; blanking the inherited workflow-level tokens here stops it
# WRITING into it, so the required `ci` job can never replay a Node-22 result as Node-24-proven.
env:
TURBO_TOKEN: ''
TURBO_TEAM: ''
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Set up Node (the lowest 22.x the workspace installs on)
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.13.0
cache: pnpm
- name: Install (frozen lockfile — better-sqlite3 must resolve a prebuild, not source-build)
run: pnpm install --frozen-lockfile
- name: Typecheck, test, build on the exact floor
# `--force` = no cache READ (genuine Node-22 run, not a Node-24 replay); the job-level blanked
# TURBO_TOKEN/TURBO_TEAM above = no cache WRITE (so `ci` never replays this Node-22 result). Both are
# needed — see the job-level comment.
run: pnpm turbo run typecheck test build --force
# CI-only strict peer-dependency gate (.npmrc keeps this OFF for local installs so fresh
# checkouts resolve while the surface packages' peers are not all in the tree yet). Catch
# peer drift here without breaking local dev.
peer-dep-gate:
name: strict peer-dependency check (advisory)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install with strict peers
run: pnpm install --frozen-lockfile --config.strict-peer-dependencies=true
# Engine coverage floor (testing.md >=90% line+branch, exit criterion #5). A REQUIRED check named
# `engine coverage floor (llm, mcp)` — kept as a SEPARATE job from `ci` so a coverage regression is
# legible on its own line rather than buried in a 12-step job. It is a repo-ROOT vitest run, which is what
# makes the root-relative per-glob thresholds authoritative (vitest.config.ts). `packages/core` is measured
# but not enforced here; see the header for the ruling and its promotion trigger.
coverage:
name: engine coverage floor (llm, mcp)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
# Share the same Turborepo local cache the `ci` job writes, so the build below is a warm cache hit
# on a same-SHA / recent-ancestor run rather than a cold rebuild.
- name: Restore Turborepo cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
# `pnpm coverage` is a repo-ROOT vitest run (not a turbo task), so it does NOT get the `test`
# task's `^build` — yet the `@relavium/*` package `exports` resolve only to `dist`. Build first so a
# fresh checkout can resolve the cross-package entries (a per-package test gets dist via turbo `^build`;
# each package still covers its OWN src via relative imports, so the floor stays src-accurate).
- name: Build workspaces (so coverage resolves the @relavium/* package entries)
run: pnpm turbo run build
# ENFORCED subset only: `llm` and `mcp` fail the build below 90% line+branch. `core` is measured and
# printed by the same run but does NOT fail it — its branch margin is +0.83 today and Phase 2.5.5
# Waves 1-3 edit it heavily, so blocking on a sub-1-point margin would red-CI real work for no
# defect (maintainer ruling 2026-07-29). A bare `pnpm coverage` still enforces all three locally.
# Promote `core` here once Wave 3's test-coverage items land.
- name: Engine coverage floor (>=90% line+branch — llm, mcp enforced; core measured)
run: pnpm coverage:enforced
# Cross-OS concurrency + headless gate (2.5.I S6). The DB write-path hardening (BEGIN IMMEDIATE + the
# SQLITE_BUSY retry's Atomics.wait sleep + WAL locking) and the two-process concurrency e2e (a child spawn +
# a file:// import of the built @relavium/db) are the parts most likely to behave differently on Windows —
# ci.yml ran ubuntu-only (release.yml already smokes the built binary cross-OS, but never the test suite).
# Runs the @relavium/db concurrency suite (the real Windows native-addon + WAL exercise) + the CLI
# concurrency/perf harness there, plus a headless no-TTY smoke. A SEPARATE, advisory job (the required check
# stays the ubuntu `ci` job) — promote to required in branch protection once confirmed stable. POSIX 0600/0700
# perm assertions are NOT exercised here (a documented Windows no-op — ADR-0050); nor is ink's raw-mode code,
# which is TTY-gated (the driver-selection gate picks the plain driver without a TTY — see the smoke below).
windows-concurrency:
name: windows · db concurrency + headless smoke (advisory)
runs-on: windows-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Restore Turborepo cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
# The two-process concurrency e2e spawns children that import the BUILT @relavium/db (by file:// URL),
# so the dist must exist before the tests run.
- name: Build
run: pnpm turbo run build
- name: DB concurrency suite (BEGIN IMMEDIATE + SQLITE_BUSY retry + WAL behave identically on Windows)
run: pnpm --filter @relavium/db test
- name: CLI concurrency + query-shape harness (cross-process spawn + EXPLAIN plans on Windows)
run: pnpm --filter relavium exec vitest run concurrency perf-budget session-chain
- name: Headless no-TTY smoke (the bundle loads; a piped chat picks the plain driver and exits cleanly)
shell: bash
run: |
set -euo pipefail
node apps/cli/dist/index.js --help >/dev/null # the tsup bundle loads + --help renders
# `chat` is TTY-gated on STDOUT (selectChatDriver / io.stdoutIsTty): with stdout captured (non-TTY)
# here, the driver-selection gate picks the PLAIN driver (ink/setRawMode is never entered), and the
# plain driver must exit cleanly on EOF — never crash. A regression routing to ink without a TTY would
# throw "Raw mode is not supported…", which this catches.
out="$(printf '' | node apps/cli/dist/index.js chat 2>&1 || true)"
if printf '%s' "$out" | grep -qiE 'setRawMode|raw mode'; then
echo "::error::chat entered raw mode without a TTY (the driver-selection gate regressed)"; exit 1
fi
echo "✓ windows headless no-TTY smoke passed"