Skip to main content

CI gates

Convention without enforcement rots. Every rule in AGENTS.md that an agent could plausibly violate has a paired CI gate that fails the PR if it slips. This page is the single source of truth for what those gates are, where they live, and how to bypass them safely.

Most of the mechanical gates (rows 1, 3–10, 14, 16) live as steps inside the single Quality Gates job of .github/workflows/ci.yml — but Quality Gates is one job among several parallel top-level jobs in that same workflow file, not the whole pipeline. Lint · Typecheck, Build, Test · CI, E2E Smoke and Verify Migration Back-Compat are each their own job (see rows 2, 11–13, 19), and Verify Release Migrations / Verify Release KB Curation are release-PR-only jobs. The source-PR jobs run on every PR targeting develop; the migration back-compat gate additionally covers hotfix PRs to main. A release/* PR is exempt from that gate only when it contains the current origin/develop wave and has no additional migration delta.

The gates

#GateScriptWhat it enforcesBypass marker
1Formatnpx prettier --checkRepo-wide Prettier compliance.— (fix it: prettier --write)
2Migration tracking (Build job, not Quality Gates)npx tsx scripts/check-migrations.tsEvery Prisma migration in */migrations/ has a tracking row in the right schema_migrations table. Guards against the 2026-05-10 dual-tracking-table footgun. Runs as the "Verify migrations" step after npx turbo run build in the Build job.
3Audit coverage lint(in lint step)Security-sensitive mutations use auditCritical(); routine ones use auditAction().— (per-callsite // audit-coverage: skip <reason> comment)
4OpenAPI freshnessnpm run check:openapiCommitted apps/project-tracker/openapi.json matches the Zod schemas. Run npx turbo run generate-openapi --filter=@constellation/project-tracker to regenerate.
5Changeset presentnpx tsx scripts/check-changeset.tsEvery PR with user-visible impact adds a .changeset/*.md fragment.[skip changeset]
6PR requirementsnpx tsx scripts/check-pr-requirements.ts(a) Title/body cites a ticket; (b) feat: PRs add/modify a spec; (c) added/modified .ai/specs/*.md matches the spec format (INF-107).[skip ticket], [skip spec], [skip spec-format]
7AC traceability (INF-110)npx tsx scripts/check-ac-traceability.tsEvery numbered AC in a changed .ai/specs/*.md resolves to a code anchor (→ file:symbol), file anchor (→ path/to/file.md), UI marker (→ <!-- ui: ... -->), or explicit waiver (→ <!-- deferred: ... -->).[skip ac-traceability]
8Docs-sync (INF-111, warn-mode v1)npx tsx scripts/check-docs-sync.tsBehaviour-changing source files (routes, services, schema, event contracts) have a paired apps/docs/docs/** edit. Warn-only in v1 — flip to hard-fail with DOCS_SYNC_ENFORCE=1 after a tuning window.[skip docs]
9Scripts unit testsnpm run test:scriptsVitest run against scripts/**/*.test.ts (the parsers / validators / CI gates).
10Route wrappingnpm run check:routesEvery route in catalog / directory / wiki is wrapped with authedRoute / authedRouteWithParams.Per-route // @route-wrap: skip <reason>
10bAgent-guidance docs (INF-179)npm run check:agent-docsAGENTS.md ↔ .claude/skills / .claude/agents alignment: path references resolve, the task router and skill/agent files stay cross-referenced (no orphans either way), and every skill/agent carries name + description frontmatter.[skip agent-docs]
11Lint + typecheck (own Lint · Typecheck job)npx turbo run lint typecheckESLint + TypeScript strict across the monorepo.
12aBuild (own Build job)npx turbo run buildFull monorepo build, plus the migration-tracking check (row 2) as a follow-up step.
12bTest (own Test · CI job)npx turbo run test:ciUnit + integration + property tests, run in parallel with Build against a real Postgres + MinIO.
13E2E smoke (own E2E Smoke job)Playwright saved-filters.smoke.spec.tsSaved-filters POST → GET → DELETE round-trip against a seeded next dev (PT-26 regression gate).
14Wiki MCP boundarynpx tsx scripts/check-wiki-mcp-boundary.tsWiki MCP tools stay within the documented boundary.
15Verify release migrations(release PRs only)Both staging + production DBs have every migration applied before the release PR opens. See .claude/skills/release-and-migrations.— (rerun after applying)
16Review threads resolvednpx tsx scripts/check-review-threads.tsNo unresolved review comment thread on the PR (mirrors branch protection's required_conversation_resolution flag as a visible CI status). Author-agnostic — any unresolved thread blocks, including a reviewer's own. Only re-runs on pull_request activity, so resolving threads without a new commit needs gh run rerun --failed to refresh the status.[skip thread-check]
17Verify release KB curation (PLT-317)scripts/check-release-kb-curation.ts (release PRs only)The release PR body carries the own-line marker KB curation pass: <YYYY-MM-DD> — <who ran it; outcome summary>, with the date matching the release branch date — proof the per-release wiki-curator judgement pass (PLT-301) actually ran.— (no marker bypass; run the pass)
18Dependency CVE gate (INF-396)npm run check:dependency-auditFails on a high/critical advisory against a production dependency, keyed on the GHSA id rather than the package (npm reports a package's MAXIMUM severity, so a by-package waiver would silently accept its future advisories). Reads the committed lockfile and reports every resolved copy. Its scope is pinned on the command line — one --workspace= per workspace, enumerated from the lockfile — so no .npmrc layer can narrow what is audited; a lockfile yielding no workspaces is an error, never an empty scope. PR-scoped to diffs touching a manifest, a lockfile, the waiver record, the checker or its workflows; dependency-audit-nightly.yml re-audits develop daily. Unfixable advisories go in scripts/dependency-audit-waivers.json with a GHSA id, rationale, review date and the exact resolved copies reviewed.Per-advisory waiver record
19Migration back-compat (PT-927)npm run check:migration-compatMigrations apply before the code deploys, so every migration must work against the outgoing build. Shape-triggered, not blanket: it fires only on a migration not yet on main that reads an app.* GUC the deployed build does not set, reaches NOT NULL with no syntactically usable DEFAULT, or drops/renames a name the old build may still use. "Does the deployed build set it?" is answered from the curated registry scripts/deployed-app-gucs.json, whose every entry names the commit that shipped its setter — verified to be an ancestor of origin/main, so listing a GUC in the same PR as the migration that reads it does not help. Each flagged file then needs one own-line Migration back-compat: <path> — <why the outgoing build is safe> in the PR body — path-scoped, so a later migration inherits nothing. Covers source PRs to develop and hotfix PRs to main; a release-named PR is skipped only when it contains current origin/develop and no migration delta beyond that wave. The shapes it cannot decide (a new trigger or CHECK constraint, a changed column meaning, a backfill correct only under the new code, or execution-dependent default semantics) are printed on every run and stay a review responsibility.[skip migration-compat]
20Codex evidence (INF-348 / INF-371)npm run check:codex-evidenceA source PR to develop, and a release/*main PR, must carry an own-line Codex review: <YYYY-MM-DD> — <rounds; findings; outcome> — <head sha> marker recording the mandatory cross-model review loop. Enforced: a real calendar date neither in the future nor earlier than the PR; both counts attached to their field names (2 rounds, 5 findings — a bare number could be a ticket id); and the head SHA the review covered, an abbreviation of 7+ characters matched against the PR head. The SHA binding (INF-371) is what makes a green check mean the pass covered this code rather than a pass was claimed.[skip codex-review]
21Migration number collisions (PT-925)npm run check:migration-collisionsNo two migrations in one directory share a leading number. Covers all eight migration directories and both layouts, mirroring each module runner's own predicate — including that the directory layout (NNN_name/migration.sql) is understood by project-tracker's runner alone, so such an entry elsewhere is reported as never-applied rather than counted. A letter suffix is a distinct version, never a collision (029b sorts between 029 and 030 by design, PLT-653). The 23 pre-existing shipped collisions are grandfathered by exact file set, not by number, so a third file at an already-grandfathered number still fails; an entry matching no on-disk collision, or naming an unconfigured directory, is itself an error. Runs in Quality Gates and as gate 19 of the pre-push local tier — the collision exists only in the union of two branches, so a purely local run cannot see it.None — renumber the unmerged migration

Bypass-marker matching

Skip markers are matched strictly so a PR description that documents a marker doesn't accidentally trigger the bypass:

  • In the title — any substring match counts (titles are short and intentional).
  • In the body — the marker must appear on its own line, trimmed. Inline mentions, backticked formatting (`[skip X]`), and prose like "use [skip X] to bypass" do NOT trigger the bypass.

Use markers sparingly and only when the rule genuinely does not apply. Every bypass should carry a written reason next to it so reviewers can judge intent.

The newest gates in detail

AC traceability — check-ac-traceability.ts (INF-110)

Reads every .ai/specs/*.md in the PR diff, parses the ## Acceptance criteria section, and verifies each numbered AC ends with one of four anchor forms:

Anchor formMeaningVerification
→ path/to/file.ts:symbolCode symbol is present in the diffGreps the file (at HEAD) for symbol definitions / exports / property heads.
→ path/to/file.md (no colon)Non-code file is present in the diffChecks the file appears in the PR's changed-file set. Use for AGENTS.md edits, agent / skill / workflow files, docs pages.
→ <!-- ui: <route> -->UI-only behaviourGated on Playwright smoke instead.
→ <!-- deferred: <reason> -->Explicit waiverSurfaces in PR body as a risk.

The script runs locally too: npx tsx scripts/check-ac-traceability.ts <spec-path> .... Exit code is 0 (clean) or 1 (failures).

Why the gate exists. The post-impl-reviewer subagent does AC mapping manually; this gate makes the mapping mechanical so even a PR opened without invoking the reviewer cannot silently miss an AC. INF-110.

Codex evidence — check-codex-evidence.ts (INF-348, head-bound since INF-371)

The gate cannot verify that Codex ran — no repo-local check could. It makes the claim explicit and attributable, so a false marker is a deliberate false statement rather than a silent omission.

Since INF-371 the marker also names the commit the review covered:

Codex review: 2026-07-29 — 2 rounds; 5 findings, 4 fixed; converged — 4f2c9ab
  • A marker citing a SHA that is not the PR head always fails, with no phase-in. Naming the head is sufficient — the summary is free prose, so it may also carry a previous head or a fixing commit.
  • A re-run is needed when the head moves, and only then. Refuting a finding or editing the PR body changes no code and leaves the marker valid; any code change invalidates it, which is why a round's fixes should be batched into one push.
  • Phase-in. A marker citing no SHA is grandfathered only where both the PR and the marker predate 2026-08-24. The test reads the PR's own dates, never the wall clock, so re-running the gate later always returns what it returned before. A grandfathered marker is not head-bound: later pushes neither invalidate it nor are covered by it, so a green check on such a PR does not mean the latest commits were reviewed.
  • Order matters when recording it. Cheapest first: if the reviewed head is already pushed and the round is clean, just edit the PR body; if the commit is still local, write the marker then push; if the round found something, push first so the findings can be anchored. Editing the body during the push's own run cancels it, and a cancelled matrix reads as every job failing at ~16s.

Full workflow: codex-review.

Docs-sync — check-docs-sync.ts (INF-111)

Maps behaviour-changing source-file patterns to required docs surfaces:

Source patternRequired docs surface
apps/catalog/src/app/.../route.tsapps/docs/docs/modules/catalog/
apps/catalog/src/server/services/apps/docs/docs/modules/catalog/
apps/directory/src/app/.../route.tsapps/docs/docs/modules/directory/
apps/directory/src/server/services/apps/docs/docs/modules/directory/
apps/wiki/src/app/.../route.tsapps/docs/docs/modules/wiki/
apps/project-tracker/src/app/api/.../route.tsapps/docs/docs/modules/project-tracker/ OR openapi.json regen
packages/platform/*/src/.../index.tsapps/docs/docs/packages/ or apps/docs/docs/architecture/
packages/contracts/src/events/apps/docs/docs/reference/events.md
prisma/schema.prisma or */migrations/apps/docs/docs/modules/ or apps/docs/docs/architecture/

If a PR touches a mapped source pattern without also touching a matching docs surface, the gate flags it. Warn-mode in v1 — exits 0 with warnings so we can tune the mapping before forcing hard-fails. Flip to hard-fail by setting DOCS_SYNC_ENFORCE=1 (per-PR via env, or globally once the false-positive rate is acceptable).

Why warn-mode. The mapping is intentionally narrow on the source side and broad on the docs side. Tuning needs real PR data — better to ship the gate as a warning, gather feedback, and harden than to ship hard-fails and have everyone start using [skip docs] reflexively.

No .kiro/ directory — check-pr-requirements.ts sub-check (d) (INF-312)

The Kiro IDE wrote spec bundles into .kiro/specs/; it was retired (INF-119) and its history archived under .ai/specs/archive/kiro/ (INF-312). Sub-check (d) of the PR-requirements gate fails any PR that (re)introduces a .kiro/ directory at any depth — the failure mode that actually happened, where a second .kiro/ root grew unnoticed under apps/project-tracker/. Write new specs as .ai/specs/SPEC-<key>-<slug>.md.

Two properties worth noting:

  • Universal. Unlike checks (a)–(c), which are source-PR rules exempt on release/hotfix and non-develop bases, sub-check (d) runs on every PR — including release/hotfix PRs to main — because reintroducing .kiro/ is wrong everywhere.
  • git-status based, not a filesystem probe. It reads git's own post-state accounting (git diff --name-only --diff-filter=d -M -z: additions, modifications, and rename targets; deletions and rename-aways excluded), then does a pure string match for a .kiro/ path segment. That is deliberately not an fs existence check — an fs probe leaks against exactly the paths this must catch (a dangling symlink, which existsSync follows to a missing target; a non-UTF-8 filename byte, which lossily decodes to U+FFFD). A string match over git's post-state has neither blind spot, and lets INF-312's own diff (which only deletes/renames .kiro/ away) pass.

Bypass: [skip kiro-guard] (a deliberate revert that re-creates .kiro/ is correctly flagged and must carry this marker).

The pre-PR self-review hook (INF-114) — not a CI gate, but enforced

.claude/hooks/pre-pr-self-review.sh runs on the agent's local machine, not in CI. It intercepts gh pr create Bash calls and refuses to let the call through unless:

  • The PR body contains a <details><summary>Self-review</summary>...</details> block, AND
  • The **Verdict:** line inside is not blocked.

Bypass: [skip self-review] (with the standard title-substring / body-own-line semantics).

The hook is documented in detail on the Subagents page. The pairing matters: developer subagents invoke post-impl-reviewer, embed its verdict bar, and the hook then validates that they actually did before letting the PR open.

Branch protection: the enforcement layer (INF-272)

Every gate in the table above only runs. Whether it can actually block a merge is a separate GitHub setting — "required status checks" on the develop and main branch-protection rules — and the two can silently drift apart. INF-272 found exactly that: Quality Gates and E2E Smoke were both advisory at the merge button (not in the required-checks list), and none of the real-Postgres integration suites (wiki / catalog / pt / directory / events / platform-db, rows folded into Build · Test) were required at all, despite this page and AGENTS.md describing them as gating.

.github/branch-protection/ is the checked-in contract — develop.json / main.json, each a valid GitHub "Update branch protection" request body — plus a README.md covering the open decisions (required approving review count, CODEOWNERS enforcement, strict/up-to-date-branch requirement). A weekly scheduled job (.github/workflows/branch-protection-drift.yml) compares the live GitHub settings against that contract and fails loudly on divergence — a drift detector, not a per-PR gate, mirroring the existing schema-parity.yml convention. Applying the contract to live settings is a deliberate, human-run step (scripts/apply-branch-protection.sh) — no agent applies a branch-protection change directly.

Running the full pipeline locally — npm run prepush (INF-354)

One command runs the local tier of the checklist:

npm run prepush

It parses AGENTS.md § Pre-Push Checklist and executes the numbered list it finds — there is no second copy of the inventory to drift (the runner and the check:agent-docs reconciliation read the same lines through one parser, scripts/lib/prepush-gates.ts). It runs all gates even after a failure and ends with a summary naming each gate and its result; pass --fail-fast to stop at the first failure, --list to print the gates without running them.

Two tiers (INF-421)

The checklist carries a local tier — the numbered list, which npm run prepush executes — and a ### CI-only gates tier, which it deliberately skips. The CI-only entries are enforced on the PR instead, and check:agent-docs still reconciles them against the workflows, so a gate cannot quietly stop running in both places.

Three gates sit in the CI-only tier: npx turbo run test:ci, npx turbo run build, and npm run test:scripts. The rule for earning that tier is cost-weighted, not hit-rate-weighted — a gate moves there when running it locally is expensive or unreliable and CI enforces it, so no coverage is lost and only the moment of discovery moves. A cheap fails-closed guard that has never fired is a guard working, not idling, and stays local. Rationale and the measured evidence: ADR-027.

The local tier's cost depends on what your edit invalidates, because lint and typecheck hash $TURBO_DEFAULT$. Measured on a quiet machine for the then-18-gate list (PT-925 later added gate 19, check:migration-collisions, at ~0.7s — the figures below predate it and were not re-measured):

your working treetotal gate timewall
unchanged (Turborepo cache hit)46.0s49s
one app edited (20 of 100 lint+typecheck tasks)180.0s3m00s
a platform package edited (61 of 100)271.9s4m32s
fresh worktree, cold cache≈4m50s

Before INF-421 the list was 19 gates with a 17-minute observed median. Gate 2 (npx turbo run lint typecheck) is the whole of that variation — the other seventeen gates total ~85s in every case. One further cost is not in these numbers and will dominate if present: the INF-393 machine-wide lock, which serializes gate runs so a concurrent session's run is queued ahead of yours.

The stamp and the two hooks

On green, the runner writes a success stamp keyed to your tree state — HEAD, the resolved origin/develop, working-tree status and diff, untracked file contents, package-lock.json, and npm's installed-tree marker. Change anything (edit, commit, fetch that moves develop, re-install) and the stamp goes stale; re-run npm run prepush. A failed run clears any existing stamp, and a run clears it before the first gate too, so the stamp always means "a completed green run at this state".

Two hooks compare that stamp (they never run gates — the comparison is ~250 ms):

HookCoversInstalled by
.claude/hooks/pre-push-gate.shagent sessions (Bash-tool pushes)tracked .claude/settings.json — nothing to install
.git/hooks/pre-pushhumans, editors, every other toolnpm run hooks:install, or automatically via prepare on any plain npm install

If you install with npm ci --ignore-scripts, prepare never runs — run npm run hooks:install once per clone (per worktree if your core.hooksPath is a relative path; the installer detects which case applies and says so).

Bypassing the hooks — not the checklist

Constitution §10 still requires every gate to pass before a push, and a red gate is blocked however spurious it looks. A bypass skips the stamp comparison — for a verdict that exists but cannot be stamped (e.g. a background git fetch invalidated it, or a --dry-run that publishes nothing) — and using one is an attributable act to name in the PR:

To skipUse
the git hookgit push --no-verify
the Claude Bash hook only[skip prepush] anywhere in the command
bothCONSTELLATION_SKIP_PREPUSH_GATE=1, or --no-verify (honoured by both)

If a class of push genuinely should not need the checklist, that is a change to §10 and needs an ADR — not a bypass habit.

Gates the aggregate deliberately does not run

The real-Postgres integration suites and the Playwright smoke tests need an environment a bare checkout lacks; they stay CI-enforced. Run them yourself when your change touches migrations, RLS, routes, or a GUI path — see AGENTS.md § Suites CI enforces. The PR-context gates can still be exercised individually:

npx tsx scripts/check-ac-traceability.ts # CI mode — needs GITHUB_BASE_REF=develop
npx tsx scripts/check-ac-traceability.ts .ai/specs/SPEC-foo.md # CLI mode
npx tsx scripts/check-docs-sync.ts # warn-mode
DOCS_SYNC_ENFORCE=1 npx tsx scripts/check-docs-sync.ts # hard-fail mode

See also

  • Subagents — the reviewing subagents that emit the verdict bar these gates consume.
  • Workflow — the end-to-end flow that runs these gates.
  • Architecture rules — the underlying hard rules each gate enforces.
  • Constitution — the invariants the rules derive from.