Skip to content

The Rust Engineering Handbook / Chapter 82

CI Matrices, Lints, Feature Combinations, and MSRV

Turn a Rust support contract into cost-aware fast, deep, and release gates across features, targets, toolchains, and dependencies.

A CI design review begins with two artifacts: a support contract and a budget. The contract promises Rust 1.85 through current stable, Linux, macOS, and Windows, default and no-default configurations, three additive features, documentation, and packaged crates. The service adds one deployment target, a container image, and a dependency audit. The budget allows 120 executor-minutes per change. A literal product of toolchains, targets, feature combinations, profiles, dependency states, and test kinds exceeds that budget before dynamic analysis begins.

The wrong response is either “test everything” or “run cargo test once.” A CI system is a portfolio of evidence. Write the support contract first, map each failure risk to the cheapest representative configuration that can expose it, then assign a feedback deadline: presubmit, scheduled, advisory, or release-only. The result is intentionally incomplete coverage with named residual risk, not an accidental matrix whose omissions no one can explain.

Start with promises, not workflow syntax

Put supported dimensions in a repository-owned document. For the chapter lab, the compact form is:

Dimension Promise Fast representative Deeper evidence
Rust 1.85 MSRV through current stable stable default; MSRV no-default beta advisory; latest dependency resolution
features empty, default, all, and named high-risk pairs five selected combinations powerset when small or changed
targets Tier-1 desktop families used by callers primary Linux host macOS/Windows scheduled or required by change risk
docs public API examples and links compile default documented set all documented feature sets
package published contents are complete metadata checks clean-room package build/test
dynamic tools selected safety and search jobs stable regressions scheduled search and instrumentation campaigns

Separate supported from observed. A project may compile on an untested target without promising it. Conversely, naming Windows support without executing any Windows evidence creates an unsupported promise. Give each promise an owner, failure policy, and review date. If budget cannot sustain it, narrow the support contract or buy capacity; do not keep a ceremonial badge.

The matrix should be generated or reviewed as data so changes are visible. Workflow YAML is an execution encoding, not the source of truth. A table, script, or manifest can state why each cell exists and which promise it protects.

Make formatting a deterministic front door

Run cargo fmt --check early. Formatting should produce one unambiguous result under a pinned rustfmt version. Avoid letting CI rewrite source, because a mutating gate hides the exact patch under review. If nightly-only formatting options are used, pin the nightly and accept its maintenance cost; stable projects usually gain more from stable rustfmt than from volatile layout preferences.

Formatting is cheap enough to run separately, but process startup and dependency compilation are not free. Arrange fast jobs so a formatting failure returns quickly without blocking independent compilation work on a serial chain. Cancellation policy also matters: superseded commits should release expensive workers while retaining enough logs to diagnose infrastructure failures.

Generated files need an explicit rule. Either check them in and verify regeneration produces no diff, or generate them as build artifacts and verify consumers use the generated output. A generic formatting pass cannot prove that schemas, bindings, or lockfiles are fresh.

Operate rustc and Clippy lints as policy

Rustc warnings identify language and compiler concerns; Clippy adds configurable analysis and style or correctness lints. cargo clippy -- -D warnings is a useful CI boundary because it converts warnings in that invocation into failure. It is not a complete policy by itself. New compiler or Clippy releases can introduce lints, targets and feature sets expose different code, dependencies may emit diagnostics, and allow-by-default groups vary in stability and fit.

Choose levels by consequence:

  • deny for a small owned set whose violations must block every supported configuration, such as unsafe-code prohibition in a safe-only crate or repository rules against unfinished-work panics in shipped paths;
  • warn for defects that should be visible while teams migrate or whose false-positive cost needs observation;
  • scoped allow with a nearby reason where the code intentionally violates a rule;
  • expect when the presence of a known lint is itself checked and the active compiler supports that policy;
  • never enable an entire restriction-style category merely because “strict” sounds safe.

