CI and Architectural Gate Mechanics
Many spec-kitty gates pass a naive local run and only go red ~40 minutes into CI, because they live in shards a fast local run never selects. This page maps the durable, repeatable ones: what trips each gate, the symptom it produces, and the command that reproduces it locally so you can catch it before pushing.
It is a mechanics reference, not a status page — for the current red list and
fast-drifting toggles see Known Current Friction
Points; for what a red main means see Red Main and
Release Readiness.
How CI is wired (so a local run can mirror it)
CI runs as a lean modular pipeline: a path router (ci-router.yml) decides which
module and data shards are affected, per-module test jobs run
(module-tests.yml / ci-modules.yml), and results aggregate with a coverage
and diff-cover gate (ci-aggregate.yml); a nightly full run (ci-nightly.yml)
and a fork-safe Sonar scan (sonar.yml) sit alongside. The trap is structural:
a passing pytest <file> locally does not mean a gate will be green, because
the gate that fails may run in a different shard, under a different marker
selection, or only over paths your diff happened to touch.
Two habits defuse most of this class:
- Reproduce a gate by running its exact selection, not just the test file —
e.g. a marker-scoped shard runs
-m "fast and not windows_ci", so a test missing thefastmarker is silently deselected there even though it passes when you name it directly. - Before declaring a branch green, run the architectural gate files your change
implicates over the rebased tip. Do not sweep the whole
tests/architectural/directory in mission work (NO_FULL_HEAVY_SUITES_IN_MISSION); CI runs the whole battery for you: the always-onarchitectural-fastjob plus the twoarchitectural-heavylegs on code and CI-config PRs.
Since mission ci-runtime-stabilisation (#5510),
these things about that pipeline are worth knowing before you read a red check:
The battery has three parts.
architectural-fastis always on (docs-only PRs included). Its roster of deterministic ratchet and census gates is held in.github/ci-module-registry.ymlunderspecial_tiers.architectural.fast_gate, with a per-file budget.architectural-heavyis one job key with a two-leg matrix (--battery-part 1/2and2/2). The three parts are file-disjoint and together cover the base selection exactly, whichtests/architectural/test_battery_partition_proof.pyproves statically.CI-config changes select the heavy battery. The router path group
ci_config(workflows, composite actions,scripts/ci/**,pytest.ini,pyproject.toml,Makefile, the registry and the shard timings file) gates the heavy battery and no other job.A change to a path a gate reads selects that gate (#6994). Many heavy gates also read paths outside their code scope. The clock bans, for example, scan
scripts/**andtests/**. Each such gate declares those paths once, underspecial_tiers.architectural.scan_scopesin the registry.scripts/ci/gate_selection.pyderives the router grouparch_scan_scopefrom them, andci-router.ymlmust list exactly that union. A diff that matches only that group runs, in each heavy leg, only the gates whose scope it touches (scripts/ci/battery_scope.pywrites the list and the plugin reads it fromSK_BATTERY_GATES_FILE). A leg that holds none of them skips its run. A code diff still runs every gate, and so does a change to a file the battery imports outsidetests/architectural/(tests/conftest.py,tests/_support/**, helper scripts), which is battery code, not a scan scope. When a new gate reads outside its code scope, add its row toscan_scopes.tests/architectural/test_battery_scan_scope_routing.pyproves the property for every declared gate and glob.A declaration must be complete, and CI checks it (#7000). A row is only as good as the read it describes: when a gate starts reading a new root, a stale row lets routing miss it again.
tests/architectural/test_gate_scan_scope_completeness.pyextracts, statically, every repository-rooted read a gate makes --_REPO_ROOT / "…", a chain built from it, aglob/rglob/iterdir/walkcall, a literalgit ls-files/ls-tree -- <path>pathspec, in the gate file and in everytests/**helper it imports (absolute or relative) -- and fails when neither the gate'sscan_scopesrow nor the battery code scope (src/**,tests/architectural/**and the router's own code groups) covers it. A read whose whole path is computed at runtime (_REPO_ROOT / rel) names no repository path and is recorded rather than guessed at; a partly computed read is checked at its literal parent. The only exemptions are reads that are not repository reads at all -- a synthetic probe path or an untracked scratch dir -- and each one carries a reason and goes stale-red. Adding a read therefore means adding a glob, not editing the check. The scan is static, so it has known blind spots (listed in the_gate_read_scopedocstring): a gate that takes the root from a pytest parameter (repo_root) or from atmp_path, a pathspec passed through a variable or through a helper whose call never names the root, and the reads of an importedtests/conftest.pyare not seen. It narrows the #6994 defect class; it does not prove absence.Corpus tests have one advisory owner.
packs.ymlruns the-m corpussuite as the advisory jobbuilt-in / -m corpus suite (advisory). The corpus tests that have no other blocking per-PR home run blocking in two router jobs:tests (corpus-blocking)(the modules that read the committed corpus) andtests (contract tools)(the contract tool unit tests). The router jobstests (cli),tests (status),tests (consolidation)andtests (corpus)no longer exist; thecli,statusandconsolidationmodule rows inci-modules.ymlare now their only homes.A nightly backstop runs the whole battery.
ci-nightly.ymljobarchitectural-backstopexecutes the full base selection with no partition plugin, so a green nightly means the battery ran (see the amendment to ADR 2026-09-26-1).A
ready_for_reviewrun can skip on prior evidence. When the same workflow already went green for the identical tested key, the selection step is suppressed and the required gates post success. A re-run always executes. See Skip-if-green on ready-for-review below and the amendment to ADR 2026-09-23-1.The merge queue runs the required checks again.
ci-router.yml,ci-modules.ymlandci-quality.ymlalso trigger onmerge_group, so the three required checks report on the queue's temporarygh-readonly-queue/main/...ref, which holdsmainplus the PRs ahead in the queue. The changed-files and prose-scan steps diff a queue run againstgithub.event.merge_group.base_sha, so it is path-scoped like the PR run. Skip-if-green never applies there, thepr:*label guards read no labels, CI Aggregate skips queue runs (diff coverage was judged on the PR), and no optional workflow joins the queue.tests/ci/test_merge_queue_wiring.pypins this.
The rest of this page groups gates by the change that trips them.
Testing and marker gates
These fire when a test file's markers, or the coverage a test contributes, do not line up with the shard that is supposed to run it.
A test with the wrong marker contributes zero CI coverage
- Trips it: a new test carries the wrong (or too few)
pytestmarkmarkers, so the shard meant to run it deselects it. Example: a test marked onlyunitwhen its shard selects-m "fast and ..."never runs in CI. - Symptom: the diff-coverage gate (
ci-aggregate.yml) goes red on a source file that has a passing test — because that test never executed in CI. Named directly (pytest <file> --cov) it shows full coverage locally, which hides the problem. - Fix / repro: give the new test the same speed + domain markers its
siblings use (typically
fastplus the module's domain marker). Reproduce the shard's real selection locally — runpytest <dir> -m "fast and not windows_ci"— not justpytest <file>.
A file with two markers can fall between two shards
- Trips it: a test file carries two markers whose CI jobs mutually exclude
it (e.g. both
distributionande2e, where the e2e shard runs-m "not distribution"and the other shard ignores the e2e directory). The file reaches no gate at all. - Symptom:
tests/architectural/test_marker_job_completeness.pygoes red — each routed marker gained a carrier that no job selects. This runs in an architectural shard, so it only surfaces on CI. - Fix / repro: make sure the file is positively selected by at least one job.
The minimal fix is a complementary pytest step in an existing job (e.g. add a
-m "distribution and e2e and not windows_ci"pass over the e2e directory); the completeness gate auto-discovers it. Runpytest tests/architectural/test_marker_job_completeness.pybefore pushing.
A new test file must declare a marker
- Trips it: a new
tests/**/test_*.pyfile lands without a module-levelpytestmark. - Symptom: an architectural marker-convention gate reds, insisting every test file declare some marker. It is distinct from the completeness gate above — and distinct from the marker-baseline gate below — so one can be green while another is red.
- Fix / repro: add
pytestmark = [pytest.mark.unit, pytest.mark.fast](or the markers the file's siblings use) at module scope. When a fold adds or moves a test file, run the marker-related tests intests/architectural/, not just one of them.
Moving a slow / stress / quarantine test can red the marker baseline
- Trips it: relocating or renaming a test that carries a budget marker
(
slow,stress,quarantine). Its node-id changes, so the pinned baseline set gains a "new" node and loses the old one, and the growth check reads that as new slow surface. - Symptom: the marker-baseline architectural gate reds even though it is the same test with the same marker.
- Fix / repro: do a documented one-for-one swap in the baseline — remove the old node-id, add the new one, and add a dated comment noting it is the same node relocated (no net-new slow surface). This is legitimate; a blanket baseline "update" that hides a genuinely new slow test is not. This gate is easy to miss because it is not in the fast shard.
New contracts/*.md YAML blocks need a round-trip skip marker
Trips it: the router job
tests (corpus-blocking)(job keytests-corpus-blockinginci-router.yml) runstests/contract/test_example_round_trip.py, which walks everykitty-specs/*/contracts/*.mdand collects each fenced```yamlblock as a contract-example case. A block in a non-legacy file that is neither executable nor marked as an illustration fails.Symptom:
test_contract_example_round_trip[...MISSING_FRONTMATTER]fails, reddeningtests (corpus-blocking). The Packs advisory corpus job deselects this file, so a red here is blocking, not advisory. A local run oftests/ci+tests/architectural(the obvious blast radius for a CI change) does not covertests/contract/, so illustrative YAML passes locally and only reds on CI.Fix / repro: tag each YAML block with one of, as its first line inside the fence:
# pydantic_model: <Model>— executed against that model, or# round-trip: skip: <reason>— non-executable illustration; the reason is mandatory (a bare marker still fails).
Run
pytest tests/contract/test_example_round_trip.pylocally before pushing.
The pre-review gate can run the full suite and hang
- Trips it:
spec-kitty agent action implement WP##(andreview, andmove-task --to for_review) trigger the auto-scoped pre-review regression gate (src/specify_cli/review/pre_review_gate.py, baseline capture inreview/baseline.py). When the auto-scope resolves broadly it runs effectively the whole suite with no path arguments. - Symptom: the CLI hangs with no output for a very long time, blocked waiting on the pytest child; parallel workers each doing this pin every core.
- Fix / repro: set
SPEC_KITTY_SKIP_PRE_REVIEW_GATE=1on the lifecycle command (this is the canonical opt-out; it no longer reads the retired sync-disable vocabulary). The claim still succeeds — the WP moves toin_progressand the worktree is created — even if baseline capture times out. To make the baseline itself cheap, declare a fastreview.test_commandin.kittify/config.yaml(a local, uncommitted no-op command is legitimate; revert it at mission end). If a killed gate leaves a stale run-lock or orphaned pytest children, remove them before retrying. Rely on targeted per-WP tests plus CI for breadth, never the full suite in-session.
The architectural gate battery
Adding new src/ symbols and new test files trips a battery of architectural
gates that each pass in isolation but only surface together in CI's
architectural jobs: the always-on architectural-fast job and the two legs of
architectural-heavy (a code-scoped job whose failure reds the router gate).
Pre-run the targeted gate files before pushing. A useful invocation base
for these is PYTHONPATH=src -o addopts="" so collection matches CI.
- Dead-symbol gate (
tests/architectural/test_no_dead_symbols.py): a new public symbol in a module's__all__that no othersrc/file imports fails. Fix by trimming__all__to genuinely public symbols — test-only helpers stay importable but out of__all__. - Dead-module gate (
tests/architectural/test_no_dead_modules.py): a new module with zero non-testsrc/importer fails — even apython -mentry point launched by an argv string, because that is not a static import. Fix by wiring it: have the launcher reference the module by name (from . import _server_main; MODULE = _server_main.__name__) rather than a string literal, so it is statically reachable and rename-safe. - Clock call-ban (
tests/architectural/test_clock_call_ban.py):time.time()/.now()/.utcnow()/.today()anywhere (including tests) outsidesrc/kernel/clock.pyfails. Import fromkernel.clockinstead. - Clock import-ban (
tests/architectural/test_clock_import_ban.py, distinct from the call-ban): a rawimport datetime/from datetime import ...anywhere outsidesrc/kernel/clock.pyfails. Import fromkernel.clock, or add an exemption line undertests/architectural/_exemptions/. - Charter facade table (
tests/architectural/test_charter_facades_reexport_offering.py): this self-discovers everysrc/charter/*.py, so a new charter facade that re-exports acharter.offeringsymbol in its__all__must be registered in the facade table. It runs in an architectural shard, invisible to fast-shard local runs. - Environment-fragile absolute counts: integration jobs run
uv sync --frozen --all-extras, which installs and imports more optional dependencies than a local checkout. An absolute module-count or import-count assertion (len(modules) <= N) passes locally and false-reds on CI. Assert a specific module's presence or absence, never an absolute count.
Fast roster, shard legs, and the partition proof
The battery base selection is tests/architectural with the marker expression
not performance and not stress and not timing, minus the four files that other
always-on lanes own (test_no_legacy_terminology.py, test_layer_rules.py,
test_pyproject_shape.py, test_archive_root_byte_identical.py). It runs as:
| Part | Job (display name) | Selection | When |
|---|---|---|---|
| Fast | architectural fast gates (ratchet/census, always-on) |
--battery-part fast: the registry roster |
every PR shape |
| Heavy leg 1 | architectural battery (heavy, code-scoped) 1/2 |
--battery-part 1/2 |
code, ci_config or architectural path changes; for a diff that matches only arch_scan_scope, the leg's gates whose declared scan scope the diff touches. A prose-only diff (#4842) runs the same scoped selection when it touches a declared scan scope, and skips the battery when it declares none (#6999) |
| Heavy leg 2 | architectural battery (heavy, code-scoped) 2/2 |
--battery-part 2/2 |
same as leg 1 |
| Backstop | Architectural battery backstop (nightly-only, #5510) |
the base, no partition flag | nightly |
- Prose-only diffs and the declared scopes (#6999, operator ruling 2026-11-01). A
PR whose whole Python diff is comments and/or docstrings is down-routed off the
module matrix and every code shard (
scripts/ci/prose_only.py, published asprose-scan'sprose_onlyoutput). The heavy battery is exempted from that subtraction for a declared scan-scope match:prose-scanalso publishesscan_scope_match(thearch_scan_scopeglobs, read throughgate_selection.matches_declared_scan_scope), the batteryif:runs whenprose_only != 'true'orscan_scope_match == 'true', and the selection step passes the verdict on (battery_scope select --prose-only) so each leg runs only the gates whosescan_scopesdeclaration matches. A prose-only diff that matches no declared scope still skips the battery. The companion output, rather than a second reference toneeds.changes.outputs.arch_scan_scope, keeps the parsed group set honest: deleting the group's own OR term still redstests/architectural/test_battery_scan_scope_routing.py. Declared scopes only: for a prose-only diff the battery relies on thescan_scopesrows being complete (checked bytests/architectural/test_gate_scan_scope_completeness.py, #7016), so a gate that reads prose more widely than its declared scope is skipped for a prose edit outside that scope. - The roster is registry-held. Each entry in
special_tiers.architectural.fast_gate.rostercarries a path, abudget_secondsand a reason. A file earns a roster slot only if it is a deterministic static gate, so a new slow or CLI-round-trip file belongs in the heavy legs. The budgets are checked statically against the committed timings bytests/ci/test_battery_roster_budgets.py, never against wall-clock time. - Adding or moving a battery test file needs no workflow edit: the plugin
scripts/ci/battery_partition_plugin.pyassigns each file to exactly one part from the per-file timings in.github/ci-shard-timings.json. A file with no timing gets the median weight, and a timing-set mismatch prints a::warning::rather than silently using uniform weights. - A partition-proof red means a file is in two parts or in none. The proof
(
tests/architectural/test_battery_partition_proof.py) evaluates the three literal--battery-partcommands, so fix the roster or the timings, not the proof. - Worker count is a literal
-n 4in every CI battery command; see CI worker policy.
Skip-if-green on ready-for-review
When a draft PR is marked ready for review, the head SHA has usually already been
tested. The selection jobs (router changes, CI Modules generate-matrix, Packs
changes) therefore run scripts/ci/green_match.py decide first. It suppresses
the path-filter step, and so every path-gated job, only when all of these hold:
- the event is a
pull_requestwith the actionready_for_review, on the first attempt of the run; - a completed, successful run of the same workflow exists for the same head SHA;
- that run carries a non-expired marker artifact
ci-tested-key-pr<N>-base-<sha>for the identical tested key(workflow file, PR, head SHA, first parent of the merge commit the run tested).
The required contexts router gate and CI Modules gate still post success. On a
skip run the always-on lanes and prose-scan still run, and tests (docs) still
runs on a prose-only PR.
- Symptom of a surprise skip: a
ready_for_reviewrun finished with every path-gated shard skipped, and the step summary names a matched run. - Escape hatch: re-run the workflow. Attempt 2 always executes.
- Never suppressed: push,
workflow_dispatch,workflow_call, schedule, any otherpull_requestaction, a failed, cancelled or in-progress prior run, a moved base, and any lookup error (the helper runs normally and warns). - CI Aggregate re-points to the matched CI Modules run after re-verifying it, and fails with "re-run CI Modules to execute" if it cannot. Re-run CI Modules, not Aggregate.
Moving or renaming a symbol that is dead-symbol allowlisted
The dead-symbol allowlist lives in tests/architectural/dead_symbol_allowlist.yaml
and is keyed by (module, name): module is the module whose __all__ declares
the name. No body hash is stored
(ADR 2026-10-01-1).
- Editing the body of an allowlisted symbol costs nothing: no allowlist edit.
- Renaming or moving one makes the gate report the old entry
GONE(for a move, with a "probably moved toX" hint) and the new location as an offender. Updatemodule:orname:of that entry in the YAML file. - Adding an entry needs a declared
category_*, a rationale (its own or inherited from the category -- see declaring a new category), anissuewhere the category setsrequires_issue: true, and a raisedtest_no_dead_symbolsleaf intests/architectural/_baselines.yaml(allowlist_entries, orwidened_grandfathered_470for the widened section). Both leaves are shrink-only caps. Try wiring the symbol, dropping it from__all__or deleting it first.
Never weaken the gate to get past it.
The ruff format exclude ratchet has a twin
[tool.ruff.format].exclude in pyproject.toml is a large, shrink-only
formatter-debt allowlist (that is why a whole-repo ruff format --check . passes
on main — excluded files are skipped). Two distinct architectural tests guard
it, and checking only one misses failures:
tests/architectural/test_ruff_format_enforcement.py— assertsruff format --check .exits 0 and every exclude entry names a live file.tests/architectural/test_ruff_format_exclude_ratchet.py— asserts every exclude entry still genuinely reformats, and that the entry count stays under a shrink-only ceiling.
Two changes trip the ratchet, and both are caught only at the integration/merge gate, not by a per-WP subset run:
- You run
ruff formaton an excluded file (making it clean) → its entry no longer reformats → red. - You delete an excluded file's source → its entry names a missing file → red.
Fix both by removing the now-dead entries (this only shrinks the list, so no ceiling bump is needed). Remove entries only for files your branch actually formats or deletes.
A scanner that swallows a parse failure reds the parse fail-closed gate
tests/architectural/test_scanner_parse_fail_closed.py scans every module under
tests/ for a construct that can swallow a failure to parse source: an
except catching SyntaxError (or a subclass) that does not always re-raise,
a bare, Exception, BaseException, AssertionError or aliased handler around
a parse call, contextlib.suppress(...) around one, or finally: return. A
scanner that skips a file it cannot parse drops it from its census, so the gate
it feeds passes over code it never read.
It trips on harmless-looking code too — for example
try: parse_file(p) except AssertionError: continue. Repro:
pytest tests/architectural/test_scanner_parse_fail_closed.py. Fix it by
reading and parsing through tests/architectural/_ast_scan.py
(read_and_parse / parse_file / parse_source / read_source, which fail
closed naming the file). Only when the input is deliberately allowed to be
unparseable, add a rationale row to its _ALLOWED_SWALLOWS ledger; the ledger
is checked for exact equality, so remove the row when the handler goes.
Router path filters are the tail of a guarded SSOT chain
The ci-router.yml path-filter globs are not a free-standing authority — they
are the verbatim tail of a three-link single-source-of-truth chain:
tests/release/ci_retirement_scrub.json (the SSOT) → .github/ci-module-registry.yml
→ ci-router.yml filters. Each link is pinned by an architectural transcription
guard (tests/architectural/test_module_shard_registry.py,
tests/architectural/test_ci_router_transcription_guards.py).
- Trips it: adding a glob to a router filter group alone.
- Symptom: the router-transcription guard reds; cascading the glob to satisfy it then reds the registry-verbatim guard unless the registry roots change too — and the registry roots drive the per-module test matrix, so this is a real CI-matrix change, not a config tweak.
- Fix: to change what a module owns for routing, edit the scrub SSOT and
cascade verbatim through all three links. To route test directories to modules
without touching the chain, derive the mapping from the registry inventory in
the gate-selection logic (
scripts/ci/gate_selection.py) rather than hand-authoring a second map.
An extra narrow lane for an out-of-matrix test
Not every test tree gets a module row. When a single out-of-matrix test file must run per
PR on a specific diff — the precedent is mission_state (#5864) and the runtime-home
path-literal guard (#6879) — add a non-src router group plus one job, not a registry row:
- declare the group's globs in
tests/release/ci_retirement_scrub.jsonnon_src_router_groupsand transcribe them verbatim into the filter block,changes.outputsand the job'sif:, plus a row intests/architectural/test_no_duplicate_suite_execution.AUTHORIZED_PER_CHANGE_SUITE_JOBSand therouter-gateneeds:list; - spell the globs
**/-prefixed (e.g.**/specify_cli/status/**). Asrc/-spelled glob makes the group src-backed, and every src-backed group must also be a module-registry row (test_gate_selection_authority.py), so the lane becomes a CI-matrix change; - keep it out of the FR-004
unmatchedunion andarchitectural-heavy'sif:— both are derived from the src-backed group set and the transcription guards red if they diverge; - do not name a directory that no src-backed group owns (e.g.
src/specify_cli/paths/**). Such a directory trips the fail-closed catch-all and already runs every job, this lane included; naming it turns that sensor off and narrows the run to your lane.
Trips it: adding the globs to the filter block only, or to a src-backed group without a
module row. Symptom: the transcription guard in
tests/architectural/test_ci_router_transcription_guards.py, or
test_select_modules_full_mode_selects_all_registry_modules, or the duplicate-suite ledger.
Stored test-universe collection
Several architectural gates need the full list of collected tests with their
markers (the "test universe"). One real pytest --collect-only of the tree takes
tens of seconds, and before this change the first test that needed the list
collected it inside its own setup while four xdist workers competed for the same
CPUs. The list is now collected once per job, in a step before pytest, and stored on
disk; the tests read the stored copy.
What it does and does not do. A first run of a checkout still collects once
in each job that consumes the list: the architectural battery leg whose files hold a
collecting test (today both legs hold one — test_fast_tier_marker_completeness.py
in 1/2 and test_same_tier_uniqueness.py in 2/2 — but a leg only collects when a
selected gate holds one, see below), and the ci module shard. The change moves that
collection out of test setup and out of worker contention. A re-run of the same
checkout restores the stored list from the CI cache instead of collecting. On one
developer machine a collection took 62.5 s and reading the stored copy 0.33 s. On CI
the pre-test step took 89 to 233 s on a first run and 0.3 to 0.6 s when restored from
the Actions cache; the three runs are in
kitty-specs/shared-collection-and-shard-recapture-01M42V58/evidence/ci-measurements.md.
Where it lives and what it is keyed on
- The code is
tests/architectural/_universe_store.py.collect_universe()intests/architectural/_gate_coverage.pycalls it; its signature and return value are unchanged. - The store is
.pytest_cache/universe-store/, which git ignores. It holds one record file,<key>.json, plus a lock file. Writing a record deletes every other record file. - The key is a digest of the committed tree (not the commit), the interpreter,
the platform, every installed distribution with its version, and the environment
variables named
SPEC_KITTY_*plusPYTEST_ADDOPTS. The variables the test session sets for itself are listed inENV_EXCLUDED_NAMESand left out, so a pre-test step and a test compute the same key. The key also overlays the operator env file (.kitty.env) through the product's own loader. A machine whose home-level.kitty.envsetsSPEC_KITTY_*variables may therefore see local reuse not happen, because pytest isolatesHOME; CI is unaffected. - A record is stored only when the checkout is still the one the key was computed
for after the collection: if a commit, a branch switch or an edit happened
meanwhile, the caller still gets the universe it collected, the report line says
not stored: the checkout changed during the collection, and the pre-test step fails on that line. - A record is read only when its schema, key, record count and origin (commit and tree) all match and it holds at least 1,000 records (a corruption guard, not a ratchet). Anything else is treated as absent and replaced after a fresh collection.
- For one key, one collection runs at a time on a machine. Other callers wait on the lock and then read the stored copy.
When the store is bypassed
The store is not read or written, and the caller collects for itself, when:
| Reason in the report | Condition |
|---|---|
dirty-checkout |
git status --porcelain is not empty. Untracked files count, because an untracked test file changes what is collected. |
root-override |
the caller passed a repo_root (a patched root). |
unsupported-platform |
the platform is not Linux or macOS. The same reason, with a detail, is reported when the store directory or its lock cannot be used. |
git-unavailable |
git cannot describe the checkout. |
env-file-unreadable |
an operator .kitty.env exists but cannot be read, so the environment variables of the key are unknown. |
The per-test budget on a collecting gate
A bypass is not free: the caller now pays the whole collection inside its own test setup. One
pytest --collect-only of the tree costs 89 to 233 s on a first CI run (the measurements above),
247.7 s on the machine that filed #7052, and 90 to 422 s on a shared development VM depending on
load; the post-test check step cannot see that cost, because it only reads the report lines.
The suite's per-test timeout is a 240 s runaway-hang backstop (pytest.ini, #3143), which is at
or below a legitimate collection. A gate whose fixture setup may collect therefore carries its
own @pytest.mark.timeout(...) budget, derived from the collector's own ceiling
(tests/architectural/_gate_coverage.COLLECT_TIMEOUT_S, 900 s) plus an allowance for the work
that runs beside the collection. pytest-timeout installs its timer over setup as well as the
call (timeout_func_only is unset), so the marker also bounds the module-scoped fixture setup
that triggers the collection.
The rule is the one pytest.ini states: a legitimate slow test gets a per-test marker, the
global default is not raised. The budget must never be below COLLECT_TIMEOUT_S — a smaller one
reports a real collection as a hang, which is exactly #7052: on the architectural battery leg the
whole-repo collection was killed at 240 s and 16 tests in
tests/architectural/test_same_tier_uniqueness.py errored at once while check still printed
"Reuse held" (an interrupted request writes no report line).
Two invariants keep the two numbers together, both in that test module: every test whose fixture
closure reaches universe must carry a budget of at least COLLECT_TIMEOUT_S, and
_gate_coverage.py may not pass a bare numeric timeout to a collection subprocess. A test that
never collects keeps the 240 s default.
Giving the tests room does not make a broken reuse green. On CI a bypass inside the pytest step
is still a red at the check step (Reuse broken: … collected / … bypassed (dirty-checkout)),
with the reason and the dirty paths in the job summary; the budget only stops that red from
arriving as a timeout storm that names no cause.
The report line
When SK_GATE_REUSE_REPORT names a file, each call of collect_universe() appends
one JSON line to it: outcome (reused, collected or bypassed), reason
(no-record, invalid-record, origin-mismatch or a bypass reason from the table),
key, caller (the pre-step is prestep, a test is its file path), seconds, and
when present detail and dirty_paths. A detail starting with not stored on a
collected line means the fresh collection could not be written back.
The pre-test step and the post-test check
python -m scripts.ci.collect_universe_prestep <command> runs from the repository
root checkout:
| Command | What it does | Exit status |
|---|---|---|
key [--with-commit] |
Prints the collection key. With --with-commit it prints <key>-<commit>, which is the CI cache key. |
0; 2 when the checkout is dirty or git cannot describe it |
collect |
Calls collect_universe() once and prints the report line. |
0 only when the line is reused, collected with the record stored, or an unsupported-platform bypass without a detail; 1 for anything else, including a record that could not be stored |
check |
Reads the report file and writes a table to the job summary. | 1 when the pre-test line is not acceptable, when a later request in the job collected or was bypassed (other than root-override) after the pre-test step stored or reused a record, or when a report line is malformed; 0 otherwise |
consumers --battery-part <part> [--gates-file <path>] |
Prints true when the battery partition holds a test file that calls collect_universe() (found by scanning tests/architectural/test_*.py), false otherwise. --gates-file (default $SK_BATTERY_GATES_FILE, the variable the battery plug-in reads) intersects the question with the gates the diff selected for this leg (#7001), so a scope-only leg whose selected gates hold no consumer skips the pre-step instead of collecting a universe it never reads; no file means the whole partition. |
0 with an answer; 1 and no answer for an unknown part, an unreadable gates file, or any other failure |
compare |
Collects once with the store bypassed and diffs the result against the stored record. | 1 on any difference or when no usable record exists |
collect and check fail when the pre-test step could not store a collection.
Only an unsupported platform is a legitimate fallback: the job then collects for
itself, check prints a fallback note and exits 0.
Where it runs
ci-router.yml, jobarchitectural-heavy(a leg runs these steps only when its partition holds a collecting test; the battery partition moves with the timings, so each leg first runsconsumers --battery-part <leg>in a step with idselect, and a failed answer fails the job). Steps in order: compute the key, restore the store,collect, save the store, run the battery (the pytest command line is unchanged),checkwithif: always(). The decision is intersected with the leg's own selected-gates file (#7001):consumersis asked with--gates-filebound to the samesteps.scope.outputs.gates_filethe battery step reads asSK_BATTERY_GATES_FILE. A scoped leg (#6994) runs a handful of gates, so it needs the universe only when one of those gates collects it; when none does, every universe step is skipped. A whole-leg run passes an empty path and keeps the whole-partition answer.module-tests.yml, jobtest. The same steps, but only in per-PR mode and only on the shard whose selected test list containstests/ci/test_corpus_blocking_home.py::test_every_corpus_test_has_a_blocking_per_pr_home, the test that takes thelive_universefixture. Thecimodule runs as one shard (shard_count: 1in.github/ci-module-registry.yml), so that shard holds the test.tests/ci/test_ci_workflow_prestep_shape.pypins that the test still exists. Full-mode warm-up may rewriteuv.lockand leave the checkout dirty, so it skips the step.- The cache key is
universe-<key>-<commit>. It includes the commit because a stored record is valid for one commit only and a CI cache key never changes once saved; a key without the commit could keep a record that a later commit rejects, and the save step is skipped after a restore hit. Only an exact key restores (there are norestore-keys), and the save runs before pytest, so a leg that goes red on a test can still be re-run warm. - The shard list lives in the runner's temp directory, not in the checkout. An untracked file there would make the checkout dirty for the rest of the job.
- None of the collect, check or compare steps sets
continue-on-error. The shape test pins that, because it would turn the check into a summary.
Nightly equivalence job
ci-nightly.yml job universe-equivalence runs collect and then compare in
one job. It shows that collection is deterministic and that a stored record
round-trips to the same list as a fresh collection. It does not show that a record
restored from the CI cache on another runner equals a fresh one. It is part of
nightly-summary.
Run it locally
# The key of a clean checkout, and the CI cache key (commit appended)
python -m scripts.ci.collect_universe_prestep key
python -m scripts.ci.collect_universe_prestep key --with-commit
# Collect once and store it, then run a gate against the stored copy
export SK_GATE_REUSE_REPORT=/tmp/universe-reuse.jsonl
python -m scripts.ci.collect_universe_prestep collect
PYTHONPATH=src python -m pytest tests/architectural/test_fast_tier_marker_completeness.py -q
python -m scripts.ci.collect_universe_prestep check
# Prove the stored copy equals a fresh collection
python -m scripts.ci.collect_universe_prestep compare
# Ask a leg about its own selected gates instead of its whole partition (#7001)
printf '%s\n' tests/architectural/test_dead_builtin_doc_paths.py > /tmp/gates.txt
python -m scripts.ci.collect_universe_prestep consumers --battery-part 1/2 --gates-file /tmp/gates.txt
# -> false (that gate never collects the universe), so every universe step is skipped
printf '%s\n' tests/architectural/test_fast_tier_marker_completeness.py > /tmp/gates.txt
python -m scripts.ci.collect_universe_prestep consumers --battery-part 1/2 --gates-file /tmp/gates.txt
# -> true (this one does)
What a scope-only leg skips
A pull request that touches only a path a few gates declare as their scan scope runs only those gates (#6994). Until #7001 its legs still asked their question of the whole battery partition, whose only two collecting tests live one per leg, so both legs answered "collect" for a diff that selected three or four unrelated gates. The intersection removes that:
| Diff | Selected gates (1/2 · 2/2) | A collecting one among them? | Universe steps |
|---|---|---|---|
docs-only (docs/**) |
3 · 3 | no | all five skipped |
docs/adr-only (docs/adr/**) |
3 · 4 | no | all five skipped |
tests-only (tests/**) |
19 · 23 | yes (both legs) | unchanged |
a src/** change |
— | full leg (mode=full) |
unchanged |
Measured on the development host (a shared machine with heavy concurrent load, so the
absolute numbers are pessimistic): a cold collection took 396.7 s and a warm reuse
0.55 s, while the decision itself — partition, AST scan and the one file read — costs
about 1.5 s. The published CI range for a first collection is 89–233 s, so the
ordering holds on CI even though this host is slower. A cold collection is the common
case for a new commit: the cache key is universe-<key>-<commit>.
Further cheap wins, noted but not taken here:
- A re-run of the same commit restores the store from the Actions cache instead of collecting, because the key does not include the partition. On a first run the two legs still collect in parallel: each job populates the cache only when it finishes, so the second leg cannot see the first one's record in time. Making the two legs share one collection would need a job-level split, not a cache change.
- A scoped leg still pays checkout and
uv sync --frozen --all-extras(about 1–2 minutes) for a handful of gates; narrowing that sync is a separate change. - The decision could reuse the partition it already computed for the plug-in instead of recomputing it, saving well under a second.
A dirty checkout (including an untracked file) is bypassed on purpose; commit or
stash first to see a reused line. To force a fresh collection, run
rm -rf .pytest_cache/universe-store; there is no bypass environment variable, and
pytest --cache-clear does not remove that directory.
Scheduled shard-timings recapture
.github/ci-shard-timings.json records, for each registry module, the exact node
ids its shard collects and how long each took (module_test_node_durations, the
identity-keyed authority scripts/ci/shard_select.py weights a shard by), their
ordered projection (module_test_durations), the count and the summed seconds. The
entry drifts whenever a test is added, removed, renamed or replaced. The workflow
ci-shard-recapture.yml (schedule 04:41 UTC daily, plus workflow_dispatch, and
only on the primary branch (main)) refreshes the drifted entries for every
registry module and proposes the change as a pull request. The code is
scripts/ci/recapture_shard_timings.py.
The job recapture-shard-timings runs the script in three steps:
| Phase | Holds the token | What it does |
|---|---|---|
detect |
yes | Asks gh whether a recapture pull request is open on the proposal branch. |
capture |
no | Finds drifted modules, captures them, and writes the result (drifted, captured, failed, deferred) as a step output and a job-summary table. |
publish |
yes | Pushes the refreshed file and opens or refreshes the pull request. |
The capture phase runs test code for a long time, so it refuses to run when
CHARTER_SHARD_RECAPTURE_TOKEN is in its environment and starts every subprocess
without any token variable.
Finding drift. A collection-only pass (pytest --collect-only, over the same
directories and marker expression the producer uses) lists each module's node
ids for every module whose committed provenance is valid. A module is drifted
when its provenance record is missing or not valid, when its collected id set
differs from the committed module_test_node_durations (a rename at an unchanged
count included — a count-only check sees nothing), when the entry is still the
retired positional shape with no node ids (a recapture is what migrates it), or
when its committed tables disagree with each other. A reordered but identical id
set is not drift: the weights are keyed by id.
Capturing. Each drifted module is captured by
python -m scripts.ci.capture_shard_timings --module <m> --write in its own
subprocess, longest-untouched first.
Time budget. The workflow passes --budget-seconds 4200, counted from the start
of the capture phase, so the count-only passes are inside it. No count pass and no
capture starts once the budget is spent; the modules left over are reported as
deferred, never as clean, and the next run recomputes drift (no state is kept).
A count pass is capped at 300 s per module, a capture at 2,100 s, and the job at
130 minutes (70 minutes to start work, up to 35 for the last capture and 25 for setup, battery refresh and publication).
The valid-capture rule. A capture is kept when its provenance record carries
the run id the script passed in, the pytest exit status was 0 or 1, at least one
test was measured, and the committed slice is structurally complete — exact, unique
node ids, finite non-negative durations, and every derived table (ordered
projection, count, summed seconds, unique_tests_measured) agreeing with the
keyed table. Anything else (a crash, a collection error, a timeout, an unreadable
file, an entry whose ids and durations do not line up) puts the committed file back
byte for byte and reports the module as failed; the other modules still run. A
capture whose pytest exit status was 1 must include a failed runtest report; it keeps its complete durations but is reported
as captured with failing tests, never as a clean capture. An exit of 1 without a failed test report is rejected as possibly incomplete. A count pass that fails
or times out also reports its module as failed. The script exits 1 when any
module failed.
Proposal. The branch is ci/recapture-shard-timings, and GitHub allows one open
pull request per head branch.
- With no open pull request,
publishforce-pushes the branch and opens one. - With an open pull request,
capturestarts from the proposal's timings for the modules it already refreshed, so each run continues the work, andpublishadds a follow-up commit with a plain push. It never forces. - A push the token is not allowed to make (HTTP 403) fails the step with a message naming the secret. That is a token problem code cannot fix; see #5624.
The red window. Per pull request, a drifted count is a non-blocking
ShardTimingsDriftWarning. The job strict-shard-timings-check in the same
workflow runs tests/architectural/test_module_length_agreement.py with
SPEC_KITTY_STRICT_SHARD_TIMINGS=1 and has no needs edge to the recapture job. So
from the first scheduled run that sees a drift until the proposal merges, the strict
check is red on each scheduled run. That window is accepted: the red is the alarm
and the proposal is the fix.
To run it by hand, capture without --write only reports drift:
python -m scripts.ci.recapture_shard_timings capture --module charter
Docs-freshness and registration gates
Adding or moving a docs/** page trips several documentation gates that draw
from separate committed catalogs; fixing one leaves the others red.
The prose of those pages is linted by a separate, always-on job: see
docs-lint.
A new docs page needs triple registration plus a description band
Trips it: a new
docs/**/*.mdpage that is not registered everywhere.Symptom: the docs build and docs-freshness checks red with a mix of
DOCS-INDEX-DRIFT,INVENTORY-INCOMPLETE/INVENTORY-LOCKFILE-DRIFT/LEAK-MISSING-INVENTORY, and a description-length failure. The "committed index" in a drift error is the machine-generated retrieval index, not the curatedindex.md, which is why updating onlyindex.mdis not enough.Fix / repro: register the page in its curated index and satisfy the frontmatter band; leave the generated surfaces alone:
- Curated section index — hand-add the page to its section's
index.md(satisfies the index-completeness rule). - Page inventory, retrieval index, ADR era table rows — do not
regenerate
docs/development/page-inventory.yaml,docs/development/docs-retrieval-index.yamlor the era table rows in the PR. They are regenerated onmainafter merge by theci/regen-docs-indexbot pull request (docs-index-regen.yml,scripts/ci/regen_docs_index.py), and thedocs-lintguard (scripts/ci/check_generated_docs_untouched.py) refuses a PR that edits them. Until the bot PR merges,INVENTORY-INCOMPLETE,LEAK-MISSING-INVENTORY,INVENTORY-LOCKFILE-DRIFTandDOCS-INDEX-DRIFTreport the new page as warnings, andspec-kitty docs querydoes not find it yet. The per-PR content check istests/docs/test_docs_index_regenerates_cleanly.py: the generators must run cleanly over the tree you merge. - Frontmatter
description— a hard 50–180 character band, enforced byscripts/docs/description_length_check.pyand the docs SEO tests.
Verify with
PYTHONPATH=. python scripts/docs/check_docs_freshness.py --ci(expecterrors=0; staleness and external-URL link-health warnings are fine).ADRs need only the ADR file: the bot adds the era index-table row (
freshen_adr_inventory --all). An ADR's frontmatter needstitle:,date:,updated:, and a 50–180 characterdescription.- Curated section index — hand-add the page to its section's
docs-lint
- Trips it: a typo or British spelling in docs prose, or a changelog
[Unreleased]entry that breaks the house style. Any PR can trip it, because the job has no path filter. - Job:
docs-lintin.github/workflows/ci-router.yml(display name "docs lint (spelling + changelog style, always-on)"). It is listed in therouter-gatejob'sneeds, so a reddocs-lintblocks the merge. It is blocking: neither command's exit code is masked. - Why always-on: its inputs span
docs/**,README.md,packs/built-in/**/*.md,pyproject.toml(the[tool.codespell]table and the pinnedcodespell) anduv.lock. No single router path group covers all of them, so a path filter would let some edits skip the check. - What it runs:
uv sync --frozen, thenpython -m scripts.docs.check_spelling(typo, US-spelling and Unreleased passes) andpython -m scripts.docs.check_changelog_style. It runs no pytest. The planted-violation and live-tree tests for the two scripts (tests/docs/test_check_spelling.py,tests/docs/test_changelog_style.py,tests/docs/test_docs_spelling_live.py) run in thetests-docsjob. - Exit codes:
0clean;1findings;2the check could not run or could not prove it looked at anything: a usage error,codespellmissing, no[tool.codespell]table inpyproject.toml, an unreadable changelog (both scripts), atypooruspass that scanned 0 files, or anunreleasedscratch file that a[tool.codespell]skipglob would drop. A changelog with no[Unreleased]section is0(nothing to check). The changelog guard also exits0on warnings alone. - Scope: Markdown only.
docs/archive/,docs/reports/,docs/plans/and the generated CLI reference (docs/api/cli-commands.md) are skipped through the[tool.codespell]skiplist, so a finding never comes from them. A relative--changelogresolves against the repository root in both scripts (against--repo-rootfor the spelling check), not the current directory. - Symptom: the job log lists one finding per line as
path:line: [rule] ... — fix, ending in a summary line. It is not one of the docs-freshness or registration errors above. The guard's rules includebullet-marker(a column-0*or+bullet),conflict-marker(a leftover<<<<<<<,|||||||or>>>>>>>line) and banned-token checks on prose between a###/####heading and its first bullet; the how-to lists every rule and the false-positive shapes (WP-D-1,SC-2086,DEFAULT-branch) to put in backticks. - Fix / repro:
make docs-lintfrom the repository root reproduces the job (it runs the same two commands). The how-to has the steps: run the checks, allow a word, exempt a quoted literal, and read a failure. Never run barecodespell, which scans the whole repository. - Owning files:
scripts/docs/check_spelling.py,scripts/docs/check_changelog_style.py, and the[tool.codespell]table (skip,ignore-words-list) plus the exactcodespell==2.4.3pin inpyproject.toml. The pin is exact on purpose: a newcodespellrelease changes its dictionary, so bump it deliberately, together with the docs it newly flags.
Touching any docs path can surface a pre-existing docs-test flake
- Trips it: touching any
docs/**path flips the docs-test path filter, which can run atests/docs/test that never ran on your branch before. - Symptom: a docs test you did not write reds — often a fragile single-cold-measurement performance assertion on a contended runner. Do not misattribute it to your diff.
- Fix / repro: classify it as pre-existing (reproduce on the merge base), then fix perf flakes at the root with warm-run discipline (discard the cold pass, assert the fastest of several warm runs); never retry-to-green.
Where the docs site deploys from
docs.spec-kitty.ai is deployed by .github/workflows/docs-pages.yml from
this repository's main. The workflow runs on pushes to main that touch
docs/** (and a few docs-tooling paths) or on manual dispatch. It first probes
whether GitHub Pages is configured and skips cleanly if not; the deploy job
itself runs only for spec-kitty/spec-kitty on refs/heads/main. A
workflow_dispatch on another branch builds but does not deploy.
The GitHub Pages docsite build needs PYTHONPATH
- Trips it: the docsite deploy (
docs-pages.yml) runs Python post-processing steps that importfrom kernel.clock import ....kernelis a src-layout package, so a step that runs rawpython3with noPYTHONPATHand no installed package raisesModuleNotFoundError: No module named 'kernel'. - Symptom: the DocFX render succeeds but the Python post-step fails, the
deploy job is skipped, and merged
docs/changes never go live. Footgun:docs-pages.ymlhas nopull_requesttrigger, so a PR that changes it is not exercised by PR CI. - Fix / repro: ensure the build job exports
PYTHONPATH: .:srcat job scope (job, not step — several post-process modules import each other and thescripts.docs.*package). Verify a change to this workflow viaworkflow_dispatchon the branch, where the build runs and the deploy stays skipped offmain.
Accept-to-consolidate gates
The accept → consolidate close-out has several gates that block silently until fed
exactly what they expect.
The issue-matrix verdict gate blocks WP approval
- Trips it:
move-task <WP> --to approvedwhen a gating#NNNreference in the mission's artifacts has no verdict row inissue-matrix.json. Not every reference gates: context-only and PR/commit references are recorded but do not block. - Symptom: the approval is blocked, and the error lists the missing rows.
- Fix: seed the matrix up front with
spec-kitty agent issue-verdict. The verdict values, which references gate, and the evidence-token rule fordeferred-with-followupare documented once, in the Issue-Matrix Verdict Reference. The per-WP reviewer should refuse to fabricate verdicts for unfixed issues; the orchestrator fills them honestly and flipsin-missionrows to a terminal verdict at accept.
The other close-out gates
- Lane branches reject any
kitty-specs/change ("kitty-specs/ changes are not allowed on lane branches"). Before moving a task tofor_revieworapproved, restore the planning artifacts from the planning branch and commit. The review-claim step can re-dirty them, so re-clean between claim and approve. - Record
acceptance-matrix.jsonverdicts withspec-kitty agent acceptance-verdict; do not hand-edit the file.spec-kitty agent acceptance-verdict --mission <m> --criterion FR-001 --result pass --evidence <ref>writes the criterion through the matrix seam, commits it, and recomputesoverall_verdict. Its negative-invariant mode (--negative-invariant <id> --description ... --verification-method grep_absence|route_check|custom_command --verification-command ...) registers an invariant through the same seam and runs it at once (--no-executeregisters only).acceptalso fails on a dirty tree, so commit or clean the dossier state first. spec-kitty consolidaterefuses a dirty coordination worktree — commit the status files and clear ignored.kittify/state in the coordination worktree, then--resume.- The graph-manifest check verifies the pack manifest, not just the graph
files. Regenerating the reference graph alone leaves the manifest stale; run
the full
spec-kitty charter pack regenerate-graph, which regenerates both. - The post-merge stale-assertion analyzer flags test string-literals tied to removed code even when the test still passes. Confirm the test is green, then refresh the docstring or literal.
Contracts gates
The mission-status and mission-write contracts, their tools and the Mission Status API service
live in spec-kitty-events (contracts/, service/; ADR
2026-10-29-1),
which builds, checks and releases them on its service-v* line. Core keeps only the five handoff
fixtures contracts/fixtures/fixture_0*.json and pins one release in
pins/read-service.source.yaml (#6915). The contract tests in core read the pinned
contract-bundles, contract-tools and conformance-fixtures extras, which make sync-services
(make dev-setup fetches the extras only) and the .github/actions/sync-services action fetch and
digest-verify; tests/pinned_service_assets.py fails closed when they are missing.
What runs where
tests (contract tools)(tests-contract-toolsinci-router.yml, a required check throughrouter-gate):pytest -m "corpus and not windows_ci" tests/contractminus three--ignoremodules, after the sync-services action. It runs when thecorpusfilter group matches (contracts/fixtures/**,pins/**) or thecontract_toolsgroup matches (tests/contract/**,pins/**,scripts/sync_services.py, the sync-services action,tests/pinned_service_assets.py, the lock and pytest configuration,tests/conftest.pyand thesrc/modules the mission-status reference reader imports, spelled**/<module path>so the group stays non-src). Unmarkedtests/contractmodules run in the module matrix.- The reality check and its payload helper (
tests/contract/test_mission_status_reality.pyandtests/contract/test_mission_status_payloads.py) read the committed corpus and run intests (corpus-blocking)(tests-corpus-blocking), together with the example round trip,tests/integration/test_mission_review_contract_gate.py,tests/specify_cli/migration/test_corpus_wp_snapshot_parity.pyand one performance class. The modules of both router jobs are deselected from the Packs advisory corpus run, so each runs once.tests/ci/test_contracts_routing.pyfails when a corpus-markedtests/contractmodule is run by neither router job, or by both, and when Packs deselects a module that does not exist. The reality check reads the repository's primary worktree, not the checkout that imports it (oracles.primary_worktree_root; #6966). The Mission resolver answers a coord-less or un-backfilled Mission's read directory with the primary worktree, so a check rooted at the checkout would enumerate onekitty-specs/tree and read Missions out of another whenever the checkout is a linked worktree — the drift read then refuses the whole report with500 drift_scan_unreadableand the Project read reports anoutside rootfallback the oracle never derives. From agit worktreethe check therefore judges the primary worktree's corpus, so a corpus change made in a linked worktree — committed or not — is not seen there; it is covered by the corpus-blocking CI job on the PR (whose checkout is the primary worktree) and after the change lands. From the primary checkout (CI, a plain clone) the root, the corpus and the pass counts are exactly as before. When git cannot name the primary worktree (no git, not a repository, an empty or failed listing) the checkout root is used and the linked-worktree mixing can return — the module must still collect there. - The nightly corpus compare (
mission-status-corpusinci-nightly.yml) starts the pinned native read-service binary and compares it with the Python reference over the whole corpus, with no exemption list (scripts/ci/mission_status_corpus_compare.py). - Pins.
tests/ci/test_service_pins_gate.pychecks every committed pin;release.ymlfetches and checks the service pins before it builds (scripts/sync_services.py --service all --check) and asserts the wheel carries the pins and no service binary. - Absence.
tests/architectural/test_events_cutover_absent.py(on the always-onarchitectural-fastroster) fails ifservices/, the removedcontracts/modules or the retiredcontracts.yml,contracts-release.ymlandmission-status-api.ymlworkflows come back, or if a CI, build or script file names them.
A reader or test-only change selects no corpus job
- Trips it: a pull request that edits a status reader the reality check imports
(anything under
src/specify_cli/status/**), the reality check's own test files or fixture, or a Mission'smeta.jsonorstatus.events.jsonl. The router'scorpusgroup matchescontracts/fixtures/**andpins/**but none of those, sotests (corpus-blocking), the job that runs the reality check, is not selected. An edit undertests/contract/**selectstests (contract tools)through thecontract_toolsgroup, which does not run the reality check. The Packs run on a push tomaindoes not help:packs.ymlpasses--deselectfor everytests/contractmodule that runs in a router job, so thebuilt-in-corpus-suitenever runs them. - Symptom: a green pull request. The drift is first seen by the nightly run
(
ci-nightly.yml, jobinterpreter-matrix-shard-4, which runstests/contract, scheduled daily at 03:17 UTC), up to a day later. This is an accepted risk, tracked in issue #5623: status-reader drift is caught by the nightly run, not on the pull request. It was seen on a real pull request, which needed a manual router run. - Fix / repro: run the check before pushing:
PWHEADLESS=1 .venv/bin/python -m pytest -q tests/contract/test_mission_status_reality.py, and dispatch the router in full mode on the branch:gh workflow run ci-router.yml -f mode=full --ref <branch>.
Mission Status API workflow
Retired with core's copy of the service (#6915): the Mission Status API's build, native legs,
lock-interop job and release run in spec-kitty-events (.github/workflows/service.yml,
service-release.yml). Core runs the nightly corpus compare against the pinned binary (above).
What only a merge can confirm
- The fleet-verdict job is triggered by
workflow_run, and GitHub runs the copy of such a workflow that is on the default branch. A change to the workflows it lists cannot be tested from a pull request; confirm it on the first run after the merge. - The Packs
built-in-corpus-suitedoes not run the reality check or the other contract tests on a push tomain: it deselects them, because the router jobtests (corpus-blocking)owns the reality check and the router jobtests (contract tools)owns the rest. The runs after the merge that execute them are the router onmain(each job runs only for a change that selects it) and the nightly shard 4. - A release tag is a maintainer act and is never pushed from a pull request.
See also
- Known Current Friction Points — the fast-drifting, time-stamped list of what is red today.
- Red Main and Release Readiness — what a red
mainmeans and why CI is the release authority. - Parallel testing — why the make targets are shaped the way they are.
- Landing contributor PRs — the maintainer landing runbook and red-classification step.