RFD 0062 — CI tiers + affected-scoping: main green by construction
- State: accepted
- Depends on:
cargo xtask affected(the affected-set foundation; the reverse-dependency closure with a conservativebroadfallback), the on-demand-tag release model (RELEASING.md), RFD 0061 (oneVERSIONfile — no version-lockstep gate to run), RFD 0060 (the Lean layering guard, a pure-text check that gates every tier while the heavyleanjob does not) - Tracks: the CI-redesign arc (this RFD is the workflow rewrite that the affected-detection tool was built for)
- Prior art: affected-set / target-determination in monorepo CI (Bazel’s
--output=targetschange-impact, Nxaffected, Turborepo’s--filter+ dependency graph) — monorepo good, monobuild bad; the merge-queue “test the would-be-merge commit” model (GitHub merge queue, Bors, Zuul’s speculative gating); the single-aggregate required check pattern that lets matrix + conditional jobs gate a queue at all.
Question
check.yml is the CI everyone depends on and the sole gate on the merge queue (ci-gate is the only required status check; ALLGREEN grouping, squash, batches of ≤5). It grew a 3-tier structure expressed as scattered if: github.event_name == … conditions on each job, and it ran the whole workspace for every code change. Two problems compounded:
- The tiers were implicit. Whether a job gated the queue, ran only post-merge, or was informational on a PR was encoded in a raw
github.event_namecomparison buried in eachif:. Reading “which jobs run for a merge_group?” meant tracing every job. A miswired tier was invisible. - Every gate was a monobuild. A one-line change to a leaf connector rebuilt and retested the entire workspace in the queue. That made the gate slow, which is the pressure that pushes checks out of the queue (where they can no longer red
main) purely for speed — the wrong reason to weaken a gate.
So: how do we make the merge gate both strong (it runs everything that can red main and is affordable) and fast (it does not monobuild), with the tier of every check self-documenting?
Decision
Principle: main is green by construction
The merge gate — the queue — runs everything that can red main and is affordable. The post-merge tier holds only a small, documented, deliberate accepted-risk set: checks that are slow, rarely combination-sensitive, and whose recovery is a fast revert. Nothing else may sit post-merge. Affected-scoping is what makes the queue both strong and fast: it is the lever that lets a gate stay strong without monobuilding, so “too slow” stops being a reason to drop a check out of the gate.
Three explicit tiers
Tier membership is a named output of the setup job (is_pr / is_queue / is_postmerge, derived once from github.event_name), and every job’s if: reads those named outputs — never raw github.event_name. A job’s tier is then self-documenting at the if: line, and a miswiring is a one-line diff, not an archaeology dig.
| Tier | Event | What runs | ci-gate |
|---|---|---|---|
| 1 — PR | pull_request | affected build + clippy + fmt + deny + drift; oxup (if affected); book (if affected); the always-on text guards (lean-layering, rfd-file-count, flake, fmt) | informational |
| 2 — queue | merge_group | Tier 1 + affected debug compiler-test + compiler-test-release-gate (release-tests affected ∩ merge-critical) + release-binary-smoke (non-nix, libduckdb-free release link) + macos-build-smoke (compile-only) | enforces |
| 3 — push / post-merge | push to main | Tier 2 + full lean + full macOS compiler-test + full-workspace compiler-test-release | enforces (loud; does not block) |
Tier 1 gives a PR fast, cached, scoped feedback (build + lint), not the slow test suite — the slow suite runs once, in the queue. ci-gate is informational on a PR so authors can enqueue without waiting on it; the enforcing run is the queue (and push).
Affected-scoping
The setup job runs cargo xtask affected for the event’s diff range — a PR uses three-dot (merge-base...head, the PR’s own changes), a queued group uses two-dot (base..head, exactly the group’s commits); the asymmetry is deliberate and preserved — and exposes broad, package_filter (a ready -p X -p Y …), and the category flags (lean / protocol / oxup / book / scripts). The scoped Rust jobs run package_filter when broad != true, else the full --workspace. oxup / book / lean and the script smokes gate on their category flag (or broad).
The tool is conservative by construction: under-including is a correctness bug (a regression slips the gate and reds main); over-including only wastes CI minutes. So every uncertain path resolves to more work — any infra / shared / unmapped path forces broad=true (the full workspace), and an empty diff is treated as broad. A derived rust output (broad || non-empty closure) gates the scoped compiler jobs, so a Lean-only or docs-only change — broad=false with an empty closure — correctly skips them rather than running cargo build with no -p (which would silently build only the default member).
Merge-critical crates
A curated set, defined once (the workflow MERGE_CRITICAL_CRATES env): oxc-instantiate oxc-serve oxc-runtime oxc-connection oxc-storage-pg — codegen, the concurrency/engine core, serving, and pg wire-divergence, the crates most prone to release-mode or cross-environment divergence. The queue’s compiler-test-release-gate runs nextest --release over (affected ∩ merge-critical); an empty intersection makes the job a no-op (it still reports success, so ci-gate stays green). This catches a release-only codegen miscompile in a divergence-prone crate in the queue (the class that bit oxc_instantiate::tier_classify::classify_body, commit 1892c0b, which debug-only CI hid for weeks) without release-testing the whole workspace in the gate. The set is curated, not derived — widen it when a new crate proves release-sensitive.
The documented accepted-risk set (Tier 3)
These run post-merge only and can red main. The bet, made explicitly: gating them in the queue is too slow, they are rarely combination-sensitive, and main recovery is a fast revert. ci-gate enforces on push, so a Tier-3 failure is loud (red main) — it just does not block the merge that introduced it.
- Full
lean—lake build+ the vector/manifest freshness gates + the batched-Lean conformance differential. Slow, OS-independent, rarely combination-sensitive. (Its pure-text companion, the RFD-0060lean-layeringguard, does gate every tier.) - Full macOS
compiler-test— the macOS test leg. The queue keeps a compile-onlymacos-build-smokeso a Darwin build break still gates; only the macOS test execution is post-merge. - Full-workspace
compiler-test-release— the complete release-mode sweep over every crate, both OSes. The queue gates the merge-critical subset; this catches a release-only bug in a non-merge-critical crate.
The system-dependency guard, deliberately non-nix
release-binary-smoke builds cargo build --release -p oxc-driver on a plain, non-nix runner with no system libduckdb (DuckDB is opt-in; the release binaries link none). A regression that makes oxc-driver need a system lib at link time passes every nix job — the dev shell supplies the lib — yet breaks the real release build (this is exactly what bit the toolchain before DuckDB was gated off by default). The nix dev shell is load-bearing but its flakiness is a known risk, so this guard does not deepen the nix reliance: it reproduces the release link outside the dev shell, which is the only place the bug is visible.
Factoring: the rust-test-env composite action
The heavy cargo nextest jobs share a long setup — free-disk, cargo caches, nix install, sccache, a Linux-only Postgres container (no job-level services: block, which would break the macOS matrix leg), the .lake cache restore + mathlib fetch + the serial eval-batch pre-build (so the conformance tests don’t race two lake builds under -j 4). That setup is factored into .github/actions/rust-test-env, so the test jobs stay thin.
Keep
ci-gateis the sole required check + its self-guard: a step that assertsci-gate.needscovers every job in the workflow, so a job added later but forgotten inneedsfailsci-gateloudly instead of silently becoming non-gating. The self-guard’s coverage is updated for the new jobs.- The composite caches (
cargo-cache,free-disk,sccache-setup) and the affected-tool’s conservatism.
Consequences
- A PR’s feedback is scoped and fast; the slow suite runs once, in the queue.
- The queue gate is strong (debug tests + the merge-critical release subset + the non-nix link + the macOS compile) yet scoped, so a leaf change does not monobuild.
maincan still go red — but only from the explicitly-listed Tier-3 set, and loudly. Recovery is a revert, by design.- Reading any job’s tier is a one-line
if:. A miswired tier or a forgottenci-gatewiring is caught by the self-guard, not by a redmain.