A CI setup that proves the workspace builds, tests, lints and stays publishable, plus the cargo plugins that
keep dependencies tidy. Commands assume a workspace root; every cargo invocation in CI uses --locked.
Contents
- What CI must prove
- Core commands
- GitHub Actions workflow
- Caching: rust-cache and sccache
- cargo-nextest
- Dependency hygiene: deny, machete, udeps, shear
- Feature and MSRV matrix: cargo-hack
- Semver and release checks
- Only building what changed
- Workspace audit in CI
- Tool summary
What CI must prove
| Property | Command |
|---|---|
| Formatting | cargo fmt --all --check |
| Every crate, every target compiles | cargo build --workspace --all-targets --locked (or check) |
| Lints clean | cargo clippy --workspace --all-targets --all-features --locked -- -D warnings |
| Tests pass | cargo nextest run --workspace --locked + cargo test --workspace --doc --locked |
| Lock file is committed and current | --locked on every command |
| Each crate builds with its own features | cargo hack check --workspace --each-feature --no-dev-deps |
| MSRV holds | cargo +1.85 check --workspace --all-targets --locked or cargo hack check --rust-version --workspace |
| Docs build without broken links | RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked |
| Licenses / advisories / duplicate policy | cargo deny check |
| Published API not broken | cargo semver-checks --workspace |
| Workspace structure | /tmp/manifest_lint . --strict, /tmp/dep_audit . --strict, /tmp/crate_graph . (this skill's scripts) |
Key flags:
--workspace(alias--all, deprecated) selects every member; without it a virtual manifest buildsdefault-membersor all members, and a root package builds only itself.--all-targets= lib, bins, tests, benches, examples. Without it, examples and benches rot.--lockedfails ifCargo.lockwould change.--offlineforbids network.--frozen= both.--all-featureschecks feature-gated code; it is not a substitute for testing features one by one.--keep-going(1.74) reports all failing crates instead of stopping at the first.
Core commands
cargo fmt --all --check
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo build --workspace --all-targets --locked
cargo nextest run --workspace --all-features --locked
cargo test --workspace --doc --locked
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --lockedPut the same list in cargo xtask ci (see crate-boundaries-and-layouts.md)
so developers run exactly what CI runs.
GitHub Actions workflow
name: ci
on:
pull_request:
push:
branches: [main]
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0 # incremental artefacts are useless in CI and bloat the cache
RUST_BACKTRACE: 1
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- run: | # this skill's scripts, vendored under scripts/
for t in manifest_lint dep_audit crate_graph; do rustc -O --edition 2021 scripts/$t.rs -o /tmp/$t; done
/tmp/manifest_lint . --strict && /tmp/dep_audit . --strict && /tmp/crate_graph .
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- run: cargo nextest run --workspace --all-features --locked --profile ci
- run: cargo test --workspace --doc --locked
msrv:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.85" # keep in sync with workspace.package.rust-version
- uses: Swatinem/rust-cache@v2
- run: cargo check --workspace --all-targets --locked
features:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-hack
- run: cargo hack check --workspace --each-feature --no-dev-deps
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2Notes:
dtolnay/rust-toolchain@stableinstalls the latest stable;@masterwith atoolchaininput installs a specific version. Arust-toolchain.tomlat the root pins the toolchain for everyone (CI and developers); then the action only needs to install components.save-ifsaves the cache only frommain, so PR branches restore it without each writing gigabytes.fail-fast: falsekeeps the other OS jobs running when one fails.- Cross-platform matrix: run full tests on Linux, a smoke
checkon others if minutes matter.
Caching: rust-cache and sccache
Swatinem/rust-cache caches ~/.cargo (registry, git) and target/ keyed by the lock file, toolchain
and job. Useful inputs:
| Input | Use |
|---|---|
shared-key | share one cache between jobs that build the same thing |
key | extra key component (e.g. a feature set) |
workspaces | "path/to/ws -> target" when the workspace is not at the repo root or there are several |
cache-on-failure | save even if the job failed (warm cache for the retry) |
save-if | restrict saving (e.g. main only) |
cache-targets | false to cache only ~/.cargo |
It removes workspace members' own artefacts before saving, keeping only dependency artefacts, which is the part that is expensive and stable.
sccache caches individual rustc invocations in a backend (GitHub Actions cache, S3, GCS, Redis, local
disk). It helps when many jobs or branches compile the same dependencies with the same flags.
- uses: mozilla-actions/sccache-action@v0.0.9 # pin the current release
- run: cargo build --workspace --all-targets --locked
env:
SCCACHE_GHA_ENABLED: "true"
RUSTC_WRAPPER: sccache- sccache cannot cache incremental compilation, linking, or crates with build scripts that are not
deterministic; set
CARGO_INCREMENTAL=0. - Use either rust-cache's
target/caching or sccache as the primary mechanism; both together mostly duplicate storage. Many teams use rust-cache for small workspaces and sccache for large ones. - Locally,
RUSTC_WRAPPER=sccacheshares compiled dependencies between different checkouts and worktrees.
cargo-nextest
A faster test runner: each test runs in its own process, in parallel across all test binaries, with retries, timeouts, JUnit output and partitioning.
cargo nextest run --workspace --locked
cargo nextest run -p shop-app -E 'test(order::)' # filter expression
cargo nextest run --workspace --partition count:1/4 # shard across CI machines
cargo nextest archive --workspace --archive-file tests.tar.zst # build once, run elsewhere# .config/nextest.toml
[profile.ci]
retries = 2
fail-fast = false
slow-timeout = { period = "60s", terminate-after = 3 }
[profile.ci.junit]
path = "junit.xml"nextest does not run doctests; keep cargo test --doc --workspace as a separate step. Tests that rely on
shared in-process state across tests (a global set once per binary) behave differently under
process-per-test; that is usually a bug worth fixing.
Dependency hygiene: deny, machete, udeps, shear
cargo-deny checks the full dependency graph against a policy in deny.toml:
cargo install cargo-deny --locked
cargo deny init # writes a commented deny.toml
cargo deny check # advisories, bans, licenses, sources
cargo deny check advisories # one category# deny.toml (excerpt)
[advisories]
ignore = [] # RUSTSEC ids with a justification comment
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0"]
[bans]
multiple-versions = "warn" # duplicate crates in the graph
wildcards = "deny" # "*" requirements (path deps without version count unless allow-wildcard-paths)
deny = [{ name = "openssl-sys", reason = "use rustls" }]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-git = []cargo-audit (cargo audit) checks only RustSec advisories against Cargo.lock; cargo-deny's advisories
check covers the same database.
Unused dependencies:
| Tool | How it works | Toolchain | Trade-off |
|---|---|---|---|
| cargo-machete | text search of sources for each dependency name | stable, very fast | false positives for deps used only via macros or renamed; ignore with [package.metadata.cargo-machete] ignored = ["..."] |
| cargo-shear | parses sources to find unused deps (also misplaced dev-deps) | stable, fast | newer; similar caveats |
| cargo-udeps | uses compiler output to find deps never linked | nightly (cargo +nightly udeps --workspace --all-targets) | precise, but needs a full build per configuration |
Run machete or shear on every PR (seconds), udeps on a schedule.
Feature and MSRV matrix: cargo-hack
cargo hack check --workspace --each-feature --no-dev-deps
cargo hack check --workspace --feature-powerset --depth 2 --no-dev-deps
cargo hack check --workspace --rust-version # each crate with its own rust-version toolchain
cargo hack test --workspace --each-feature --exclude-features nightlycargo-hack builds packages one at a time, so feature unification across members cannot hide missing features. Details: features-and-unification.md.
Semver and release checks
semver:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: obi1kenobi/cargo-semver-checks-action@v2Also run cargo publish --dry-run -p <crate> for each publishable crate in release PRs (bottom layer first),
or let release-plz do both. See publishing-msrv-and-lockfiles.md.
Only building what changed
In large workspaces, building every member for every PR wastes time. Options:
- Rely on caches first. With rust-cache or sccache, unchanged crates are mostly free.
- Path filters per app in CI (
on.pull_request.paths), coarse but simple. - Affected-crates computation: map changed files to members, then add their reverse dependencies.
cargo metadata --format-version 1gives the graph; tools such ascargo-guppy-baseddeterminator, orcargo hakari's companion tooling, compute affected packages. Then runcargo nextest run -p crate-a -p crate-b. Always keep a full--workspacerun onmain. - Remember feature unification: building a subset can change features and miss errors that the full build
would show, or vice versa. The full build on
mainis the backstop.
Workspace audit in CI
The three scripts are single-file, std-only Rust programs: rustc compiles each in about a second, no Cargo
project or network needed, so they can run in the cheapest job (any runner with a Rust toolchain):
for t in manifest_lint dep_audit crate_graph; do rustc -O --edition 2021 scripts/$t.rs -o /tmp/$t; done
/tmp/manifest_lint . --strict # fail on any manifest finding
/tmp/dep_audit . --strict --ignore CW014 # tolerate unpinned git deps while migrating
/tmp/dep_audit . --json > dep-audit.json # artefact for dashboards
/tmp/crate_graph . --dot > crates.dot # document the architecture (exit 1 on a cycle)Tool summary
| Tool | Install | Purpose |
|---|---|---|
| cargo-nextest | taiki-e/install-action with tool: cargo-nextest | fast, parallel, sharded tests |
| cargo-hack | cargo install cargo-hack --locked | per-crate feature and MSRV matrices |
| cargo-deny | EmbarkStudios/cargo-deny-action@v2 | advisories, licenses, bans, sources |
| cargo-audit | cargo install cargo-audit --locked | RustSec advisories only |
| cargo-machete / cargo-shear | cargo install cargo-machete --locked | unused dependencies, stable toolchain |
| cargo-udeps | cargo install cargo-udeps --locked | unused dependencies, nightly, precise |
| cargo-semver-checks | obi1kenobi/cargo-semver-checks-action@v2 | breaking API changes |
| cargo-msrv | cargo install cargo-msrv --locked | find / verify the MSRV |
| cargo-hakari | cargo install cargo-hakari --locked | workspace-hack crate for unified features |
| release-plz / cargo-release | see publishing reference | version bumps and ordered publishing |
| sccache | mozilla-actions/sccache-action | shared compilation cache |
| Swatinem/rust-cache | GitHub Action | cache ~/.cargo and dependency artefacts |
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-error-handling-thiserror-anyhow-designer
Design and review Rust error handling with thiserror 2 and anyhow 1: library vs application split, typed error enums with #[from], #[source], #[error(transparent)] and #[non_exhaustive], ErrorKind patterns, context via .context()/.with_context(), bail!/ensure!, source() chains, {:#} and {:?} reports, RUST_BACKTRACE/RUST_LIB_BACKTRACE, Box<dyn Error + Send + Sync>, main -> anyhow::Result or ExitCode, unwrap/expect/panic policy, Option to Result, collecting iterators of Results, axum IntoResponse mapping, retryable errors, tracing, async Send bounds, testing with matches! and downcast_ref, no_std core::error::Error, and snafu, eyre/color-eyre, miette, error-stack.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-tokio-async-service-builder
Build and review production async Rust services on Tokio 1.x and axum 0.8: runtime choice (#[tokio::main] multi_thread vs current_thread, Builder), axum routing with /{id} paths, State, extractors, IntoResponse error types, axum::serve with_graceful_shutdown, tower/tower-http middleware (TraceLayer, TimeoutLayer, CorsLayer, ConcurrencyLimitLayer, RequestBodyLimitLayer), Arc<AppState>, std vs tokio Mutex, JoinSet, TaskTracker, CancellationToken, select! cancel safety, bounded mpsc, oneshot, broadcast, watch, actors, Semaphore, timeouts, retries, spawn_blocking, sqlx PgPool, reqwest Client reuse, tracing, tests with start_paused and oneshot, Dockerfile. Fixes 'future cannot be sent between threads safely', E0373, Rc or MutexGuard held across await, 'Cannot start a runtime from within a runtime', 'Path segments must not start with :'. Ships std-only Rust scanners and a compiling axum 0.8 scaffold.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.