Skip to content

fix: address Phase 2 review findings from #1819 #4522

fix: address Phase 2 review findings from #1819

fix: address Phase 2 review findings from #1819 #4522

Workflow file for this run

name: CI
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pull-requests: read
# Build native modules (better-sqlite3, bufferutil, utf-8-validate) from source
# instead of downloading prebuilt binaries via `prebuild-install`. Those
# prebuilds come from GitHub Releases (objects.githubusercontent.com), whose
# fetch wedged on the hosted runners — `npm ci` hung silently after the
# `prebuild-install` warning until the job timeout, on every retry. Source
# builds use only the tarball already in the npm cache + the runner's toolchain
# (~48s for better-sqlite3), so the install no longer depends on that CDN.
# No-op for pure-JS deps, for the root install (no gyp natives), and for the
# `--omit=dev` binary-matrix jobs. See RCA docs/rca/2026-05-30-state-source-integrity.md.
env:
npm_config_build_from_source: 'true'
jobs:
# ─────────────────────────────────────────────────────────────────────
# Gate hosting taxonomy (DR-1): every job/gate below lives in exactly one
# of four host classes (zero-dep prefix unfiltered, deps-tail unfiltered,
# deps-unfiltered suite-shaped, deps-filtered). The decision table, the
# two-surface subset rule, and the non-blocking allowlist contract
# (tests/scripts/ci-topology.test.ts) are documented ONCE, canonically, at
# docs/guides/ci-gate-hosting.md — cite it at each gate's wiring site,
# do not restate it here. `pr-body-check.yml` is a separate workflow,
# outside this aggregator's contract (see the guide's out-of-scope note).
# ─────────────────────────────────────────────────────────────────────
changes:
name: Detect Changes
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-latest
outputs:
root: ${{ steps.filter.outputs.root }}
mcp: ${{ steps.filter.outputs.mcp }}
prompts: ${{ steps.filter.outputs.prompts }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
root:
- 'src/**'
# Task 030 moved every co-located suite out of `src/`, so a PR
# that changes only a test now touches `tests/**` and nothing
# else. Without this the jobs policing that test are skipped —
# and a skipped required job reads as passed (#1711). Deliberately
# the whole tree rather than a tier list, because a new tier would
# otherwise be un-triggered until someone noticed.
- 'tests/**'
- 'content/**'
- 'content/harness/runtimes/**'
# Projection roots (DR-22): these directories/files are part of
# the shipped surface (see `files` in package.json) and are
# regenerated + drift-checked by `skills:guard` / `hooks:guard`
# in the `test-root` job below. Without an entry here, a PR
# that ONLY touches one of these paths never flips `root` to
# 'true' — `test-root` (and both guards) never runs, and the
# deletion/mutation of a shipped agent, command alias, or hook
# passes CI green, unobserved (the gap this filter closes).
# `rendered/` IS those roots. The DR-4 block folded `agents/`,
# `command-aliases/`, `commands/`, `rules/` and `skills/` into one
# generated tree and this list kept naming the old five, so all of
# them matched nothing — reopening the exact gap the comment above
# says this filter closes, since a PR touching only generated
# output stopped flipping `root` and `render:guard` stopped
# running on the PR that caused the drift.
- 'rendered/**'
- 'hooks/**'
- 'content/harness/hooks/**'
- 'binding/**'
- 'content/harness/binding/**'
- '.claude-plugin/**'
# The agent-generator sources under the MCP workspace feed the
# rendered `agents/**` projection above; a change there must
# trigger the root suite's `skills:guard` drift check on the
# CAUSING PR, not only on a later PR that happens to touch the
# rendered output.
- 'src/runtime/agents/**'
- 'AGENTS.md'
- 'package.json'
- 'tsconfig.json'
- '.github/workflows/ci.yml'
# `tests/scripts/release-workflow.test.ts` (root suite) parses
# release.yml — a release.yml-only PR must run the root suite
# or the workflow's own contract test never fires on the PR
# that changes it.
- '.github/workflows/release.yml'
# Task 036 gathered every gate, build and publish script under
# `tools/`. They were `scripts/**`, deliberately outside this
# filter, with unfiltered jobs re-asserting them. Naming the tree
# is the simpler guarantee: a PR that weakens a gate now triggers
# the lane that runs it, instead of relying on a second job to
# notice. The whole tree, so a new `tools/` root is not
# un-triggered until someone spots it.
- 'tools/**'
# Fail-open registers the architecture liveness closer reads.
# A PR that only edits one of these never flipped `root`, so
# `test-root` (and the live-oracle teeth) skipped while the
# register could evaporate. The same class is already documented
# for `rendered/` above.
- '.github/CODEOWNERS'
- 'knip.json'
- '.exarchos/**'
- 'manifest.json'
mcp:
- 'src/**'
# `tests/unit/**` and `tests/integration/**` are the core suite's
# own tests after task 030 — same reason as the `root` filter.
- 'tests/**'
# Was `scripts/core/**`. Widened to the whole tree by task 036 for
# the same reason as the `root` filter above: this lane hosts
# gates whose sources moved into sibling `tools/` roots, and a
# gate outside every filter of its own hosting job is exactly what
# the guard inventory's reachability proof rejects.
- 'tools/**'
- 'tools/test-helpers/**'
- 'vitest.config.ts'
- 'package.json'
- '.github/workflows/ci.yml'
prompts:
# Authored sources and the tree they render to. These were
# `skills/`, `commands/` and `rules/` until the DR-4 block moved
# them, after which this lane fired on prompt changes only by
# accident — through `tests/evals/**`.
- 'content/**'
- 'rendered/**'
- 'tests/evals/**'
- 'tools/evals/evals/**'
# Opt-in eval package (DR-3): owns the promptfoo dep the graders
# dynamic-import. A change here must still fire RUN_EVALS / the
# eval gate, so it is named alongside the eval sources above.
- 'tools/evals-pkg/**'
- 'src/workflow/playbooks.ts'
test-root:
name: Root Package
needs: changes
if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && needs.changes.outputs.root == 'true'
runs-on: ubuntu-latest
steps:
# fetch-depth: 0 — the DR-8 legacy-hash manifest test
# (tests/scripts/generate-legacy-skill-hashes.test.ts, part of the root unit suite)
# derives render hashes from release tags via `git show <tag>:<path>`. The
# default shallow, tagless checkout makes `git tag --list 'v2.*'` empty →
# enumerateReleaseRefs() returns [] → the suite reds. Full history brings the
# tags AND their tree objects.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
- run: npm run typecheck
# Windows-portability lint (#1623): editor-first ESLint rules, also run
# here as a config-rot guard + second enforcement of the spawn-shim /
# URL.pathname anti-patterns (the grep-gate is the primary CI enforcer).
- run: npm run lint:windows
- run: bash tests/scripts/check-eslint-windows.test.sh
- run: npm run test:run
# The extracted conformance suite (task 018a). `npm run test:run` is the
# `unit` project ONLY, so the `conformance` project needs its own step or
# it is collected by nothing — the silent-green shape this suite exists to
# catch, applied to the suite itself. This is the WHOLESALE host, the role
# `test-mcp` filled before the extraction; the five Wave-1 guards keep
# their individually-named UNFILTERED hosts on `grep-gates`.
- run: npm run test:conformance
# End-to-end install acceptance (task 028). Its own step for the same
# reason as conformance above — `npm run test:run` is the `unit` project
# only, so this project is collected by nothing without it.
#
# It materializes HEAD's TRACKED content into a scratch dir and runs the
# real installer over that, which is the only vantage point from which a
# gitignored payload, an unflattened skill tree, or a hook registered
# twice is visible. Every other check reads the working tree, where an
# untracked file resolves exactly like a shipped one.
- run: npm run test:acceptance
# render:guard — the single drift gate over everything the build
# generates. Re-renders content/ and fails if any generated tree differs
# from what is committed: the rendered/ artifact trees (skills, commands,
# rules, agents, command-aliases), the plugin-root hooks/ and binding/
# output, the per-harness agent dot-directories, and the embedded runtime
# module codegen'd from content/harness/runtimes/*.yaml.
#
# It replaces skills:guard, hooks:guard and runtimes:guard, which each
# covered one slice. Consolidating them also lets the guard assert its own
# liveness: every declared scope must resolve to a non-empty tree, so a
# guard that has stopped watching fails instead of reporting one confident
# success. Depends on dist/install/render-guard.js from the earlier
# `npm ci` (`prepare` -> `tsc`).
- run: npm run render:guard
# desc:budget-guard — MCP description token-budget gate (#1321, R-E).
# Tool/action descriptions cost every agent on every `tools/list` call;
# this asserts each composite-tool / slim / per-action description stays
# under a documented token budget (chars/4 estimate) and fails CI on
# overrun with a per-offender breakdown. Runs here (not grep-gates)
# because it imports TOOL_REGISTRY via tsx, which needs both the root
# and MCP-server deps this job already installs. Sibling to skills:guard
# / lint:invariants. Blocking via the ci-gate aggregator (test-root).
- run: npm run desc:budget-guard
test-mcp:
name: Exarchos MCP Server
needs: changes
if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && needs.changes.outputs.mcp == 'true'
runs-on: ubuntu-latest
env:
RUN_EVALS: ${{ needs.changes.outputs.prompts == 'true' && '1' || '' }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
steps:
- uses: actions/checkout@v4
with:
# fetch-depth: 0 — the DR-7 mutation gate (check-mutation-gate.mjs,
# observe→blocking) diffs `<base>...HEAD` (merge-base three-dot) to
# scope changed `src`. A shallow checkout lacks
# the merge-base commit, so `git diff FETCH_HEAD...HEAD` fails with
# "no merge base" (fail-closed). Full history makes the base-ref diff
# resolvable in CI. Mirrors the fetch-depth:0 jobs elsewhere here.
fetch-depth: 0
# The mutation gate only reads history, and this job then runs
# repo/dependency code (test runners, bun build, npm postinstalls), so
# don't persist the checkout token into .git/config for those steps.
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
# `compiled-binary-mcp.test.ts` shells out to
# `bun run tools/release/build-binary.ts` from the repo root — bun must
# be on PATH or the spawn fails with `exit null`.
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
# One install: task 019 dissolved the nested workspace, so the core's
# dependency closure and the installer's are the same closure now.
- name: Install dependencies
run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
- run: npx tsc --noEmit
# DR-5 (coverage baseline): the plain `npm run test:run` suite step BECOMES
# the coverage-instrumented run — ONE execution, not two (a second full
# run would double wall-time and double exposure to the known flaky
# E2E/perf tests). RUN_EVALS is pinned OFF here (step env overrides the
# job-level derivation) so the measured lane is stable and
# RUN_EVALS-independent; the eval lane it used to carry is preserved as
# its own conditional step below. DR-1 host class: deps, filtered on `mcp`
# — scan surface src/** ⊆ the mcp filter; the scripts/
# ratchet implementation is re-asserted unfiltered by a grep-gates
# .test.sh step (DR-10). See docs/guides/ci-gate-hosting.md.
- name: Coverage-instrumented suite (DR-5, RUN_EVALS pinned off)
env:
RUN_EVALS: ''
run: npm run test:coverage
# DR-5 coverage non-regression ratchet, BLOCKING (task 009: the CI soak
# is complete — 3 green observe-mode runs on this feature branch fed the
# measured, floored-epsilon baseline at
# tools/audit/coverage-baseline.json — so `--observe` is
# dropped and a regression beyond the floored epsilon now fails this
# job). The path is $GITHUB_WORKSPACE-absolute because the script
# resolves its own inputs from its location, not the cwd. DR-1 host
# class: deps,
# filtered on `mcp`. See docs/guides/ci-gate-hosting.md.
# `if: !cancelled()` (not the implicit `success()`): the ratchet is
# EVIDENCE that must be captured even when the coverage suite above went
# red — vitest writes coverage-summary.json on failure too
# (reportOnFailure), so the ratchet still has a summary to inspect. A red
# suite already fails the job on its own step; running the ratchet anyway
# does not mask that (it can only ADD a reason), it just ensures the
# non-regression signal is never silently skipped after an unrelated flake.
- name: Coverage ratchet — blocking (DR-5)
if: ${{ !cancelled() }}
run: node "$GITHUB_WORKSPACE/tools/audit/gates/check-coverage-ratchet.mjs"
# DR-5: publish the coverage summary as a run artifact so task 009 can
# capture the live baseline (totals + run-ids) from ≥3 CI runs. `path` is
# workspace-relative — upload-artifact ignores the job's run
# default-directory.
- name: Upload coverage summary (DR-5)
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: coverage-summary
path: coverage/coverage-summary.json
if-no-files-found: error
# DR-5 eval-lane preservation (non-negotiable): the replaced plain-suite
# step was the ONLY execution surface of the RUN_EVALS-gated eval tests
# (evals/harness.test.ts, evals/graders/llm-rubric.test.ts). This
# conditional step reproduces the OLD job-level RUN_EVALS derivation
# EXACTLY — it fires iff `needs.changes.outputs.prompts == 'true'`, the
# same predicate that set RUN_EVALS=1 — running the eval files with
# RUN_EVALS=1 OUTSIDE the coverage instrument, so the eval lane fires on
# prompts-touching PRs exactly as before. DR-1 host class: deps, filtered
# on `mcp`. See docs/guides/ci-gate-hosting.md.
- name: Eval lane (RUN_EVALS-gated, prompts-touching PRs only) (DR-5)
# `!cancelled() &&` the SAME prompts predicate: the eval lane is the
# preserved evidence surface for the RUN_EVALS-gated tests — a red
# coverage suite above must not silently skip it on a prompts-touching
# PR. The prompts predicate is unchanged (only prompts-touching PRs run
# it); adding `!cancelled()` only lets it survive an earlier step failure.
if: ${{ !cancelled() && needs.changes.outputs.prompts == 'true' }}
env:
RUN_EVALS: '1'
run: npx vitest run tools/evals/evals/harness.test.ts tools/evals/evals/graders/llm-rubric.test.ts
# DR-7 diff-scoped mutation gate, OBSERVE MODE, pull_request events only
# (a push-to-main has no PR base to diff). The script self-fetches the
# shallow PR base ref (origin/$GITHUB_BASE_REF) and invokes the in-tree
# mutation-adequacy handler via bun (set up above); an empty server diff
# is a logged skip, a git failure is fail-closed. Observe mode logs the
# verdict and always exits 0 — this step STAYS observe (task 009
# deliberately deferred its flip: the diff-scoped mutation gate degrades
# in CI because StrykerJS dry-run fails on the full server suite;
# tracked in #1720). It flips to blocking once #1720 resolves. DR-1 host
# class: deps, filtered on `mcp` — scan surface servers/** ⊆ the mcp
# filter; the scripts/ impl is re-asserted unfiltered in grep-gates
# (DR-10). See docs/guides/ci-gate-hosting.md.
- name: Mutation gate — observe mode (DR-7), PR events only
if: github.event_name == 'pull_request'
run: node "$GITHUB_WORKSPACE/tools/audit/gates/check-mutation-gate.mjs" --observe
# DR-7 composed-path Stryker smoke test (task 012): resolver → handler →
# adapter → real pinned Stryker → parser, on a tiny fixture scope. Runs in
# its OWN step OUTSIDE the coverage instrument and OUTSIDE the default
# suite glob (excluded in vitest.config.ts) so it neither inflates the
# coverage-measured lane nor rides the Windows leg's known spawn-flake
# class. EXARCHOS_SMOKE_ONLY=1 lifts that exclusion for this one
# invocation (vitest's CLI --exclude is additive and cannot un-exclude).
# test-mcp runs on ubuntu-latest, so this is Linux-only by host. DR-1 host
# class: deps, filtered on `mcp`. See docs/guides/ci-gate-hosting.md.
- name: Composed-path Stryker smoke test (DR-7, task 012) — Linux-only
env:
EXARCHOS_SMOKE_ONLY: '1'
run: npx vitest run tests/unit/verbs/stryker-adapter.smoke.test.ts
# cli:vocab-guard (#1317) — mechanical gate against CLI vocabulary drift
# (R-A / Principle 6). Walks the rendered Commander surface via `buildCli`
# and fails on a banned verb (`info`, `ls`, `rm`) or banned flag alias
# (`--format` as a JSON carrier, `--skip-confirmation*`). Runs here rather
# than in grep-gates because resolving `buildCli` pulls in `bun:sqlite`,
# which needs Bun (already set up above) + the MCP deps installed in this
# job. Flows into the required `ci-gate` check via test-mcp.
- run: npm run cli:vocab-guard
# ─────────────────────────────────────────────────────────────────────
# test-windows (#1170)
# ─────────────────────────────────────────────────────────────────────
# Runs the MCP server unit project on a real windows-latest host — the
# first Windows CI for Exarchos. Two jobs in one:
# 1. Closes the long-standing Windows-CI gap (#1085 class): Linux-only
# tests have shipped Windows bugs (path-separator, ancestry preflight).
# 2. Captures the `merge.preflight` debug payload that #1402 phase-2 RCA
# needs — `EXARCHOS_PREFLIGHT_DEBUG=1` makes the ancestry preflight
# emit its 9-field structured payload on a genuine Windows host (DR-2).
# Harmless on green runs; the RCA reads it from the event store.
#
# `shell: bash` is set per-step for the *.sh helpers (Git Bash ships on
# windows-latest); npm/npx steps use the default shell. The bun setup
# mirrors test-mcp so the compiled-binary tests can resolve their bundler.
# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD avoids the npm-ci chromium wedge (#1502).
# ─────────────────────────────────────────────────────────────────────
test-windows:
name: Windows Unit (MCP)
needs: changes
if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && needs.changes.outputs.mcp == 'true'
runs-on: windows-latest
env:
EXARCHOS_PREFLIGHT_DEBUG: '1'
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- name: Install dependencies
shell: bash
run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
- run: npx tsc --noEmit
- run: npm run test:core
# DR-11s (WLM #1576): zero-count guard for the worktree suite.
# `npm run test:run` above already executes the worktree tests on this
# windows-latest host; this step FAILS the job if a future path-filter /
# matrix-exclusion regression silently drops them (a green-on-zero-tests
# lane would otherwise falsely satisfy the Windows-CI requirement).
- name: Guard — worktree suite executed on Windows (DR-11s)
shell: bash
run: |
npx vitest run src/verbs/worktree/ --reporter=json --outputFile=.wlm-win-guard.json
count=$(node -e "const r=require('./.wlm-win-guard.json');process.stdout.write(String((r.numPassedTests||0)+(r.numFailedTests||0)))")
echo "worktree tests executed on windows-latest: $count"
if [ "$count" -eq 0 ]; then
echo '::error::worktree suite ran 0 tests on windows-latest — DR-11s guard (a path-filter/matrix regression dropped the suite)'
exit 1
fi
# DR-8 (harness-launcher #1603): the NAMED win32-fragile launcher tests.
# `npm run test:run` above already executes these on this windows-latest
# host; running them again BY NAME pins the DR-8 contract that the Windows
# lane gates the async spawn shim resolution (task 003,
# src/utils/process.spawn.test.ts) and the worktree path
# derivation/containment (task 009, src/launcher/topology.test.ts) —
# authored OS-native, not POSIX-literal mocks. Naming the files (vs. only
# the whole suite) makes a future path-filter/matrix regression that drops
# either one fail loudly here rather than pass green-on-zero. The zero-count
# guard is the same fail-closed backstop as DR-11s above.
#
# NOTE: marking test-windows as a REQUIRED branch-protection check is a
# manual repo-settings step (Settings → Branches → main → required status
# checks) — out-of-repo and not vitest-assertable. Capture it as a manual
# followup in the merge PR so "lane present but non-blocking" cannot
# silently pass as done. (test-windows already flows into the blocking
# ci-gate aggregator below.)
- name: DR-8 — named win32-fragile launcher tests (spawn 003 + path 009)
shell: bash
run: |
npx vitest run src/utils/process.spawn.test.ts src/launcher/topology.test.ts --reporter=json --outputFile=.launcher-win-guard.json
count=$(node -e "const r=require('./.launcher-win-guard.json');process.stdout.write(String((r.numPassedTests||0)+(r.numFailedTests||0)))")
echo "win32-fragile launcher tests executed on windows-latest: $count"
if [ "$count" -eq 0 ]; then
echo '::error::named win32-fragile launcher tests ran 0 tests on windows-latest — DR-8 guard (a path-filter/matrix regression dropped src/utils/process.spawn.test.ts or src/launcher/topology.test.ts)'
exit 1
fi
# ─────────────────────────────────────────────────────────────────────
# test-windows-root (task 021)
# ─────────────────────────────────────────────────────────────────────
# Runs the root `unit` suite (`npm run test:run`) on a real
# windows-latest host. The sibling `test-windows` lane above covers only
# the `core` project; the root tiers (installer surfaces under
# `src/install/`, `scripts/`) had NO Windows coverage until now. This closes that gap
# so root-side path/OS regressions fail here rather than shipping.
#
# Gated on `changes.outputs.root` — the same path filter as the Linux
# `test-root` lane — and wired into the blocking `ci-gate` aggregator
# below (with a fail-closed skip guard mirroring the MCP DR-5 guard).
#
# Install prerequisites mirror `test-root`: the root `unit` vitest project
# includes `scripts/**/*.test.ts` (e.g. `tests/scripts/build-binary.test.ts`,
# which shells out to `bun build --compile`), so bun + the MCP server's
# runtime deps must be present for the bundler to resolve
# pino/commander/@modelcontextprotocol/sdk/zod/yaml. `shell: bash` is set
# on the *.sh install steps (Git Bash ships on windows-latest); npm steps
# use the default shell. PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD avoids the
# npm-ci chromium wedge (#1502).
#
# NOTE: marking this job as a REQUIRED branch-protection check is a manual
# repo-settings step (Settings → Branches → main → required status checks)
# — out-of-repo and not YAML-assertable. Capture it as a manual followup in
# the merge PR so "lane present but non-blocking" cannot silently pass as
# done. (test-windows-root already flows into the blocking ci-gate
# aggregator below.)
# ─────────────────────────────────────────────────────────────────────
test-windows-root:
name: Windows Unit (Root)
needs: changes
if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && needs.changes.outputs.root == 'true'
runs-on: windows-latest
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
steps:
# fetch-depth: 0 — this lane also runs the root unit suite, which includes the
# DR-8 legacy-hash manifest test that reads release-tag trees via `git show`.
# A shallow, tagless checkout would red it (see test-root above).
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- name: Install root dependencies
shell: bash
run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
# The MCP server's deps must be present so `bun build --compile` can
# resolve `pino`, `commander`, `@modelcontextprotocol/sdk`, `zod`, and
# `yaml` when bundling `src/index.ts` (exercised by
# `tests/scripts/build-binary.test.ts`, part of the root `unit` project run by
# `npm run test:run`).
- run: npm run test:run
# ─────────────────────────────────────────────────────────────────────
# binary-matrix
# ─────────────────────────────────────────────────────────────────────
# Cross-compiles the Exarchos CLI + MCP server into a single native
# binary per OS/arch pair, using `bun build --compile --target=<bun-target>`
# from `tools/release/build-binary.ts`.
#
# DRIFT CONTRACT:
# - The `matrix.target` list below MUST match the `os-arch` names
# derived from `TARGETS` in `tools/release/build-binary.ts`.
# - `tests/scripts/ci-binary-matrix.test.ts` is the drift enforcement gate:
# if this matrix or `TARGETS` is edited without the other, that
# test fails `npm run test:run`.
# - The script itself rejects unknown `--target <name>` values with
# a listed error message — failing fast inside the runner if a
# typo slips past CI review.
#
# Releases: this job uploads per-target workflow artifacts only. Actual
# GitHub Release publishing + SHA-512 checksums are owned by
# `.github/workflows/release.yml` (task 2.7).
#
# DISPOSITION (DR-4, wave-S enforcement-substrate spec): NON-BLOCKING —
# not in `ci-gate.needs`, and recorded as such (with this rationale) in
# the `tests/scripts/ci-topology.test.ts` non-blocking allowlist. Rationale:
# release-lane compile evidence, not a per-PR gate — the standing reason
# the `--minify` A/B comparison was dropped (#1703). Measured over the
# last 60 completed `ci.yml` runs (2026-07-17): 300/300 per-target job
# instances executed (5-target matrix × 60 runs), 0 failed — the low
# failure rate does not change the disposition, since the rationale for
# non-blocking is architectural (release-lane evidence), not reliability.
# Host class: deps, unfiltered, suite-shaped — see
# docs/guides/ci-gate-hosting.md.
# ─────────────────────────────────────────────────────────────────────
binary-matrix:
name: Binary Matrix (${{ matrix.target }})
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Keep in sync with TARGETS in tools/release/build-binary.ts.
target:
- linux-x64
- linux-arm64
- darwin-x64
- darwin-arm64
- windows-x64
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
# CodeRabbit/CI #1213: install root deps so `bun build --compile`
# can resolve modules imported via the cross-tree bridge — e.g.
# `js-yaml` (from `src/install/runtimes/load.ts`) and `@inquirer/prompts`
# (from `src/install/install-skills.ts`) which the MCP entry point reaches
# transitively. Both are declared in root `package.json` but were
# absent on the runner because only the nested workspace had a
# `npm ci`. The bundler then errored with "Could not resolve
# 'js-yaml'" / "Could not resolve '@inquirer/prompts'".
- name: Install root dependencies
run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
# Install MCP-server *runtime* deps so `bun build --compile` can resolve
# `pino`, `commander`, `@modelcontextprotocol/sdk`, `zod`, and `yaml`
# when bundling `src/index.ts`. `--omit=dev` skips
# the heavy dev-only tree (notably `promptfoo`, an eval-only library, and
# `better-sqlite3`, used only by the Node test shim) — the compiled binary
# embeds `bun:sqlite`, so none of it is needed here. Big install-time win
# + far less exposure to the npm-ci stall (RCA 2026-05-30-state-source-integrity).
- name: Build binary for ${{ matrix.target }}
run: bun run tools/release/build-binary.ts --target ${{ matrix.target }}
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: exarchos-${{ matrix.target }}
path: dist/bin/exarchos-${{ matrix.target }}*
if-no-files-found: error
retention-days: 7
# ─────────────────────────────────────────────────────────────────────
# validate-no-legacy
# ─────────────────────────────────────────────────────────────────────
# Runs tools/audit/gates/validate-no-legacy.sh, which rolls up two checks:
# 1. The NoLegacy_* shell assertion suite (tests/scripts/validate-no-legacy.test.sh)
# — pins obsolete v2.8 install artifacts as deleted/archived.
# 2. `knip` (files + dependencies + exports + types) via the allowlist-diff
# wrapper (tools/audit/knip-diff.ts) — detects unused modules, deps,
# exported symbols, and types, diffed against
# tools/audit/knip-allowlist.json (fails closed on unallowlisted or
# expired findings, a missing binary, or unparseable output).
#
# Runs in parallel with test-root / test-mcp on a GitHub-hosted
# ubuntu-latest runner. All CI jobs run on ubuntu-latest — the
# self-hosted runners were retired after their npm ci step intermittently
# stalled (network RX dies mid-install), whereas the same install completes
# in 30-45s on hosted runners.
# ─────────────────────────────────────────────────────────────────────
validate-no-legacy:
name: Validate No Legacy
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies (root)
run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
# The exports/types sweep resolves TypeScript across the workspaces
# knip.json declares. Since task 019 the product is ONE package, so the
# single install above is the whole closure.
- name: Run validate-no-legacy rollup
run: bash tools/audit/gates/validate-no-legacy.sh
# ─────────────────────────────────────────────────────────────────────
# manifest-gate (DR-2 / Task 004)
# ─────────────────────────────────────────────────────────────────────
# The primary de-divergence guarantee: on each consolidation PR, prove NO
# pre-image test case was lost. Reconstructs BOTH pre-images (the legacy
# __tests__ copy AND the co-located canonical copy) from the PR's merge-base
# via `git show <merge-base>:<path>` (hence `fetch-depth: 0`) and asserts
# every case survives into the PR-HEAD result (merged file or relocated
# <base>.legacy.test.ts sibling) verbatim modulo import-path rewrites, or as
# a textual duplicate — BIDIRECTIONAL (both sides). Equivalence is TEXTUAL
# only; a divergent vi.mock/vi.hoisted/env preamble forces relocate, never a
# silent drop. The gate is inert on PRs that touch no consolidation pair.
# WIRED into `ci-gate.needs` below so it is REQUIRED — a job merely present
# in ci.yml but absent from the aggregator fails OPEN (the Wave-S lesson).
# Host class: deps-tail, unfiltered — see docs/guides/ci-gate-hosting.md.
# ─────────────────────────────────────────────────────────────────────
manifest-gate:
name: Manifest Gate (textual case-preservation)
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# DR-2: reconstruct pre-images from the merge-base via `git show`, so
# the full history (not a shallow clone) must be present.
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
# The gate imports consolidate-suite.mjs, which pulls in `typescript` (a
# ROOT devDependency) for TS-compiler-API case extraction. Only the root
# install is needed — no MCP-server deps, no native build.
- name: Install dependencies (root)
run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
- name: Manifest gate — no pre-image case dropped (DR-2, Task 004)
# Reconstruct against the PR's actual base branch (not a hardcoded
# origin/main) so a stacked PR onto a non-main base verifies against the
# right merge-base; on push events base_ref is empty and the tool
# defaults to origin/main.
run: node tools/audit/manifest-gate-ci.mjs ${{ github.base_ref && format('--base origin/{0}', github.base_ref) || '' }}
# ─────────────────────────────────────────────────────────────────────
# grep-gates
# ─────────────────────────────────────────────────────────────────────
# Fast pre-test structural checks that enforce architectural invariants
# by scanning src — no full install, no build:
#
# 1. `withSession({...})` calls in production code must include
# `operationId` or `allowNonIdempotent: true` (#1342 P1.D).
# 2. The literal `BEGIN IMMEDIATE` may only appear inside the SQLite
# substrate (`storage/`, `event-store/`); leakage into application
# code is a layering violation (#1342 P1.E).
# 3. Event-sourcing read-path gates (v2.11.0 Wave 3): the upcast
# choke-point (#1556), no raw `.state.json` IO (#1504), a single
# canonical workflow-state fold (#1554), and event-store access only
# via the composition root. Zero-dependency node scripts.
#
# The #1342 gates are pure bash + grep; the Wave-3 gates are pure node
# (built-ins only). Blocking gate via the `ci-gate` aggregator below.
# ─────────────────────────────────────────────────────────────────────
grep-gates:
name: Grep Gates (idempotency + substrate)
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: withSession idempotency contract (#1342 P1.D)
run: bash tools/audit/gates/check-withsession-idempotency.sh src
# Self-test for the gate above. It exercises the gate's own matching logic
# against seeded fixtures in a tmpdir — it says nothing about production
# code. Paths resolve from the script's own location, not the cwd.
- name: withSession idempotency gate self-test (#1342 P1.D)
run: bash tests/scripts/check-withsession-idempotency.test.sh
- name: BEGIN IMMEDIATE substrate-only (#1342 P1.E)
run: bash tools/audit/gates/check-begin-immediate-substrate.sh src
# Self-test for the gate above. Seeded-fixture cases plus RealTree_* cases
# that scan this checkout to catch false positives on the legitimate
# in-substrate `.immediate()` call site.
- name: BEGIN IMMEDIATE substrate gate self-test (#1342 P1.E)
run: bash tests/scripts/check-begin-immediate-substrate.test.sh
# Event-sourcing read-path structural gates (v2.11.0 Wave 3). Each is a
# zero-dependency node script that scans src for an
# architectural violation and exits non-zero on a regression. These
# previously ran only via `npm run validate`, which no workflow invoked —
# so a real regression in the event-store read path would have passed CI.
- uses: actions/setup-node@v6
with:
node-version: '24'
- name: Upcast choke-point — migrateEvents at the read seam (#1556)
run: node tools/audit/gates/check-query-upcast-choke-point.mjs
- name: No raw .state.json read/write in production (#1504)
run: node tools/audit/gates/check-no-state-json.mjs
- name: Single workflow-state fold — one canonical reducer (#1554)
run: node tools/audit/gates/check-single-workflow-fold.mjs
- name: Event-store access via composition root (#1342)
run: node tools/audit/gates/check-event-store-composition-root.mjs
- name: Windows-portability anti-patterns (#1623)
run: node tools/audit/gates/check-windows-portability.mjs
- name: Windows-portability gate self-test (#1623)
run: bash tests/scripts/check-windows-portability.test.sh
- name: WLM wiring gate — DR-1 retry adapter + DR-2 skills reroute (task-004)
run: node tools/audit/gates/check-wlm-wiring.mjs
- name: WLM wiring gate self-test (task-004)
run: bash tests/scripts/check-wlm-wiring.test.sh
# v2.12 proof-substrate ownership census (DR-5). Exact typed dispositions
# reject new direct gate emitters, provider registrations, and custom-shell
# transition guards while preserving the audited v3.0 migration reserve.
- name: Gate-runner ownership census (v2.12 DR-5)
run: node tools/audit/gates/check-gate-runner-ownership.mjs
- name: Gate-runner ownership census self-test (v2.12 DR-5)
run: bash tests/scripts/check-gate-runner-ownership.test.sh
# ── Wave-S zero-dep register + node-only re-asserts (DR-9, DR-10) ──────
# Zero-dependency node (uses the node set up above; no install) on the
# UNFILTERED host so they fire on every PR. See
# docs/guides/ci-gate-hosting.md.
# DR-9 type-debt register: per-file `as unknown as` count budget compared
# against tools/audit/gates/type-debt-baseline.json. BLOCKING from merge day one
# (an unwired register is the "exists, not wired" class this spec closes).
# Scans the whole src tree, not a PR diff. DR-1 host class: zero-dep
# prefix, unfiltered.
- name: Type-debt count-budget register (DR-9)
run: node tools/audit/gates/check-type-debt.mjs
# DR-10 .test.sh re-asserts (task-015 pattern — every check script's
# self-test runs on every PR, unfiltered). These two are zero-dep node;
# the bun-dependent check-mutation-gate.test.sh re-assert lives in the tsx
# tail below (after the MCP install + a bun setup).
- name: check-type-debt self-test re-assert (DR-10)
run: bash tests/scripts/check-type-debt.test.sh
- name: check-coverage-ratchet self-test re-assert (DR-10)
run: bash tests/scripts/check-coverage-ratchet.test.sh
# Enforcer-wiring gate (task 011, DR-5/DR-8). Zero-dependency node
# script: reconciles every scripts/check-*|lint-* primary against
# tools/audit/gates/enforcer-wiring-manifest.json by TRANSITIVELY walking npm-script
# chains + workflow run-steps and inspecting per-term exit-code handling.
# Catches the four trap classes a name-grep cannot see — orphan,
# unreachable-via-uninvoked-npm-script, exit-code-swallowed (`|| true` /
# continue-on-error), and diff-dependent-gate-without-`synchronize`. Lives
# on this unfiltered host so it fires on every PR (DR-8: a gate in a
# path-filtered job is skipped-as-passed). Self-tests: the four
# `EnforcerWiring_*` cases in tests/scripts/check-enforcer-wiring.test.ts, run by
# the root unit suite (test-root).
- name: Enforcer wiring gate (DR-5/DR-8, task 011)
run: node tools/audit/gates/check-enforcer-wiring.mjs
# Cross-gate fail-closed self-test (task 015, DR-8). The gate's own
# `.test.ts` runs only in the path-filtered `test-root` job (excludes
# `scripts/**`), so a scripts-only PR skips it. This `.test.sh` re-asserts
# the tool-missing + unparseable-output fail-closed paths on THIS
# unfiltered host, where they fire on every PR. Pure node, no install.
- name: Enforcer-wiring gate self-test (DR-8, task 015)
run: bash tests/scripts/check-enforcer-wiring.test.sh
# Plugin-packaging gate (task 064, DR-24). Previously reachable ONLY as
# step 1 of the `npm run validate` && chain, which no workflow invokes
# (class-2 unreachable) — and it was itself RED, so the 8 gates after it
# never executed locally either. Measured 2026-08-07: 1 of 9 declared
# steps ran. Its expectations now live in
# `.claude-plugin/packaging-policy.json`, read at runtime, and the gate is
# zero-dependency node (the old bash+jq implementation is a thin wrapper),
# so it rides this unfiltered zero-dep prefix and fires on every PR.
- name: Plugin-packaging gate (DR-24, task 064)
run: bash tools/audit/gates/validate-plugin.sh
# DR-10 re-assert for the gate above. Its `.test.ts` runs only in the
# path-filtered `test-root` job (which excludes `scripts/**`), so a
# scripts-only PR would skip the case that proves the gate can still fail.
# This `.test.sh` asserts the fail-closed + non-empty-denominator paths on
# THIS unfiltered host. See docs/guides/ci-gate-hosting.md.
- name: Plugin-packaging gate self-test (DR-10, task 064)
run: bash tests/scripts/validate-plugin.test.sh
# The aggregating `npm run validate` runner (task 064, DR-24). Runs every
# step declared in tools/audit/gates/validate-manifest.json instead of
# short-circuiting, so a red early gate can no longer render the later
# ones as skipped-as-passed. `--list` is the cheap reachability proof for
# THIS host: it fails when the manifest declares zero steps, which is the
# non-empty-denominator tooth. The steps themselves are each wired
# individually elsewhere in this job (and in the tsx tail), so running the
# whole chain here would only duplicate them.
- name: Validate manifest declares a non-empty step list (DR-24, task 064)
run: node tools/audit/gates/run-validate.mjs --list
- name: npm-ci-retry helper tests
run: bash tests/scripts/npm-ci-retry.test.sh
# TEMP diagnostic (RCA 2026-05-30-state-source-integrity): the MCP `npm ci`
# wedges fetching native artifacts. Probe each host with hard timeouts to
# pinpoint which egress hangs from the runner. Non-blocking.
- name: CDN connectivity probe
run: |
set +e
for url in \
https://registry.npmjs.org/ \
https://objects.githubusercontent.com/ \
https://github.com/WiseLibs/better-sqlite3/releases/ \
https://nodejs.org/download/release/v24.12.0/SHASUMS256.txt ; do
echo "--- $url ---"
curl -sS -o /dev/null -w " http=%{http_code} connect=%{time_connect}s total=%{time_total}s\n" \
--connect-timeout 15 --max-time 60 "$url" || echo " FAILED/timeout: $url"
done
echo "probe complete"
# ─────────────────────────────────────────────────────────────────
# NOTE: the design's "grep-gates are root-only / zero-dependency" framing
# is inaccurate for this job as a whole — do not rely on it. The tsx-backed
# gates below shell `tsx` over the product TypeScript source, so this
# job installs the package's dependencies (step below).
#
# Rehydration prefix/prose gates (task 011, DR-8). These two gates
# previously ran ONLY via `npm run validate`, which no workflow invokes —
# so a real DR-12 fingerprint drift or DR-13 prose regression passed CI
# (the enforcer-wiring gate above flags this exact class-2 "unreachable"
# trap). They belong on this UNFILTERED host so they fire on every PR
# (DR-8). Unlike the grep-only gates above, each shells out to `tsx` on
# the MCP TypeScript source (fingerprint-cli.ts / prose-lint-cli.ts), so
# this job installs the root devDep (tsx) + the MCP runtime deps (pino,
# reached transitively by fingerprint-cli.ts) before running them. Kept at
# the end of the job so the fast install-free structural gates run first.
- name: Install root dependencies (for tsx-backed gates)
run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
# Duplicate-location ratchet (DR-1, Task 022). An (area, basename)
# intersection guard that FAILS on any legacy __tests__/<area>/<base>.test.ts
# twin of a co-located subject not in the (empty) allowlist. Shares Task
# 001's `enumeratePairs` — so it imports consolidate-suite.mjs, which pulls
# in `typescript` (a root devDependency); it therefore rides the root
# install above rather than the zero-dep prefix. No extra install step.
# The allowlist shrinks to 0 once all 17 pairs relocate, so this gate is
# green only in the consolidated end-state (it lands with/after the pairs).
- name: Duplicate-location ratchet (DR-1, Task 022)
run: node tools/audit/check-no-duplicate-suites.mjs
# ── Wave-S tsx-tail + bun gates (DR-8, DR-2, DR-10) ────────────────────
# These ride the UNFILTERED grep-gates host so they fire on every PR,
# using the BOTH dep trees installed by the two steps above. See
# docs/guides/ci-gate-hosting.md.
# DR-8 vocabulary-lint: scans content/, commands/, docs/architecture/,
# docs/guides/ (broader than any path filter) via a tsx entrypoint that
# needs both dep trees — exactly what this tail already installs, so the
# marginal cost is one tsx invocation. DR-1 host class: deps tail,
# unfiltered.
- name: Vocabulary-lint (DR-8)
run: npm run lint:invariants
# Error-envelope lint (#1706 DR-2, task 002): scans the registration-set
# surface (src/orchestrate/**) via a DEDICATED
# type-aware flat config (eslint.envelopes.config.js — never the shared
# eslint.config.js the filtered test-root lint:windows step loads),
# hosted as a scripts/lint-*.mjs PRIMARY (not a bare eslint invocation)
# so check-enforcer-wiring.mjs's manifest walker can see and reconcile
# it. Rides this UNFILTERED deps-tail — not the filtered test-root — so
# a registered MCP handler that lets a throw abnormally complete it
# fails CI on every PR; needs the root eslint/typescript-eslint
# devDependencies already installed above. DR-1 host class: deps tail,
# unfiltered.
- name: Error-envelope lint (#1706 DR-2)
run: npm run lint:envelopes
# DR-10 .test.sh re-assert (task-015 pattern — every check/lint script's
# self-test runs on every PR, unfiltered): the wrapper's own
# fixture-driven both-direction + fail-closed assertions, so a
# scripts-only PR that skips the filtered test-root suite still proves
# the gate is provably failable.
- name: Error-envelope lint self-test (#1706 DR-2)
run: bash tests/scripts/lint-envelopes.test.sh
# The RULE's own self-test, as distinct from the wrapper's. `lint-envelopes.test.sh`
# above covers the wrapper's exit codes against one violating fixture; these 18
# cases cover the rule's handler resolution (adaptXxx unwrap, zero-arg factory,
# cast, inline literal, the fail-loud `unresolvedHandler` path) and its three
# exemption classes. They were executed by NOTHING: it is a `node:test` file with
# a `.js` extension, and the root vitest `include` list is all `*.test.ts` under
# src/benchmarks/scripts/hooks/test/docs — no `tools/eslint-rules/**`, no `.js` pattern.
# A blocking lint rule whose resolution logic is unproven is the DR-24 shape the
# rest of this lane exists to prevent.
- name: Error-envelope RULE self-test (18 cases)
run: node tools/eslint-rules/no-handler-throw.test.js
# `tools/eslint-rules/tsconfig.json` exists solely to typecheck `__fixtures__/**/*.ts`
# and was compiled by nothing either — root `tsconfig.json` includes `src/**/*`
# and `tsconfig.scripts.json` includes `scripts/**/*.ts`; neither covers this tree.
- name: Typecheck tools/eslint-rules/
run: npx --no-install tsc -p tools/eslint-rules/tsconfig.json
# DR-15/DR-24 ratchets re-asserted UNFILTERED. Both live in root `src/` and were
# hosted only by the `root`-path-filtered test-root lane, while the surfaces they
# SCAN sit outside that filter: `advisory-registry` walks `scripts/**` plus
# `.github/workflows/**`, and `shim-registry` walks
# `src/runtime/**` (the filter names only
# `src/runtime/agents/**` from that tree). So a PR editing exactly
# what they police did not arm the job that runs them — skipped-as-passed, which
# is the failure mode these ratchets were written to close. Hosting them here
# makes each fire on every PR regardless of the paths touched.
- name: Advisory + shim ratchets (DR-15/DR-24, unfiltered)
run: npx --no-install vitest run src/install/advisory-registry.test.ts src/install/shim-registry.test.ts
# DR-2 CI-topology conformance test on the UNFILTERED host: a root-suite
# vitest that parses ci.yml and asserts the ci-gate aggregator's
# completeness / evaluate-coverage / skip-guard coverage. Running it here
# (not only via test-root's filtered suite) means neither the topology nor
# the detector can change without the detector running on that very PR.
# Uses the root vitest installed above. DR-1 host class: deps tail,
# unfiltered.
- name: CI-topology conformance test (DR-2)
run: npx --no-install vitest run tests/scripts/ci-topology.test.ts
# ── `scripts/` typechecks (task 066, DR-24) ────────────────────────────
# Both `scripts/` trees were compiled by NOTHING: `tsconfig.json` and
# `tsconfig.json` both `include: ["src/**/*"]`, so
# the guards living there — cli-derivation-guard.ts, authority-live-proof.ts,
# cli-vocab-guard.ts, guard-inventory.ts, tools/audit/* — were never
# typechecked. Three separate tasks (020, 021, 026) each discovered this
# independently and each hand-ran a standalone `tsc --noEmit` over their own
# files; widening the tree at once surfaced 12 real errors (9 of them in
# tools/audit/), all fixed by narrowing/widening rather than by a cast.
#
# Hosted HERE and not in test-root/test-mcp, deliberately. The scan surface
# of the root scripts project is `scripts/**` PLUS the MCP `src/` modules its
# guards import, and `changes.root` covers neither (this file already notes
# at the enforcer-wiring self-test above that the root filter excludes
# `scripts/**`, so a scripts-only PR skips test-root entirely). An unfiltered
# host makes the two-surface subset rule hold trivially. DR-1 host class:
# deps tail, unfiltered — both dep trees are installed above. See
# docs/guides/ci-gate-hosting.md.
- name: Typecheck the root `scripts/` tree (task 066, DR-24)
run: npx tsc -p tsconfig.scripts.json
# The guard's own self-test on the same host (DR-24: "each guard's
# self-test runs in the same CI job as the guard"). `tsc -p` already fails
# on a config resolving ZERO files (TS18003), but it exits 0 over a config
# that quietly stopped covering the guards it exists to cover — so this
# asserts the resolved file set against a stated coverage floor, and
# carries the zero-file kill fixture.
- name: scripts/ typecheck coverage self-test (task 066, DR-24)
run: npx --no-install vitest run tests/scripts/tsconfig-scripts-coverage.test.ts
# DR-14 cast-census scope (task 022's finding, folded into task 066): the
# census roots must cover every tree the tsconfig projects compile, so a
# directory cannot be typechecked by one gate and un-censused by the other.
# Runs unfiltered for the same reason as the typechecks above — the roots
# span both packages, and `changes.root` covers neither `scripts/` tree.
- name: Cast-census scope + budget ratchet (DR-14, task 066)
run: npx --no-install vitest run src/install/tsconfig-strictness.test.ts
# DR-10 .test.sh re-assert for check-mutation-gate (task-015 pattern,
# unfiltered — every check script's self-test runs on every PR). Its
# invocation seam drives the real mutation-adequacy handler through a
# bun-run bridge (EventStore needs bun:sqlite), so this re-assert needs
# BOTH bun AND the MCP node_modules installed above. grep-gates does not
# otherwise set up bun, so this step adds it (mirrors test-mcp's
# oven-sh/setup-bun@v2 idiom). The other two re-asserts (type-debt,
# coverage-ratchet) are zero-dep node and live in the prefix above.
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- name: check-mutation-gate self-test re-assert (DR-10)
run: bash tests/scripts/check-mutation-gate.test.sh
- name: Prefix-fingerprint gate — rehydration stable-prefix drift (DR-12)
run: node tools/audit/gates/check-prefix-fingerprint.mjs
- name: Prose-lint gate — rehydration prose slop (DR-13)
run: node tools/audit/gates/check-prose-lint.mjs
# Measured-premise drift gate (task 054, DR-27). Binds every annotated
# numeric claim in the internal-mechanics-overhaul spec (+ the invariants
# catalog) to the artifact that produces it, so the document cannot assert
# a number nothing derives. The `outputSchema` census and `EventTypes`
# derivations shell out to `tsx` over the MCP source, so this rides the
# deps tail rather than the zero-dep prefix. Unfiltered on purpose: the
# premises drift when the CODE changes, which is precisely the PR a
# docs-path filter would skip.
- name: Measured-premise drift gate (DR-27)
run: node tools/audit/gates/check-measured-premises.mjs
# Its own self-test on the same host (DR-24: "each guard's self-test runs
# in the same CI job as the guard", so guard-execution failure cannot pass
# as success). Includes the rev-3 kill fixture — a document already proven
# wrong, which the checker must still report as drifted.
- name: Measured-premise gate self-test (DR-27)
run: npx --no-install vitest run tests/scripts/check-measured-premises.test.ts
# No-circular runtime import-cycle ratchet (task 010, DR-4/DR-8). Runs the
# ROOT-hoisted dependency-cruiser over src, computes
# the runtime cycles (Tarjan, via architecture/import-cycles.ts), and diffs
# them against tools/audit/cycle-baseline.json — failing CLOSED on an
# unbaselined cycle, an expired waiver, a PHANTOM entry (baselined edge with
# no live cycle — the no-mask tooth), or missing/unparseable depcruise output
# (exit 2). Blocking, NOT advisory: the shared config ships `no-circular` at
# `warn` so the dogfooded runBoundaryLint stays green; the ratchet lives here.
# Uses the root tsx + depcruise installed by the two steps above. Self-tests:
# the four named DR-4 failure-mode cases in tests/scripts/audit/cycle-gate.test.ts,
# run by the root unit suite (test-root).
- name: No-circular cycle ratchet (DR-4/DR-8, task 010)
run: npx --no-install tsx tools/audit/cycle-gate.ts
# Cross-gate fail-closed self-tests for the two tsx-backed audit gates
# (task 015, DR-8). Their own `.test.ts` cases run only in the
# path-filtered `test-root` job (excludes `scripts/**`), so a scripts-only
# PR skips them. These `.test.sh` re-assert the tool-missing +
# unparseable-output fail-closed paths here, driving the REAL CLI via the
# EXARCHOS_{DEPCRUISE,KNIP}_BIN seam (a missing path / a garbage-emitting
# stub) — so depcruise/knip need not even be resolvable. Uses the root tsx
# installed by the two steps above.
- name: Cycle-gate ratchet self-test (DR-8, task 015)
run: bash tests/scripts/audit/cycle-gate.test.sh
- name: Knip-diff gate self-test (DR-8, task 015)
run: bash tests/scripts/audit/knip-diff.test.sh
# Module-intent gate (task 013, DR-7/DR-8). Delegates reachability to the
# vendored tools/audit/refgraph.mjs detector, then requires every
# dead-in-prod module (0 production importers) to declare intent: a
# RESERVED(issue, owner, expires) header with a future expiry, OR
# membership in a declared allowlist class (test-infra / build-shim /
# type-test entrypoint). Expired-and-unadopted RESERVED → FAIL (the DR-7
# "deletion happens at expiry" enforcement point). Fails CLOSED (exit 2) on
# a scan crash / unparseable detector output / unreadable module (DR-8) —
# never passes on partial evidence. Zero-dependency node; refgraph.mjs is
# itself pure node:fs. Self-tests: the DR-7/DR-8 cases in
# tests/scripts/check-module-intent.test.ts, run by the root unit suite (test-root).
- name: Module-intent gate (DR-7/DR-8, task 013)
run: node tools/audit/gates/check-module-intent.mjs
# Cross-gate fail-closed self-test (task 015, DR-8). The gate's own
# `.test.ts` runs only in the path-filtered `test-root` job (excludes
# `scripts/**`), so a scripts-only PR skips it. This `.test.sh` re-asserts
# the tool-missing + unparseable-output fail-closed paths here (via the
# `--refgraph` seam: a missing detector / a garbage-emitting stub). Pure
# node, no install.
- name: Module-intent gate self-test (DR-8, task 015)
run: bash tests/scripts/check-module-intent.test.sh
# Wave-1 guard inventory + CI-reachability proof (DR-24, task 063). Parses
# this workflow, both package.json script tables, both vitest configs and
# the enforcer-wiring manifest, then proves every guard Wave 1 shipped is
# executed by some CI job — or carries a recorded, EXPIRING reason why not.
# Also reports path-filtered hosting rather than accepting it (#1711's
# skipped-as-passed failure), and fails closed on a zero-guard resolution.
# This step IS the guard: the module is a library, its assertions live in
# the test, so hosting the test here satisfies DR-24's "each guard's
# self-test runs in the same CI job as the guard" for free. Must be
# UNFILTERED — `scripts/**` is outside the `root` path filter, so the
# filtered `test-root` job skips-as-passed on exactly the PR that edits this
# inventory. Rides the deps tail (needs vitest + typescript + js-yaml from
# the root install above), not the zero-dep prefix.
# See docs/guides/ci-gate-hosting.md.
- name: Wave-1 guard-inventory reachability proof (DR-24, task 063)
run: npx --no-install vitest run tests/scripts/guard-inventory.test.ts
# Cast-census self-test re-assert (DR-24, task 063 finding). The DR-14 cast
# budget is the constraint every task in this wave is judged against, and
# its census (`tools/audit/tsconfig-strictness/count-casts.ts`) was hosted ONLY
# by the `root`-filtered `test-root` job — while its own source sits under
# `scripts/**`, which the `root` filter excludes. A PR that weakened the
# census therefore never armed the only job that runs it: the two-surface
# subset rule violated, found by the inventory above. This re-assert closes
# the implementation-surface half on the unfiltered host. It is the
# fixture-driven SELF-TEST only (tmpdir + string inputs); the repo-wide
# ratchet stays in `src/install/tsconfig-strictness.test.ts` on `test-root`, so this
# step adds no new repo-wide assertion. Same DR-10 pattern as the
# `.test.sh` re-asserts above.
- name: Cast-census self-test re-assert (DR-24, unfiltered)
run: npx --no-install vitest run tests/scripts/tsconfig-strictness/count-casts.test.ts
# G2 `outputSchema` vacuity ratchet (DR-4, task 017). Runs all four teeth
# against the live triple: membership (allowlist vs census, both
# directions), seed key-set integrity (vs the frozen digest), and — new in
# task 017 — the per-entry EXPIRY, enforced rather than advisory. Tasks 055
# and 060 shipped the first three but wired NONE of them to an executable
# gate: the whole `auditVacuity*` family was driven only by its co-located
# vitest in the `mcp`-PATH-FILTERED `test-mcp` job, which
# tools/audit/gates/guard-inventory.ts names as an R-11 instance and #1711 names as
# skipped-as-passed on exactly the PRs it polices. This step is the gate.
#
# This is ALSO the one place DR-4's mechanism reads the wall clock: the
# library takes `today` as a required argument so its unit tests are
# deterministic, and the deadline reddens the MERGE rather than a
# developer's local `vitest run`. Expect it to go red of its own accord
# after VACUITY_EXPIRY_HORIZON (2027-02-28) — that is the deadline working,
# and the repair is paying declarations down, never re-dating the horizon.
#
# Rides the deps tail (needs `tsx` + the MCP node_modules to resolve the
# registry's Zod objects — a named binding launders a source-text grep, so
# the schema OBJECT must be walked), not the zero-dep prefix. Must be
# UNFILTERED: the allowlist, the pin and this guard all live under
# the `mcp` filter, but the ratchet governs a repo-wide contract
# and a `mcp`-filtered host skips-as-passed on any PR outside it.
# See docs/guides/ci-gate-hosting.md.
- name: outputSchema vacuity ratchet — G2 + enforced expiry (DR-4, task 017)
run: npx --no-install tsx tools/audit/core/output-schema-ratchet-guard.ts
# Its self-tests on the SAME host (DR-24: "each guard's self-test runs in
# the same CI job as the guard", so guard-execution failure cannot pass as
# success). Includes task 017's kill fixtures — the live 112-entry seed
# carried one day past its horizon, and the blanket "bump every date in one
# commit" renewal — both of which must be RED. Hosting the census and
# allowlist suites here too closes their implementation-surface half: their
# only other host is the `mcp`-filtered `test-mcp`.
#
# `output-schema-census.selftest.test.ts` (task 018) is the fourth file and
# covers what the other three structurally cannot: they all call
# `runGuard()` in-process and read its RETURN VALUE, so DR-4's entrypoint
# tail — the `isDirectRun` predicate and the `process.exit` that consumes
# it — was executed by nothing. It spawns the step ABOVE as a real process.
# It must be hosted HERE and not only on the `mcp`-filtered `test-mcp`: a
# self-test for an unfiltered gate that is itself path-filtered is #1711's
# skipped-as-passed on exactly the PR that breaks the gate.
#
# SPLIT ACROSS TWO WORKSPACES by task 018a. The census and its selftest
# moved to `tools/conformance` (root vitest, `conformance` project); the
# ratchet guard's own test and the allowlist test stayed with the subject.
# Both halves must remain hosted here — dropping either recreates the
# skipped-as-passed hole this step was written to close.
- name: outputSchema ratchet self-tests re-assert (DR-4/DR-24, unfiltered)
run: >-
npx --no-install vitest run
tests/core/scripts/output-schema-ratchet-guard.test.ts
src/output-schema-vacuity-allowlist.test.ts
# THE WHOLE extracted conformance suite, unfiltered — G2's census and
# selftest, G3, G5, and every other census task 018a moved out.
#
# One step for the package rather than one per guard, and the reason is
# the two-surface subset rule rather than brevity. These censuses now live
# under `tools/conformance/**`, which no `dorny/paths-filter` key matches,
# so their only other host (`test-root`, filtered on `root`) is armed by
# NO PR that edits them: #1711's skipped-as-passed, pointed at the
# enforcement suite itself. An unfiltered host clears it for the whole
# package at once, and cannot go stale as the package grows.
#
# The alternative was widening `root` to cover `tools/conformance/**`.
# Rejected for the reason already recorded in GUARD_EXEMPTIONS: that key
# also arms `test-windows-root`, the lane #1699 has never proven green.
- name: Conformance suite — every extracted census (DR-2/DR-4/DR-6/DR-24, unfiltered)
run: npm run test:conformance
# G1 CLI-derivation allowlist ratchet (DR-5, task 023). Parses the CLI
# composition root, derives the hand-written-literal population from that
# parse, and holds it to a shrink-only allowlist: membership in BOTH
# directions (an untracked literal fails; a tracked name that is no longer a
# literal goes stale), a frozen key-set pin over `allowed ∪ retired` that
# catches an in-place swap no count and no comparison-against-today can see,
# and a per-entry ISO expiry that is ENFORCED and capped by one pinned
# horizon so a waiver cannot renew itself.
#
# This is ALSO the one place DR-5's mechanism reads the wall clock: the
# library takes `today` as a required argument so its unit tests are
# deterministic, and the deadline reddens the MERGE rather than a
# developer's local `vitest run`. Expect it to go red of its own accord
# after CLI_DERIVATION_EXPIRY_HORIZON (2027-02-28) — that is the deadline
# working, and the repair is registering verbs through a derivation helper,
# never re-dating the horizon.
#
# This is the RATCHET entrypoint; its DERIVATION sibling is wired directly
# below. The ratchet states the SHRINK-ONLY policy over the tolerated set;
# the sibling states the DERIVATION policy ("no un-allowlisted literal
# `.command(` at all"). Both are blocking as of task 076.
#
# Rides the deps tail (needs `tsx` + `typescript`; it parses the source and
# never resolves `buildCli`, so unlike the sibling `cli-vocab-guard` it
# needs neither Bun nor `bun:sqlite`), not the zero-dep prefix. Must be
# UNFILTERED: the policy file, the pin and this guard all live under
# the `mcp` filter, but a `mcp`-filtered host skips-as-passed on
# any PR outside it. See docs/guides/ci-gate-hosting.md.
- name: CLI derivation allowlist ratchet — G1 shrink-only + enforced expiry (DR-5, task 023)
run: npx --no-install tsx tools/audit/core/cli-derivation-ratchet-guard.ts
# G1 DERIVATION entrypoint — direct and BLOCKING as of task 076 (DR-5).
#
# This gate spent Wave 1 in GUARD_EXEMPTIONS as `unreachable`: it exited 1
# by design, because `merge-orchestrate` was declared twice (registry action
# + a hand-written command in the composition root) and — being the kill
# fixture — could not be allowlisted around. Task 023 paid the other ten
# literals down to tracked debt; task 076 deleted the eleventh by moving the
# promotion onto the registry's `cli.topLevel` hint, which is what let this
# entrypoint go green on a clean tree. The exemption entry is REMOVED, not
# re-dated, so `guard-inventory` now reports it `enforcement: blocks`,
# `pathFilteredOnly: false`, `via=direct`.
#
# Invoked DIRECTLY (no `npm run` wrapper) so the inventory's indirection
# resolver records `via=direct` rather than walking a shell shim. Same host
# class as the ratchet above: deps tail, unfiltered, for the same reason.
- name: CLI derivation guard — G1 no un-allowlisted literal commands (DR-5, task 076)
run: npx --no-install tsx tools/audit/core/cli-derivation-guard.ts
# Its self-tests, plus the DERIVATION guard's, on the SAME host (DR-24:
# "each guard's self-test runs in the same CI job as the guard"). These
# carry task 023's kill probes — a seeded eleventh entry, an in-place swap,
# a paydown recorded as a deletion, the live seed carried one day past its
# horizon, the blanket "bump every date" renewal and an emptied allowlist —
# and task 021/022's kill fixture — RE-SEEDED by task 076, which deleted
# the live subject by fixing the defect: the proof now splices the deleted
# command back into the real composition root and re-runs the same guard,
# while a separate assertion pins that the LIVE tree no longer carries it.
# Both `merge-orchestrate` policy-map exclusions still assert. Hosting them
# here closes their
# implementation-surface half: their only other host is the `mcp`-filtered
# `test-mcp`, and `changes.mcp` does not arm on a workflow-only PR.
- name: CLI derivation guard + ratchet self-tests re-assert (DR-5/DR-24, unfiltered)
run: >-
npx --no-install vitest run
tests/core/scripts/cli-derivation-ratchet-guard.test.ts
tests/core/scripts/cli-derivation-guard.test.ts
tests/core/scripts/cli-vocab-guard.test.ts
# G3 report-coupled ratchet (DR-2, task 013). Enumerates the live event
# registry, derives each registration's coupling through the DR-2 tier
# union, and holds the report-coupled population to a shrink-only seed
# (membership + ISO expiry + a frozen key-set pin against in-place swaps).
# The census module is a pure library, so this test file IS the guard — and
# it carries the kill fixtures (a seeded 26th report-coupled registration,
# an emptied denominator, a seeded tier/source disagreement), which
# satisfies DR-24's "each guard's self-test runs in the same CI job as the
# guard" for free: guard-execution failure exits non-zero here rather than
# passing as success. Must be UNFILTERED — the co-located test would
# otherwise be hosted only by `test-mcp`, which is filtered on `mcp` and
# skips-as-passed on exactly the PRs #1711 describes. Rides the deps tail,
# not the zero-dep prefix: it resolves the MCP TypeScript source through
# the MCP vitest installed by the two steps above.
# See docs/guides/ci-gate-hosting.md.
# G3's census moved to `tools/conformance` by task 018a and is now hosted
# by the whole-suite step above, which is unfiltered for the same reason
# this step was.
# G4 effect-ledger bijection (DR-7) and G5 authority-topology census (DR-6),
# hosted UNFILTERED — added by task 027 at the Wave-1 exit.
#
# Both guards were `pathFilteredOnly: true` until now: their only hosts were
# `test-mcp` and `test-windows`, which are filtered on `mcp`. That is the
# #1711 shape exactly — the gate is skipped-as-passed on the PRs it polices,
# and `guard-inventory` reports the condition rather than accepting it. The
# Wave-1 exit proof (`wave1-exit.test.ts`) asserts every declared Wave-1
# guard has at least one unfiltered host, and these two were the only
# members failing it.
#
# This wires HOSTING, not enforcement severity. G5 remains observe-then-
# enforce PER ROW via each authority-topology row's `enforceFrom` field
# (§3a, reconciled in rev 3) — flipping it wholesale here would red-line CI
# for four waves, which is the contradiction rev 3 was written to remove.
# What changes is that the guard now RUNS on every PR instead of only on
# `mcp`-touching ones.
#
# SPLIT ACROSS TWO WORKSPACES by task 018a. G5's census moved to
# `tools/conformance`; G4's `effect-ledger.ts` did NOT — it has seven
# production consumers, so extracting it would have inverted the
# dependency direction between `src/` and `tools/`.
# The SUBJECT still lives at `src/architecture/effect-ledger.ts`; task 030
# moved only its self-test into the `tests/unit/` mirror.
- name: G4 effect-ledger self-test (DR-7/DR-24, unfiltered)
run: npx --no-install vitest run tests/unit/architecture/effect-ledger.test.ts
# G5's census moved with G3's; the whole-suite step above hosts it.
# DR-3 event-name grammar census + two-way ratchet (task 015). Enumerates
# the LIVE registry at runtime — `getValidEventTypes()`, so custom
# `registerEventType` names are in the denominator, which is the population
# no compile-time proof over `EventType` can see — decides every name
# against task 014's grammar, and holds the recorded grammar concessions to
# a two-way ratchet: a registered name the grammar rejects fails, and a
# recorded concession no live name exercises fails as stale cover. The
# census module is a pure library, so this test file IS the guard, and it
# carries the kill fixtures (a REAL malformed custom registration that
# `registerEventType` accepts today, an emptied denominator, an emptied
# concession table, a repaired EVENT_NAME_PATTERN, a lapsed expiry) — which
# satisfies DR-24's "each guard's self-test runs in the same CI job as the
# guard" for free.
#
# This is also where DR-3's mechanism reads the wall clock: the library
# takes `today` as a required argument so its unit cases are deterministic,
# and the concession deadline reddens the MERGE. Expect it to go red of its
# own accord after the seeded expiry (2027-02-28) — that is the deadline
# working, and the repair is retiring the concession, never re-dating it.
#
# DR-3's event-name grammar census also moved to `tools/conformance` and
# is hosted by the whole-suite step above — unfiltered, as it must be: the
# census governs a repo-wide naming contract.
# See docs/guides/ci-gate-hosting.md.
# Audit-delivery closure (DR-4/DR-24, task 069). Holds every declared
# delivery obligation to BOTH halves: the producing action's live
# `outputSchema` must declare the delivered field and its enumerator as
# required typed properties, and a declared reader document must carry the
# whole instruction inside one section. It exists because
# `check_invariant_conformance` computed `auditPrompt` and returned it
# through a `vacuityWaiver` schema while NO skill, command, rule or doc told
# anybody to read it — the field occurred in exactly five files repo-wide,
# four of them its own tests. The module is a pure library, so this test file
# IS the guard, and it carries the kill fixtures (the pre-069 vacuous
# contract, a reader that only invokes the gate, scattered non-co-located
# mentions, an emptied obligation list) — which satisfies DR-24's "each
# guard's self-test runs in the same CI job as the guard" for free.
#
# Must be UNFILTERED: the obligation binds a file under
# the `mcp` filter to one under `content/**`, so EITHER path
# filter would skip-as-passed on exactly the PR that breaks the binding
# from the other side. Rides the deps tail — it walks live Zod objects.
# See docs/guides/ci-gate-hosting.md.
- name: Audit-delivery closure (DR-4/DR-24, task 069)
run: npx --no-install vitest run src/architecture/audit-delivery-closure.test.ts
# ─────────────────────────────────────────────────────────────────────
# e2e-process
# ─────────────────────────────────────────────────────────────────────
# Runs the `process` vitest project against a real compiled binary.
# The project's setupFiles assert `exarchos` is on PATH, so we build
# the host (linux-x64) binary and symlink it before invoking vitest.
#
# The job covers the full process-fidelity matrix landed in W1–W5 of
# the v2.9.0 e2e harness:
# - P1 fixtures (preflight, runCli, spawnMcpClient, normalizers)
# - P2 saga primitives + #1208 regression
# - P3 parity tests (workflow.describe, event.query, workflow.rehydrate
# with F6.1 reconstructability)
# - P4 CLI surface tests (version, doctor, install-skills, schema,
# topology, emissions, mcp start/stop)
#
# Linux only — Windows process fidelity is v2.10 P5.
#
# DISPOSITION (DR-4, wave-S enforcement-substrate spec, corrects the stale
# "Blocking gate as of T3.7" claim below, which was never true — this job
# has never been in `ci-gate.needs`): NON-BLOCKING. Measured over the last
# 60 completed `ci.yml` runs (2026-07-17) via `gh api .../actions/runs/<id>/jobs`:
# executed 60/60 (no path filter), failed 2/60 — a 3.33% failure-rate-when-
# executed, both failures in the "Run process-fidelity suite" step and
# consistent with the known SQLITE_BUSY flake cluster. That is above the
# spec's 2% blocking threshold, so this job stays non-blocking and is
# recorded (with this measured rate) in the `tests/scripts/ci-topology.test.ts`
# non-blocking allowlist. Follow-up to burn down the flake and flip this to
# blocking: lvlup-sw/exarchos#1718. Host class: deps, unfiltered,
# suite-shaped — see docs/guides/ci-gate-hosting.md.
# ─────────────────────────────────────────────────────────────────────
e2e-process:
name: E2E Process (linux-x64)
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
# One closure since task 019: vitest and the build-binary toolchain sit
# beside pino/commander/@modelcontextprotocol/zod/yaml in the same manifest.
- name: Install dependencies
run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
- name: Build host binary (linux-x64)
run: bun run tools/release/build-binary.ts --target linux-x64
- name: Symlink exarchos onto PATH
run: sudo ln -s "$PWD/dist/bin/exarchos-linux-x64" /usr/local/bin/exarchos
- name: Verify exarchos on PATH
run: which exarchos && exarchos --version
- name: Run process-fidelity suite
run: npm run test:process
# ─────────────────────────────────────────────────────────────────────
# outcome-tests
# ─────────────────────────────────────────────────────────────────────
# Runs the `outcome` vitest project (Wave 1 data-safety substrate,
# Phase B). The tier exercises operator-visible behaviour using real
# OS state (tmpdir HOME, real git repos with sibling worktrees) via
# helpers under `tests/outcome/_helpers/`.
#
# Linux-only by design: the helpers in T-010/T-011 use POSIX-style
# path semantics and `git worktree` invocations that are not yet
# validated on Windows. Cross-platform outcome coverage is deferred
# to a later wave alongside the v2.10 Windows process-fidelity work.
#
# NOTE: marking this job as a required branch-protection check is a
# manual repo-settings step (Settings → Branches → main → required
# status checks). Capture that as a manual followup in the merge PR.
# ─────────────────────────────────────────────────────────────────────
outcome-tests:
name: Outcome Tests (linux-x64)
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: package-lock.json
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
# One closure since task 019: vitest, the outcome project's `bun:sqlite`
# alias target, and `better-sqlite3` (the shim's runtime dependency) all
# come from the same manifest.
- name: Install dependencies
run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh"
# The install-skills outcome test invokes the platform binary at
# `dist/bin/exarchos-<os>-<arch>`. Without this build step the test
# silently degrades to "no on-disk effects → empty manifest →
# assertion failure", which masks real regressions in the installer.
- name: Build host binary (linux-x64)
run: bun run tools/release/build-binary.ts --target linux-x64
- name: Configure git identity for outcome helpers
run: |
git config --global user.email "outcome-tests@example.com"
git config --global user.name "outcome-tests"
- name: Run outcome-tier suite
run: npm run test:outcome
ci-gate:
name: CI Gate
runs-on: ubuntu-latest
needs: [changes, test-root, test-mcp, test-windows, test-windows-root, validate-no-legacy, manifest-gate, grep-gates, outcome-tests]
if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && always()
steps:
- name: Evaluate results
run: |
echo "changes=${{ needs.changes.result }}"
echo "test-root=${{ needs.test-root.result }}"
echo "test-mcp=${{ needs.test-mcp.result }}"
echo "test-windows=${{ needs.test-windows.result }}"
echo "test-windows-root=${{ needs.test-windows-root.result }}"
echo "validate-no-legacy=${{ needs.validate-no-legacy.result }}"
echo "manifest-gate=${{ needs.manifest-gate.result }}"
echo "grep-gates=${{ needs.grep-gates.result }}"
echo "outcome-tests=${{ needs.outcome-tests.result }}"
if [[ "${{ needs.changes.result }}" =~ ^(failure|cancelled)$ ]]; then
echo "::error::Change detection: ${{ needs.changes.result }}"
exit 1
fi
if [[ "${{ needs.test-root.result }}" =~ ^(failure|cancelled)$ ]]; then
echo "::error::Root package tests: ${{ needs.test-root.result }}"
exit 1
fi
# DR-3 (wave-S skip-guard, mirrors the test-windows/test-windows-root
# pattern below): test-root is REQUIRED for any root-touching PR. A
# generic skip is OK (non-root PRs legitimately skip test-root via
# `changes.outputs.root`, host class: deps, filtered — see
# docs/guides/ci-gate-hosting.md). But if root code DID change and
# the lane was skipped, that's a path-filter/matrix regression
# silently dropping our only Linux root-package coverage
# (skipped-as-passed hole) — fail closed.
if [[ "${{ needs.changes.outputs.root }}" == 'true' \
&& "${{ needs.test-root.result }}" == 'skipped' ]]; then
echo "::error::Root package tests (test-root) skipped on a root-touching PR (path-filter/matrix regression dropped required root coverage)"
exit 1
fi
if [[ "${{ needs.test-mcp.result }}" =~ ^(failure|cancelled)$ ]]; then
echo "::error::MCP server tests: ${{ needs.test-mcp.result }}"
exit 1
fi
# DR-3 (wave-S skip-guard, mirrors the test-windows/test-windows-root
# pattern below): test-mcp is REQUIRED for any MCP-touching PR. A
# generic skip is OK (non-MCP PRs legitimately skip test-mcp via
# `changes.outputs.mcp`, host class: deps, filtered — see
# docs/guides/ci-gate-hosting.md). But if MCP code DID change and
# the lane was skipped, that's a path-filter/matrix regression
# silently dropping our only Linux MCP-package coverage
# (skipped-as-passed hole) — fail closed.
if [[ "${{ needs.changes.outputs.mcp }}" == 'true' \
&& "${{ needs.test-mcp.result }}" == 'skipped' ]]; then
echo "::error::MCP server tests (test-mcp) skipped on an MCP-touching PR (path-filter/matrix regression dropped required MCP coverage)"
exit 1
fi
# test-windows is BLOCKING. The Windows-portability gap (#1620) is
# closed — the windows-latest MCP suite is green — so a red Windows
# job now fails CI like any other tier, guarding against regressions.
if [[ "${{ needs.test-windows.result }}" =~ ^(failure|cancelled)$ ]]; then
echo "::error::Windows unit tests: ${{ needs.test-windows.result }}"
exit 1
fi
# DR-5 (WLM slice 3): the windows lane is REQUIRED for any MCP change.
# The aggregator treats generic skips as OK (non-MCP PRs legitimately
# skip test-windows via `changes.outputs.mcp`). But if MCP code DID
# change and the lane was skipped, that's a path-filter/matrix
# regression dropping our only Windows coverage — fail closed.
if [[ "${{ needs.changes.outputs.mcp }}" == 'true' \
&& "${{ needs.test-windows.result }}" == 'skipped' ]]; then
echo "::error::Windows lane skipped on an MCP-touching PR (path-filter/matrix regression dropped required Windows coverage)"
exit 1
fi
# test-windows-root (task 021) is BLOCKING — same posture as
# test-windows. It gives the root package (installer surfaces, src/)
# its first Windows coverage, so a red root Windows job fails CI.
if [[ "${{ needs.test-windows-root.result }}" =~ ^(failure|cancelled)$ ]]; then
echo "::error::Windows root-package tests: ${{ needs.test-windows-root.result }}"
exit 1
fi
# The root Windows lane is REQUIRED for any root-source change. A
# generic skip is OK (non-root PRs legitimately skip test-windows-root
# via `changes.outputs.root`). But if root code DID change and the
# lane was skipped, that's a path-filter/matrix regression dropping
# our only Windows coverage of the root package — fail closed.
if [[ "${{ needs.changes.outputs.root }}" == 'true' \
&& "${{ needs.test-windows-root.result }}" == 'skipped' ]]; then
echo "::error::Windows root lane skipped on a root-touching PR (path-filter/matrix regression dropped required Windows root coverage)"
exit 1
fi
if [[ "${{ needs.validate-no-legacy.result }}" =~ ^(failure|cancelled)$ ]]; then
echo "::error::Validate no legacy: ${{ needs.validate-no-legacy.result }}"
exit 1
fi
# manifest-gate (DR-2, Task 004) is BLOCKING. It runs on every PR and
# is inert unless the PR touches a consolidation pair, so it needs no
# path-filter skip-guard (its `if:` references no changes.outputs.*).
if [[ "${{ needs.manifest-gate.result }}" =~ ^(failure|cancelled)$ ]]; then
echo "::error::Manifest gate (textual case-preservation): ${{ needs.manifest-gate.result }}"
exit 1
fi
if [[ "${{ needs.grep-gates.result }}" =~ ^(failure|cancelled)$ ]]; then
echo "::error::Grep gates: ${{ needs.grep-gates.result }}"
exit 1
fi
if [[ "${{ needs.outcome-tests.result }}" =~ ^(failure|cancelled)$ ]]; then
echo "::error::Outcome tests: ${{ needs.outcome-tests.result }}"
exit 1
fi
echo "All checks passed (skipped jobs are OK)"