Use Nix derivation identities to skip structurally unaffected CI checks
- Dominant language
- Rust
- Stars
- 2.6k
- Forks
- 179
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 29
Description
*Authored by an AI agent acting on Josh Liebow-Feeser's behalf.*
Related: #1380.
## Proposal
Our PR and merge-queue workflows currently run many checks that are structurally unrelated to many changes. Hand-written path filters can reduce this work, but they provide only a convention: the filter can omit a real dependency while the check still has access to the entire checkout. Nix is a good general fit because a check can be represented as a sandboxed derivation whose source contains only its declared repository inputs. If those inputs, the check implementation, and all other semantic inputs are unchanged, the derivation identity is unchanged; a trusted cached successful output is then evidence that the exact computation has already passed.
Very roughly, we could:
- represent each logical CI check (initially, approximately each existing job or matrix row) as a normal input-addressed Nix derivation with an explicitly filtered source tree;
- run a small planner on the exact PR or merge-group tree, evaluate every required check's candidate output path, and query a trusted binary cache;
- dynamically schedule only cache misses, while an always-run, fail-closed aggregate job verifies that every required check was either reused from the trusted cache or executed successfully; and
- roll this out first in shadow mode, then for isolated deterministic checks, then for the main Rust matrix.
Nix would not prove that we chose the *right* dependency boundary for a test. It would make the chosen boundary enforceable, auditable, and usable as an exact cache key. Changes to the selection machinery itself would need conservative handling.
---
# Detailed design
## Goals
The primary goal is to avoid executing a CI check when we can establish that the exact declared computation represented by that check has already succeeded.
More specifically:
1. A change outside a check's declared transitive input closure should not invalidate the check.
2. A check should be unable to accidentally read undeclared repository files.
3. The same mechanism should work for pull requests, merge-group commits, and pushes to `main`.
4. Skipping must fail closed: every required check must be accounted for, and planner/evaluation/cache failures must cause CI failure or conservative execution.
5. The dependency declarations used for selection and the dependencies available during execution should have one source of truth.
6. The system should preserve the current ability to parallelize expensive matrix rows.
7. The design should support incremental adoption; it should not require rewriting all CI at once.
Secondary goals are to improve reproducibility, share toolchain/dependency closures without a monolithic per-run Docker artifact, make merge-queue reuse compositional, and expose useful diagnostics about why a check was invalidated.
## Non-goals
This is not intended to:
- infer semantically minimal tests from a Rust diff;
- prove that a test suite is complete;
- make inherently external or time-sensitive checks permanently cacheable;
- split immediately to one derivation per Rust test case;
- trust arbitrary cache contents merely because a Nix store path exists; or
- permit a PR to weaken the selection or checking policy that judges that same PR.
The initial useful granularity is a current logical job or matrix row. Finer splitting should be driven by measured invalidation and runtime data.
## Core model
For a required logical check `C` and candidate repository tree `R`, define a normal input-addressed derivation:
```text
D(C, R) = derivation(
check implementation,
filtered repository source,
toolchain and tools,
target and feature configuration,
relevant environment,
explicit freshness/randomness inputs,
other declared transitive inputs,
)
```
The check creates a small output only after its validation command succeeds. For example:
```json
{
"schema": 1,
"check": "build-test/zerocopy/nightly/all/x86_64-unknown-linux-gnu",
"passed": true
}
```
The output is not the evidence by itself; the important evidence is that a trusted builder successfully realized the exact derivation and published the expected output path.
The reuse rule is:
```text
reuse(C, R) iff trusted_cache_contains(output_path(D(C, R)))
```
Otherwise the check must be built.
This must use ordinary input-addressed derivations for check results. Fixed-output derivations are appropriate for fetching independently hashed inputs, but not for success markers whose identity must include the check implementation and all declared inputs.
### What this establishes
Assuming:
- the derivation is evaluated from trusted check-selection logic;
- all semantic inputs are declared;
- the build sandbox prevents access to undeclared host/repository state;
- the cache or builder is trusted; and
- the relevant hash functions are sound,
then two candidate trees that produce the same check derivation represent the same declared computation. A successful cached output may be reused.
This is stronger than comparing changed paths to a hand-written path list. A path list says what we *believe* is relevant while the process can still read the entire checkout. A filtered derivation makes the declared source closure the only repository content available to the process.
### What this does not establish
Nix cannot determine that the declared closure matches the intended coverage of the check. If a test ought to inspect `foo.rs` but its source fileset omits `foo.rs`, Nix faithfully proves only that the check does not consume it.
The design therefore separates two questions:
1. **Policy:** Which inputs should this check cover?
2. **Mechanism:** Can the check access anything else, and can we identify the exact declared computation?
Nix provides a strong mechanism for the second question. Reviews, coverage inventories, mutation tests, and periodic full runs remain important for the first.
## Check specification
Each logical check should have a stable ID and a specification containing at least:
```text
id
description
source fileset
check harness/command
tool and toolchain inputs
target/platform
feature/configuration inputs
freshness policy
runner class
estimated cost class
```
A schematic Nix interface might look like:
```nix
mkCheck {
id = "actions/validate";
src = sourceFrom [
./.github
./ci/check_actions.sh
];
nativeBuildInputs = [ action-validator ];
command = "./ci/check_actions.sh";
runnerClass = "linux-x86_64";
freshness = "immutable";
}
```
The derivation must not receive `./.` or the full flake source merely for convenience. Doing so would make every repository change an input and erase most of the selection benefit.
`lib.fileset`/`toSource` is a natural way to construct explicit source trees. The exact abstraction can evolve, but it should be difficult to accidentally pass the full checkout.
### Check IDs
Check IDs are an interface between:
- the trusted required-check registry;
- Nix check definitions;
- the planner;
- GitHub matrix generation;
- executors; and
- the final gate.
They should be stable, unique, machine-validated, and independent of display names. Renaming/removing an ID should be treated as a policy change, not as an incidental refactor.
Example IDs:
```text
build/zerocopy/msrv/default/x86_64-unknown-linux-gnu
build/zerocopy-derive/stable/default/i686-unknown-linux-gnu
miri/zerocopy/all/tree/aarch64-unknown-linux-gnu
special/codegen
special/kani
static/actions
static/fmt/zerocopy
static/fmt/anneal
static/readme
anneal/v2
evals/unsafe-rust/v5-protocol
```
## Source closures
The first implementation should use broad, conservative project-level closures. Most of the available benefit comes from separating top-level domains, not from immediately trying to prove that one Rust module cannot affect another.
Likely initial domains include:
- core `zerocopy` library/tests;
- `zerocopy-derive`;
- shared repository tooling;
- Anneal;
- Exocrate;
- Hermes;
- `.github` workflows/actions;
- evaluation artifacts and their validators;
- documentation/policy-only material.
Within a domain, checks can still have distinct sources. For example:
- Actions validation needs `.github/**`, its validator harness, and the pinned validator.
- The root formatting check can become one derivation per Rust workspace.
- Version consistency needs the relevant manifests and the check script.
- Stale UI stderr validation needs the UI test sources, snapshots, harness, and compiler inputs.
- README validation needs the generator, documentation source, and generated README.
- A core build row needs Cargo manifests/lock/config, `build.rs`, library source, the relevant proc-macro source, test support, selected tests, toolchain, target, features, and flags.
- Codegen needs its test, source under test, LLVM/show-asm tooling, and snapshot inputs.
- An eval protocol validator should receive only the particular eval run/framework inputs it validates.
The source tree given to a derivation should preserve whatever relative layout its build system requires. A check that discovers an omitted dependency should fail because the file is absent, causing the fileset to be corrected.
### Cargo-specific boundaries
Cargo metadata and package graphs create legitimate broad dependencies. For example, `zerocopy` tests depend on `zerocopy-derive`, and many derive changes should invalidate core rows. The goal is not to override Cargo's dependency graph; it is to avoid invalidating that graph for unrelated repository content.
A conservative initial core source may include:
```text
zerocopy/Cargo.toml
zerocopy/Cargo.lock
zerocopy/.cargo/**
zerocopy/cargo.sh
zerocopy/build.rs
zerocopy/src/**
zerocopy/tests/**
zerocopy/testutil/**
zerocopy/zerocopy-derive/**
selected shared tools/scripts
licenses or generated inputs actually consumed by the build
```
Later, ordinary build rows, UI tests, codegen, docs, semver checks, and coverage can be split because they consume meaningfully different inputs.
### Repository coverage inventory
It would be useful to generate an inventory mapping maintained paths to the checks whose source closures contain them. This does not prove semantic completeness, but it catches accidental "no check covers this maintained subtree" cases.
The policy could require every maintained path to be one of:
- covered by one or more checks;
- explicitly classified as documentation/data that needs no executable check; or
- deliberately ignored with a recorded justification.
This is especially relevant for isolated subtrees such as `evals/`: skipping all core checks should not silently mean that no validation applies.
## Manifest
Nix evaluation should expose a machine-readable candidate manifest. For example:
```json
{
"schema": 1,
"specDigest": "...",
"checks": {
"build/zerocopy/nightly/all/x86_64-unknown-linux-gnu": {
"attr": "ciChecks.x86_64-linux.build-zerocopy-nightly-all",
"drvPath": "/nix/store/...drv",
"outPath": "/nix/store/...-ci-check",
"runnerClass": "linux-x86_64",
"costClass": "medium",
"freshness": "immutable"
}
}
}
```
Semantic properties must affect the derivation, not merely the manifest metadata. `runnerClass` can be scheduling metadata only if runner differences are irrelevant to the result; otherwise a semantic platform/host epoch must be an input to the derivation.
The planner should validate that:
- the manifest schema is supported;
- every required ID appears exactly once;
- no unknown authoritative ID appears without an explicit policy;
- all output paths are valid;
- IDs and attributes are unique;
- the set is nonempty;
- freshness policies are recognized; and
- the candidate cannot silently delete baseline requirements.
## Planner
An always-run planner should operate on the exact tree GitHub is asking us to test:
- the PR merge ref/commit for `pull_request`;
- the temporary merge-group commit for `merge_group`;
- the pushed commit for `main`.
Conceptually:
```text
candidate_manifest = evaluate_required_checks(candidate_tree)
for check in required_checks:
if check.freshness_policy requires execution now:
missing += check
else if trusted_cache contains check.outPath:
reused += check
else:
missing += check
validate(accounted_for(required_checks) == reused union missing)
emit execution plan
```
The planner should query the candidate output path itself. Comparing only the base and candidate derivation identities is useful for diagnostics but is not sufficient as the reuse rule: an unchanged identity is reusable only if a trusted successful output is available.
### Planner outputs
The planner should emit:
- the complete required-check manifest;
- reused checks and cache evidence;
- missing checks;
- execution batches/matrices;
- reasons for forced freshness;
- a plan digest;
- summary statistics; and
- human-readable invalidation diagnostics where practical.
For diagnostics, evaluating the merge base as well can explain whether a check changed because of:
- repository source;
- the check implementation;
- a toolchain/input lock;
- target/features/flags;
- freshness epoch; or
- selection policy.
This explanation is not part of the correctness decision.
### Execution packing
One GitHub runner per tiny derivation would waste setup time. The planner should be able to pack missing checks by:
- runner class;
- expected duration;
- resource requirements;
- shared setup/closure;
- desired parallelism; and
- maximum batch duration.
Examples:
```text
static-linux-1:
actions validation
version consistency
README check
stale-stderr check
build-nightly-x86-1:
several short related feature rows
kani:
Kani alone
```
Nix still caches each derivation independently even when one GitHub job realizes several of them.
Initially, the packer can be simple and deterministic. Historical duration data can later improve balancing without affecting check identity.
## GitHub Actions integration
The required workflow should always trigger on `pull_request` and `merge_group`. It should not rely on workflow-level `paths` filters, because required workflows that never start can remain pending. Selection should happen inside the workflow.
A schematic shape is:
```yaml
jobs:
plan:
outputs:
small-matrix: ...
large-matrix: ...
small-count: ...
large-count: ...
steps:
- checkout exact candidate
- install pinned Nix
- evaluate and validate manifest
- query trusted cache
- emit plan
execute-small:
needs: plan
if: needs.plan.outputs.small-count != '0'
strategy:
matrix: ${{ fromJSON(needs.plan.outputs.small-matrix) }}
steps:
- build assigned derivations
execute-large:
needs: plan
if: needs.plan.outputs.large-count != '0'
strategy:
matrix: ${{ fromJSON(needs.plan.outputs.large-matrix) }}
steps:
- build assigned derivations
gate:
if: always()
needs: [plan, execute-small, execute-large]
steps:
- verify every required check is accounted for
- accept an executor skip only when its planned count was zero
- reject failures, cancellations, malformed plans, or missing checks
```
The number of executor job *families* should be static so that `needs` can be fail-closed; each family can use a dynamic matrix.
### Final gate
The final gate should remain the single required status check. It should accept a logical check only if:
```text
REUSED:
the planner identified the exact candidate output path, and that output
exists in an approved trusted cache under the applicable freshness policy
or
BUILT:
the check was present in an executor's requested set, the executor realized
the planned derivation successfully, and the result corresponds to the
planned derivation/output path
```
The gate should fail for:
- planner or Nix evaluation failure;
- unsupported/malformed/empty manifest;
- duplicate or missing check IDs;
- an executor skipped despite a nonempty plan;
- an executor failure or cancellation;
- a check accounted for both as reused and built, or neither;
- a built output that does not match the planned derivation;
- a reused path that cannot be verified at gate time;
- a required-check registry mismatch; or
- any selection-TCB change not handled by the conservative policy.
The existing `all-jobs-succeed` pattern is a good foundation: continue to use an always-run aggregate job that explicitly inspects outcomes rather than assuming arbitrary skipped jobs are acceptable.
### Same-run result transport
There are several options for proving successful execution to the gate:
1. Have all missing checks in a static executor family represented by a matrix job and rely on the matrix job result plus the immutable plan.
2. Upload per-batch immutable receipts/artifacts containing the planned and realized output paths.
3. Publish results to a trusted binary cache and have the gate re-query every output path.
4. Run orchestration through a trusted remote Nix builder so the cache itself is the result transport.
The first two are sufficient for an initial GitHub-hosted implementation. The fourth gives the cleanest eventual model.
## Trust model
### Trusted cache
A cache hit is only evidence if the cache is trusted. Possession of a binary-cache signing key is equivalent to the ability to assert arbitrary successful outputs.
Therefore:
- untrusted PR jobs must not receive a shared-cache signing key;
- a shared cache should accept writes only from trusted builders or trusted `main` jobs;
- substituter URLs and public keys are part of the selection TCB;
- the planner/gate must not silently fall back to an unsigned or unapproved cache; and
- cache verification failures should cause a miss or CI failure, never success.
### Initial cache topology
A practical first stage is:
- **shared main cache:** populated only by trusted `main` pushes, readable by PR and merge-group runs;
- **PR-local acceleration cache:** scoped to one PR for repeated pushes, but not authoritative across PRs or for the merge queue;
- **same-run artifacts:** used to fan out or report results within one workflow execution.
This already eliminates structurally unaffected checks whose outputs are present from `main`. A check changed by the PR would run again in the merge queue because the untrusted PR could not publish authoritative shared outputs.
### Trusted remote builder
A later stage can use an isolated trusted builder:
- GitHub supplies the exact derivation to build;
- the builder evaluates/realizes it under the approved policy;
- only the builder holds cache signing credentials;
- successful outputs enter the shared cache;
- PRs can never directly publish success assertions.
This permits a merge-group run to reuse PR results when the exact derivation survives unchanged.
### Compositional merge-queue reuse
Content-addressed check identities allow a merge group to reuse work from multiple PRs.
For example:
- PR A changes core Zerocopy and successfully builds the affected core derivations.
- PR B changes only an eval framework and successfully builds the affected eval derivations.
- A merge group contains A and B.
If the combined tree leaves A's core derivations and B's eval derivations unchanged, the merge group can reuse both sets. This is more precise than treating "the PR CI result" as an indivisible unit.
The planner must always evaluate the actual merge-group tree. It must never assume that a PR result remains valid merely because that PR is present in the group.
## Selection trusted computing base
The following are capable of changing which computations are considered sufficient and should be treated as a selection/checking TCB:
- required-check registry;
- source fileset definitions;
- check harnesses and success-marker logic;
- planner and manifest validation;
- executor wrapper;
- final gate;
- workflow files that invoke the system;
- Nix/flake locks and trusted substituter configuration;
- cache public keys;
- freshness-policy implementation; and
- any helper that can rewrite or reinterpret the execution plan.
A PR must not be able to weaken this machinery and have the weakened machinery certify the same PR.
### Conservative initial policy
Initially, any change to the selection TCB should force:
1. the full existing legacy CI suite;
2. all candidate Nix checks; and
3. consistency checks on the baseline and candidate required-check registries.
No structural skipping should be used for such a PR.
### Stronger eventual architecture
The authoritative planner and gate should ultimately run from code not controlled by the candidate PR. Options include:
- a pinned reusable workflow from the default branch;
- a small external GitHub App/service;
- a `pull_request_target` coordinator that never executes untrusted code with secrets and invokes isolated builders; or
- trusted base-revision check definitions parameterized by the candidate source tree.
A robust two-version approach is:
- the trusted base specification defines the minimum authoritative checks for the candidate tree;
- the candidate specification is also evaluated and run as appropriate;
- removals/weakening cannot take effect until after merge;
- additions can be exercised before becoming authoritative; and
- TCB-changing PRs fall back to the full legacy suite.
Care is required to distinguish the **check harness** from the **subject under test**. If a candidate can modify the script that decides success, that script is TCB and the trusted base version should judge the candidate during a TCB-changing PR.
## Hermeticity and ambient inputs
Checks should be classified by the strength of their reuse guarantee.
### Hermetic checks
These have:
- normal sandboxed derivations;
- no network;
- fully pinned inputs;
- deterministic or intentionally seeded behavior;
- no meaningful undeclared host dependence; and
- no side effects.
They can generally be reused indefinitely for the same derivation identity.
Examples should eventually include most compilation, formatting, generated-file, and static validation checks.
### Host-coupled checks
Some tests depend on kernel/CPU/runner behavior not completely captured by a Nix userspace closure.
For these checks:
- pin the GitHub runner family (for example, avoid `ubuntu-latest`);
- include an explicit semantic platform epoch where necessary;
- record architecture and relevant capabilities;
- periodically force recertification; and
- avoid claiming that identical Nix userspace inputs alone imply identical execution semantics.
### External or time-sensitive checks
Checks that consume mutable external state should either pin that state or declare a freshness policy.
Examples:
- vulnerability/advisory databases;
- "latest published Zerocopy" semver baselines;
- online audits;
- mutable registries;
- service availability;
- publication to Codecov;
- checks whose value comes from recurring execution.
Useful freshness policies might include:
```text
immutable
daily
weekly
per-main-revision
always
explicit epoch
sampled/periodic audit
```
The epoch must become an actual derivation input when it is intended to invalidate reuse.
### Network access
Normal check derivations should run without network access. Fetches should be separate pinned derivations with explicit hashes.
A check that installs a tool from the network during execution is not structurally closed even if its repository source is filtered. Tool installation should move into the Nix closure.
### Randomness and repeated sampling
Caching changes the number of independent executions. This matters for randomized-layout tests, fuzz-like checks, flaky-test detection, and any check whose value comes partly from repeated sampling.
Where possible:
- make seeds explicit and model each desired seed as an input/check;
- use a rotating seed/recertification epoch;
- keep some checks `always` or periodically rebuilt; and
- retain scheduled full/audit runs.
A successful cache hit should mean "the required sampling policy has already been satisfied for this identity/epoch," not accidentally "we stopped sampling forever."
## Zerocopy-specific decomposition
### Main build matrix
The existing `build_test` rows are a reasonable initial derivation granularity:
```text
crate × toolchain descriptor × feature profile × target
```
Nix can generate the valid matrix from data rather than duplicating large `exclude` tables across the workflow and check definitions. Existing policy distinctions—such as PR versus merge-queue target coverage—can remain explicit at first, although content-addressed reuse may later make more merge-queue work cheap enough to run/reuse earlier.
Each row should include:
- exact crate source closure;
- Cargo manifests/lock/config/vendor inputs;
- resolved/pinned Rust toolchain;
- target support/toolchain components;
- feature profile;
- `RUSTFLAGS`/`RUSTDOCFLAGS`;
- build/test/doc/clippy harness;
- target-specific exceptions; and
- any semver baseline if that operation remains in the row.
It may be cleaner to split test/build/doc/clippy/semver into separate derivations later. They have different dependency and freshness properties, and one failing operation should not prevent successful reusable outputs for unrelated operations.
### Toolchain and dependency preparation
The current Docker producer avoids repeated installation, but it is still run and its large artifact is exported/uploaded/fanned out even for changes that do not need the core matrix.
Nix should instead make toolchains and tools ordinary shared store closures. Missing checks can substitute the closures they need. If no selected check requires a closure, no producer job is needed.
This does not guarantee lower transfer volume automatically; Nix closures can also be large. Measure:
- shared-cache download volume;
- GitHub artifact upload/download volume;
- runner setup latency;
- cache hit rate;
- remote-builder locality; and
- whether persistent trusted builders avoid repeated transfers.
The key structural win is that environment preparation becomes demand-driven per selected check instead of a mandatory global precursor.
### Miri
Miri should be a separate family by crate/profile/target/borrow model. Its inputs must include the exact Rust/Miri toolchain and any standard-library source dependencies.
The current need to bypass vendoring for Miri is a sign that this check is not yet fully closed. Until those sources are pinned and supplied hermetically, use a conservative freshness policy or continue running Miri through the legacy path.
### Kani
Pin the Kani implementation/toolchain and represent the proof command as an explicit check. If the GitHub Action remains the execution mechanism, it is an opaque external check and cannot initially receive the same strong derivation-level guarantee. Prefer eventually moving the actual Kani invocation into a pinned Nix closure and keeping only orchestration in Actions.
### Semver checks
"Compare against the latest crates.io release" is mutable external state. Prefer an explicit baseline source/revision/hash as a derivation input. A scheduled automation can update that baseline.
This also avoids making an otherwise identical source tree produce different answers based on when it runs.
### Codegen
Codegen checks should include:
- source under test;
- codegen test harness;
- expected snapshots;
- exact Rust/LLVM/tool versions;
- target/CPU flags; and
- the install helper only if the helper itself is part of the trusted, pinned tool closure.
Separate tool installation from test execution.
### Coverage and publication
Split:
```text
pure derivation: source + toolchain + coverage tool -> lcov artifact
side effect: upload that exact artifact to Codecov
```
The coverage computation can be reused. Publication can run under its own policy and does not need to contaminate the check identity.
### Formatting
Split root formatting by workspace/project. A change under Anneal should not invalidate formatting of core Zerocopy, Exocrate, and unrelated tooling.
A small aggregate formatting check can require all workspace formatting markers.
### Static repository checks
Actions validation, version consistency, stale stderr, README generation, TODO policy, job-dependency validation, and toolchain-list validation are good early candidates because their intended inputs are relatively easy to enumerate.
Some checks should be decomposed. For example, the TODO policy currently includes both a repository scan and the latest commit message. Make those separate checks so every new commit message does not invalidate the expensive/source-wide portion.
### Anneal
Anneal already demonstrates several pieces of this design:
- Nix-produced toolchain artifacts;
- explicit layout validation;
- a trusted-main versus PR-local cache distinction; and
- immutable artifact fan-out.
The current GitHub cache key still duplicates a hand-maintained list of files intended to influence the archive. Replace that duplicated key logic with the actual candidate derivation identity/output path.
Anneal can be migrated independently before unifying all repository CI under one planner.
### Evaluation artifacts
`evals/**` should have an explicit check family or an explicit "data only" classification. A PR like #3607, which changes only a specific evaluation-run subtree, is the motivating shape:
- core Rust build/test, Kani, codegen, coverage, target checks, and core environment preparation should remain reusable;
- only validators whose source closure contains that eval subtree should run; and
- the final gate should still account for every repository-wide policy check.
This should become an acceptance test for the rollout.
## Nix evaluation concerns
The derivation must not accidentally depend on the entire flake source.
In particular:
- construct filtered source paths whose content hash reflects only selected files;
- avoid passing the root flake path through the derivation environment;
- avoid wrappers that copy the full checkout before filtering;
- ensure evaluation-only metadata does not inject the candidate commit hash unless commit identity is semantically relevant;
- treat changes to Nix expressions/check specs as TCB changes; and
- add tests proving that modifying an unrelated file leaves representative derivation/output paths unchanged.
Evaluation should run in pure mode with IFD disabled unless a narrowly reviewed check requires otherwise. Planner evaluation must be bounded and treated as untrusted-input processing.
## Failure behavior
Selection optimization should never make CI less available or less safe.
Recommended behavior:
- cache outage: treat as misses and run checks, unless the trusted builder itself is unavailable;
- malformed cache response/signature failure: fail or miss, never reuse;
- Nix evaluation failure: fail closed;
- unknown file/domain classification: conservatively run broad checks or fail the inventory check;
- planner timeout/resource exhaustion: fail closed;
- executor capacity issue: normal CI failure/retry behavior;
- result upload failure: fail the executor;
- gate cannot retrieve/validate plan: fail;
- selection-TCB change: run full legacy CI.
A cache is an optimization and evidence store, not a single point that can silently turn missing evidence into success.
## Rollout
### Phase 0: measurement
Collect baseline data over a representative set of PR and merge-queue runs:
- changed paths/domains;
- jobs and matrix rows executed;
- per-job wall time and runner time;
- critical path;
- Docker/artifact transfer volume;
- failure distribution;
- rerun/flakiness rate; and
- how often recent `main` would already contain each proposed check output.
Use this to estimate both runner-minute and latency savings.
### Phase 1: shadow planner
Define representative derivations and evaluate/query them, but continue running all existing CI.
For every run, report:
- predicted reused and missing checks;
- derivation changes;
- cache availability;
- estimated saved runner time;
- path/domain coverage; and
- discrepancies between the proposed plan and observed legacy failures.
No checks are skipped.
### Phase 2: isolated deterministic checks
Move easy static checks and per-workspace formatting to Nix. Continue running legacy equivalents temporarily and compare results.
Enable reuse for these checks once parity is established.
### Phase 3: matrix parity
Represent each existing core matrix row as a derivation, initially executing every row. Confirm:
- command equivalence;
- target/feature/toolchain coverage;
- failure equivalence;
- generated artifacts/snapshots;
- performance; and
- sandboxed source boundaries.
### Phase 4: PR selection using trusted-main cache
Enable the planner to skip exact cache hits from the trusted `main` cache. Keep TCB-changing PRs on full legacy CI.
This yields the largest low-risk win for changes isolated from the core library.
### Phase 5: merge queue
Run the same planner on exact merge-group commits. Initially rerun PR-dirty checks because PR jobs cannot publish authoritative shared results.
### Phase 6: trusted remote builder
Introduce trusted PR builds and a signed shared cache, allowing exact PR outputs to be reused by merge groups and later pushes.
### Phase 7: retire redundant infrastructure
Once the Nix closure distribution is reliable and efficient, remove the monolithic Docker producer/fan-out where it no longer helps. Retain legacy fallback paths until the new system has sufficient operational history.
### Phase 8: refinement
Use measurements to split checks whose source closures are too broad, improve packing, and tune freshness/periodic-audit policy.
## Validation strategy
### Structural invariance tests
For representative checks, mutate files outside the declared source closure and assert that the derivation/output path is unchanged.
Mutate each declared input class and assert that it changes:
- source file;
- harness;
- toolchain lock;
- target/features;
- flags;
- seed/epoch;
- baseline source.
### Sandbox tests
Add a fixture check that attempts to read an undeclared repository file and assert that it fails.
Also test accidental full-checkout references by inspecting derivation closures and rejecting the root repository source where forbidden.
### Planner/gate adversarial tests
Exercise:
- empty manifest;
- duplicate IDs;
- deleted required check;
- unknown freshness policy;
- forged reused entry;
- cache signature failure;
- stale/wrong output path;
- executor skip with nonempty plan;
- cancellation;
- partial matrix execution;
- mismatched plan digest;
- candidate TCB weakening;
- merge-group SHA mismatch; and
- cache outage fallback.
### Differential runs
During shadow/parity phases, run both systems on the same candidate and require matching pass/fail outcomes for equivalent checks.
When a legacy check fails that the planner predicted reusable, treat that as a serious modeling or nondeterminism bug and block rollout for that check family.
### Scheduled recertification
Run periodic full or forced-rebuild workflows that:
- ignore old success markers for selected families;
- rotate random/freshness epochs;
- use Nix rebuild/check modes where useful;
- compare rebuilt outputs;
- exercise current host assumptions; and
- alert on nondeterminism or cache/build disagreement.
## Expected impact
The effect depends strongly on change shape.
| Change shape | Expected selection benefit |
| --- | --- |
| `evals/**`, isolated docs, or unrelated data only | Very high; most core/specialized Rust checks reusable |
| Anneal-only or Exocrate-only | High; core Zerocopy matrix reusable |
| `.github/**` or selection-TCB changes | Low initially; full conservative run |
| `zerocopy-derive` implementation | Moderate; derive and dependent core checks legitimately invalidated |
| central `zerocopy/src/**` | Lower job-elimination benefit; many checks correctly invalidated |
| Cargo/toolchain/global harness changes | Broad invalidation |
Runner-minute savings may be much larger than critical-path latency savings. If one remaining dirty Kani or Miri check is the critical path, eliminating dozens of other jobs mainly reduces cost and queue contention.
Merge-queue throughput may improve more than individual PR latency because exact check outputs can survive rebases and combine across disjoint PRs.
## Risks and tradeoffs
- **Complexity:** We would be building a small CI planner and trust model, not merely adding a few Nix expressions.
- **Incorrect filesets:** The mechanism can enforce a wrong boundary perfectly. Review and validation remain necessary.
- **Nix evaluation overhead:** Fine-grained derivations and large manifests can make evaluation expensive; measure and bound it.
- **Runner setup/download overhead:** A cache hit that still requires installing Nix or downloading a large closure may save CPU but not enough wall time. Job elimination and remote builders matter.
- **Operational dependency on cache/build infrastructure:** Outages need conservative fallback.
- **Cross-platform semantics:** Nix system identity does not capture every host property.
- **Reduced repeated sampling:** Permanent cache reuse is inappropriate for some checks.
- **Bootstrap/security:** Candidate-controlled CI policy cannot safely certify itself.
- **Contributor experience:** Local reproduction should remain straightforward, ideally `nix build .#ciChecks.` plus convenient aliases.
These are manageable, but they argue for a measured rollout rather than an immediate rewrite.
## Open questions
1. Should the eventual execution model be dynamic GitHub matrices, a single coordinator with trusted remote Nix builders, or a hybrid?
2. Where should the authoritative selection TCB live?
3. Which existing jobs are sufficiently hermetic to reuse indefinitely, and which need epochs or `always` policies?
4. What is the right initial check granularity for the main matrix?
5. Should test/build/doc/clippy/semver operations be separate from the beginning or split after parity?
6. What cache implementation and signing/key-custody model should we use?
7. How should external contributors' PRs interact with trusted builders?
8. How much closure transfer would Nix require compared with the current Docker artifact?
9. Which maintained paths currently have no meaningful CI coverage?
10. What scheduled recertification cadence is appropriate for Miri, Kani, randomized layout, vulnerability data, and host-sensitive tests?
11. Should selection policy be encoded entirely in Nix, or should a small non-Nix registry describe required checks and import Nix identities?
12. How should we expose invalidation explanations without making diagnostic metadata part of derivation identity?
## Initial success criteria
A first production milestone should demonstrate all of the following:
- A PR changing only a covered `evals/**` subtree runs the relevant eval/static checks and does not start the core Docker environment or core Rust matrix.
- A change outside a representative check's fileset leaves its derivation/output path unchanged.
- A check cannot read a repository file omitted from its fileset.
- Every required check is accounted for exactly once as reused or built.
- Cache, planner, and gate failures fail closed or conservatively execute checks.
- Selection-TCB changes force full legacy CI.
- The exact merge-group commit is planned.
- The required GitHub status always completes rather than remaining pending due to path filtering.
- Shadow/differential testing has found no case where a skipped/reused check would have failed under the legacy implementation.
- The measured runner-time savings justify the added infrastructure.
Contributor guide
Research direction
Start by reading the existing PR and merge-queue workflows under .github, then inspect the repository's current CI checks and scripts such as ci/check_actions.sh. Compare those entry points with the proposed check specifications, source closures, planner, trusted-cache validation, and fail-closed aggregate gate; done means the design is implemented incrementally without weakening required-check policy.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- github-actions, rust
- Domain
- build-system, ci-cd
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 30/100