vike
Developer

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.

On this page