Keep durable policy in Cargo.toml or source where all developers and tools see it. Use CI flags for gate-wide escalation and experiments. The lab manifest forbids unsafe Rust and denies debug-print and unfinished-work macro lints; the verification command escalates all remaining warnings. A public library may preserve stricter API and documentation rules than benches or generated bindings. Scope those differences explicitly rather than excluding whole target classes from Clippy.

Lint exceptions are debt only when unmanaged. Require a reason tied to an invariant or compatibility constraint, prefer the narrowest item, and review broad module-level allows. Pinning an old compiler forever to avoid new warnings transfers cost into security and ecosystem compatibility.

Build documentation as a caller configuration

Rust documentation contains public paths, doctests, intra-doc links, and feature-dependent examples. Run documentation tests where relevant and build docs with warnings denied for the feature set users will read. A default-only build misses items documented behind optional features; an all-feature build can hide examples that incorrectly assume a feature is always present.

Select documented configurations deliberately:

default documented experience
no-default minimal caller
feature-specific public surfaces
all-features collision check

If platform-specific APIs appear in docs, build for or on the promised target where practical. Docs.rs has its own build environment and metadata; a local documentation build is necessary evidence but not a guarantee of remote rendering. For a release, inspect the packaged crate in a clean environment rather than relying on a workspace checkout that contains extra files and path dependencies.

Do not confuse all features with all combinations

Cargo features are intended to be additive. --all-features enables their union; it does not test --no-default-features, each singleton, or interactions where one feature is absent. Feature unification can also cause a dependency feature to be enabled through another workspace member, masking a consumer configuration.

The lab defines text, checksum, and metrics. Its script runs:

--no-default-features
default
--all-features
--no-default-features --features checksum,metrics
--no-default-features --features text,checksum

This selection has reasons. Empty protects the minimal core. Default protects the common caller. All catches name collisions and code that fails when capabilities coexist. checksum,metrics exercises two nondefault operational capabilities without text. text,checksum covers the likely library integration. If a bug appears only in text,metrics, promote that pair or revise the feature architecture.

For a small number of public independent features, an exhaustive powerset may be affordable. At ten features, 1,024 combinations per target and toolchain usually are not. Reduce the space using feature relationships, changed-code impact, pairwise interaction coverage, known risk clusters, and historical failures. Document forbidden combinations with compile-time errors only when they represent an invalid contract; prefer additive designs that make combinations coherent.

Test package selection carefully in workspaces. --workspace --all-features answers a different question from checking a single package as an external consumer. Resolver behavior and feature unification vary by workspace graph and target. Use cargo tree -e features or metadata evidence when a surprising configuration needs explanation, and include a consumer fixture for public feature contracts that workspace unification could conceal.

A finite CI budget routes toolchain and feature risks to fast blocking checks, target risks to scheduled deep checks, dependency risks to advisory checks, and package risks to release-only checks. Documentation and other dimensions remain explicit inputs to the portfolio.
A CI matrix earns trust by spending finite capacity on named risks: protect common and high-consequence contracts quickly, rotate broader search through deep lanes, and reserve artifact-only evidence for release.

Test targets by consequence

Cross-compilation proves that code can be compiled for a target; it does not execute tests against that target’s filesystem, process, networking, allocator, ABI, or synchronization behavior. Decide which promises need compile evidence and which need native execution.

A portable library often runs native tests on one host per promised OS family and cross-checks additional architectures. A service may block on its deployment target and schedule smoke tests elsewhere. FFI, filesystem semantics, endianness, pointer width, atomics, and platform APIs deserve stronger target evidence than pure arithmetic.

Use conditional compilation as a visible architecture boundary. A target that compiles no tests can look green while its implementation is unused. Record test counts or include target-specific assertions for critical modules. Keep secrets and external services out of basic portability jobs; later architecture work should make those effects controllable through explicit seams.

Make MSRV an executable contract

