CI derivation
One implementation of what CI runs, its selection rules and diff-base ladder, and the five gates that hold the local recipes and CI to a single spelling.
"What does CI run for this change" has exactly one implementation: xtask/src/ci/. Everything that
needs the answer calls it — both workflows' plan step (affected), the local recipes' crate roster
(crates), and a developer at a prompt. xtask/src/lib.rs's header states the rule those three
consumers exist to serve: there is no second copy to keep in step, so a new exclusion, feature lane
or trigger crate is one edit.
The output contract
xtask/src/ci/mod.rs's Plan::render emits seven key=value lines in a fixed order — any,
crates, suites, hist, core, app, lightgbm — which the workflow turns into job outputs.
That shape is a contract with the workflow files, not an internal detail, and suites is a JSON
array whose order becomes matrix-leg order.
Selection
The rule is the same for a pull request and for a push to main (a push just diffs against the
pre-push SHA instead of the merge base): the crates a changed file owns, plus every crate that
transitively depends on them through a normal dependency, intersected with the CI roster — except
that a .rs file under the owning crate's own tests/, benches/ or examples/ seeds no
reverse-dep walk, since nothing outside that crate can link a test-target source. A crate whose
changed files are all of that shape is therefore selected alone; a src/ change, or a non-.rs
file under tests/ (a fixture a src/ file may include_str!), restores the full walk for that
crate. Dev-dependents are added exactly one hop.
That one-hop rule is the whole design of xtask/src/ci/graph.rs. A dev-dependency is a test edge,
not a layering edge, and this workspace dev-depends upward on purpose; feeding those edges back into
one graph makes it cyclic, and a transitive walk then escapes the layering entirely. Measured: the
chain oanda -> bridge-core =dev=> binance -> core =dev=> script -> indicators is how one
indicator-registry edit reached an unrelated venue crate, the DataFusion lane and every Polymarket
suite — 31 crates where 9 were affected.
Narrowing must never silently under-test, so three things escalate to the full roster: a
workspace-global path (GLOBAL_PREFIXES, except a purely additive Cargo.lock-only change), an
unresolvable diff base, and CI_FULL=1.
The diff base
xtask/src/ci/git.rs resolves whose changes a run is testing, and every rung of its ladder was
forced by a measurement. actions/checkout on a pull_request event checks out the merge ref,
not the head; that commit's first parent is the base-branch tip and its second is the PR head. The
payload's base.sha field lags that parent, and a three-dot diff cannot repair the gap because
base.sha is already an ancestor through the first parent. Measured on one pull request that
changed 2 files: base.sha was two commits behind, the diff arrived with 40 paths including a
global one, and the plan escalated to the full matrix on the strength of files belonging to two
other pull requests. HEAD^1..HEAD on the same merge ref returns exactly its own 2 paths.
Every ambiguity resolves toward the larger set
The failure mode of getting the base wrong is under-testing. An unclassifiable parent pair, a base
branch that is not an ancestor, an orphaned before SHA, unrelated histories, a failed diff — each
falls through to the next, more conservative rung, and the last rung is None, which the caller
reads as the full matrix.
The five gates
ci_plan_gate.rs holds the derivation itself: the diff-base ladder over real git repositories
with actual merge commits — the only test in the tree that builds them — plus the pure selection
rules over synthetic graphs. It cannot be skipped for a change that could break the derivation,
because GLOBAL_PREFIXES names xtask/, so any planner change escalates to the full roster. That
escalation is not a convenience: the planner computes the plan for its own pull request, so without
it a bug that selected too little would select too little for itself, go green, and conceal itself.
local_gate_mirrors_ci.rs holds the local recipes to what CI derives. The measured cost of not
having it: the local crate roster one crate behind the derivation, the release workflow's third copy
six behind, and one feature lane naming four features where the suite named six. Its first direction
no longer compares a copy to a truth, because there is no copy — ci_crates := calls the
derivation, and what is asserted is the property that made the comparison unnecessary: the assignment
invokes it and names no crate. The second compares the clippy and features recipes against the
suite script on subcommand, packages, features, target and flags. UNMIRRORED_SUITE_STEPS enumerates
the suite's structural steps that cannot be mirrored at all — the recipes run through PowerShell on
the Windows dev box, where grep does not exist — so that hole is measured rather than invisible.
Its third, every_suite_key_is_both_implemented_and_plannable, holds the suite keys in the script
and in FEATURE_SUITES to one set — the direction a comparison of commands is blind to.
feature_suite_roster_gate.rs is where that key pairing is argued, and the two disagreements
are not symmetric. An arm with no key can never be planned: the commands exist, the mirror gate
dutifully requires the recipes to match them, and the lane reads in review as covered code while CI
compiles not one line of it — silent, permanently. A key with no arm is loud. The check itself was
written here too and deleted rather than shipped twice, so what this file keeps is the half nothing
else can see — the workflow's own prose, where a comment may name one suite key or all of them,
never a partial list, because that comment had already rotted to eleven of fourteen.
feature_lane_coverage.rs asks the missing direction — that every declared Cargo feature is
compiled by some lane. A feature no lane turns on is never type-checked, never linted and never
run, and looks exactly like covered code in a diff. Coverage is computed to a fixpoint over three
activation rules: named by a lane, reached through a crate's own feature closure, or turned on by a
dependency declaration that resolver-2 unifies across everything built together. default gets no
special case and an empty feature array gets no exemption — emptiness describes the array, not the
code behind the #[cfg].
lockfile_gate_mirror.rs covers the one invocation in the merge gate that passes --locked.
The incident: a commit added a dev-dependency to a manifest and touched no Cargo.lock; every local
check was green because a bare cargo check, test or clippy silently updates the lockfile and
carries on. The whole missing delta was one string in one array. The gate pins that CI still runs the
shared script, that it runs in the one job with no condition on it, that the pre-push recipe runs it
before pushing and before any lane cargo check rewrites a stale lock, and that the local recipe
delegates rather than reimplementing — because a hand-rolled copy would go green and red at the same
moments while losing the diagnostic, which is the entire reason a local runner is worth having.
Building
The workspace roster, the feature lanes a default build never compiles, the one committed cargo config, and why the xtask convention is spelled differently here.
Structure gates
Three gates over the shape of the workspace — dependency direction, compile-time paths, and re-spelled primitives — and how to read each one's failure.