package.rust-version = "1.85" communicates a minimum supported Rust version to Cargo and tooling. It does not prove the crate and resolved dependencies compile there. Run the actual MSRV toolchain with representative features and targets. Avoid letting a lockfile generated by a newer resolver conceal what downstream users will resolve, but also avoid making every pull request depend on an unconstrained fresh registry state.

Rust 2024 implies resolver version 3, whose Rust-version-aware behavior prefers compatible dependencies under its fallback policy. That improves resolution but is not a substitute for an MSRV job. Dependencies may omit accurate rust-version metadata, code may use a newer library API, build scripts and dev-dependencies have their own constraints, and the tested lockfile may differ from a library consumer’s resolution.

Choose an MSRV policy:

  • exact and blocking: appropriate when a public library promises a version and can fund the test;
  • rolling window: advance on a documented cadence, often aligned with supported compiler or distribution windows;
  • best effort: label it honestly and avoid a precise manifest promise the project will not maintain.

Run enough MSRV configurations to expose language/library use and feature-gated code, but do not duplicate the entire stable matrix without evidence of value. Commonly, MSRV checks empty/default and the most compatibility-sensitive feature cluster; stable owns the full selected matrix. When raising MSRV, review SemVer and ecosystem policy, update documentation and CI atomically, and test the release artifact.

Stable is the primary current contract. Beta is an early-warning channel for upcoming stable changes and can be advisory until triaged. Nightly is appropriate for explicitly nightly-owned tools such as Miri or sanitizer mechanisms; a transient nightly component failure should be visible without pretending the stable product is broken. Pin dated nightlies for reproducible deep jobs and schedule renewal.

Separate lockfile freshness from reproducibility

Applications and services usually commit a lockfile to reproduce deployed dependency selection. Libraries may also commit one for contributor and CI reproducibility even though downstream resolution is governed by dependency requirements. A locked presubmit run answers “does this revision work with the reviewed graph?” A scheduled fresh-resolution run answers “does it still work with newly selectable compatible dependencies?” Keep both questions.

For current stable, update dependencies in an isolated scheduled job and build/test without silently committing the result. Report the candidate lockfile diff and failures. For MSRV, use Rust-version-aware resolution as designed and verify the resulting graph. Test minimal dependency versions only where the available Cargo mechanism and project’s declared version ranges make that experiment meaningful; historically, minimal-version workflows have involved unstable or evolving behavior and can be defeated by dependencies with inaccurate lower bounds. Treat them as targeted ecosystem evidence, not a universal blocking command.

A fresh-resolution failure can mean an upstream regression, an overly broad requirement, a new MSRV incompatibility, or a defect in the project. Assign ownership and preserve the resolved graph. Automatically pinning around every failure can accumulate silent dependency debt.

Audit dependencies with multiple signals

Dependency auditing is not one command. Separate at least:

  • known vulnerability advisories for the resolved graph;
  • license and source policy;
  • duplicate or unexpectedly activated packages/features;
  • unmaintained, yanked, or policy-prohibited dependencies;
  • provenance and integrity controls for fetched artifacts;
  • review of build scripts, proc macros, and native dependencies with elevated build-time power.

Choose failure thresholds and exception workflow. A vulnerability with no reachable affected code may still require action, but its response differs from an exploitable deployed path. Exceptions need owner, justification, affected versions, compensating control, and expiry. Cache advisory data carefully so an unavailable feed does not become a false green.

Libraries should inspect normal, build, and dev dependency implications for consumers and contributors. Services should connect the Rust graph to container base images and deployment artifacts; a clean Cargo audit does not cover an operating-system package vulnerability.

Test what will be released

cargo test in a rich workspace can pass while the package is missing a README, schema, license, generated source, or test fixture. Before release:

  1. inspect package contents;
  2. build and test the packaged archive in a clean directory where supported;
  3. build documentation for advertised features;
  4. verify license, repository, readme, and version metadata;
  5. verify no secrets, large corpora, local paths, or internal-only files are included;
  6. run ABI, protocol, or compatibility fixtures required by the release contract;
  7. associate results with the exact source revision and toolchain.

For a service, replace crate publication with artifact provenance: build the same binary or image intended for deployment, scan it, record dependencies and configuration, and smoke-test startup and shutdown. A debug workspace test does not validate a stripped release binary, container entrypoint, or migration bundle.

Allocate fast and deep lanes

A useful pipeline offers progressively broader evidence:

Fast blocking lane, roughly minutes: format check; stable compile/test for default; no-default library check; selected Clippy targets with warnings denied; changed-code unit/integration tests; deterministic regressions promoted from dynamic analysis.

Full blocking lane, within review tolerance: stable selected feature matrix; promised primary targets; docs and doctests; actual MSRV representatives; required integration tests; dependency policy using cached, fresh-enough data.

Scheduled deep lane: fresh dependency resolution; beta advisory; broader targets; feature powerset or pairwise expansion; Miri, sanitizers, fuzzing, model checking; slow platform and lifecycle tests.

Release lane: clean package/artifact build; release profile; package contents; compatibility fixtures; provenance, audit, and smoke checks against the exact candidate.

Do not use “non-blocking” to mean “ignored.” Advisory failures need an owner, notification, service-level target, and escalation before they become stable failures. Conversely, flaky infrastructure should not train maintainers to rerun until green. Classify product, test, tool, and infrastructure failures separately and retain evidence.

Design two gate portfolios

Create two tables under a 120 executor-minute presubmit budget.

For a public library, include no-default/default/all/selected feature evidence, actual MSRV, stable and beta policy, promised OS targets, docs, downstream consumer fixture, fresh resolution, audit, and package verification. For each row, name the contract, command class, toolchain/target/features, cadence, blocking policy, expected cost, and retained artifact.

For a deployed service, begin with the deployment target, locked graph, migrations, release profile, container or service artifact, startup/shutdown, configuration validation, and vulnerability ownership. Add MSRV only if the service genuinely promises it. Decide which feature combinations are deployable rather than testing library-style powersets that cannot occur in production.

Then remove at least two redundant cells from each table and explain the residual risk. Add one cell justified by a historical or high-consequence failure. The exercise succeeds when the portfolios differ: repository purpose changes the evidence architecture.

Review the matrix as a maintained contract

Before accepting a CI design, ask:

  • Is every matrix cell linked to a support promise or named failure risk?
  • Do empty, default, all, and risky feature interactions receive distinct treatment?
  • Could workspace feature unification hide an external consumer failure?
  • Are compile-only target checks distinguished from native execution?
  • Does the declared MSRV run on the actual compiler with representative dependencies and features?
  • Are stable, beta, and pinned nightly failures governed differently for good reasons?
  • Do locked and fresh-resolution jobs answer separate questions?
  • Are lint denies small, owned, and reproducible across intended configurations?
  • Do documentation and package checks inspect what callers receive?
  • Are dependency exceptions owned and expiring?
  • Are deep advisory failures triaged before they reach the blocking channel?
  • Can the team explain what the matrix deliberately does not cover?

CI earns trust by making omissions explicit. A finite portfolio cannot explore every byte, schedule, target, dependency graph, and feature set. It can protect the configurations the project actually promises, rotate deeper search through the remaining risk, and preserve every discovered defect as cheaper evidence. For those gates to remain hermetic, observable, and fast, the architecture must now expose controllable time, I/O, entropy, failure, and environment boundaries.

Sources and version notes

Cargo resolver, feature, and MSRV behavior is version-sensitive. The Rust 2024/resolver 3 statements in this chapter assume Cargo 1.84 or newer; the lab declares Rust 1.85 and is verified on the exact installed MSRV and current toolchain. Re-check current Cargo, rustc, Clippy, rustfmt, and hosted CI behavior before changing a production policy.