Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

CI and Tooling for Cargo Workspaces

Reference for rust-cargo-workspace-designer · 313 lines

Programming / Rustv1.0.0Updated OCT 21 20267 references6 scripts

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

PropertyCommand
Formattingcargo fmt --all --check
Every crate, every target compilescargo build --workspace --all-targets --locked (or check)
Lints cleancargo clippy --workspace --all-targets --all-features --locked -- -D warnings
Tests passcargo 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 featurescargo hack check --workspace --each-feature --no-dev-deps
MSRV holdscargo +1.85 check --workspace --all-targets --locked or cargo hack check --rust-version --workspace
Docs build without broken linksRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked
Licenses / advisories / duplicate policycargo deny check
Published API not brokencargo 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 builds default-members or all members, and a root package builds only itself.
  • --all-targets = lib, bins, tests, benches, examples. Without it, examples and benches rot.
  • --locked fails if Cargo.lock would change. --offline forbids network. --frozen = both.
  • --all-features checks 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

bash
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 --locked

Put the same list in cargo xtask ci (see crate-boundaries-and-layouts.md) so developers run exactly what CI runs.

GitHub Actions workflow

yaml
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@v2

Notes:

  • dtolnay/rust-toolchain@stable installs the latest stable; @master with a toolchain input installs a specific version. A rust-toolchain.toml at the root pins the toolchain for everyone (CI and developers); then the action only needs to install components.
  • save-if saves the cache only from main, so PR branches restore it without each writing gigabytes.
  • fail-fast: false keeps the other OS jobs running when one fails.
  • Cross-platform matrix: run full tests on Linux, a smoke check on 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:

InputUse
shared-keyshare one cache between jobs that build the same thing
keyextra 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-failuresave even if the job failed (warm cache for the retry)
save-ifrestrict saving (e.g. main only)
cache-targetsfalse 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.

yaml
      - 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=sccache shares 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.

bash
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
toml
# .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:

bash
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
toml
# 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:

ToolHow it worksToolchainTrade-off
cargo-machetetext search of sources for each dependency namestable, very fastfalse positives for deps used only via macros or renamed; ignore with [package.metadata.cargo-machete] ignored = ["..."]
cargo-shearparses sources to find unused deps (also misplaced dev-deps)stable, fastnewer; similar caveats
cargo-udepsuses compiler output to find deps never linkednightly (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

bash
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 nightly

cargo-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

yaml
  semver:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: obi1kenobi/cargo-semver-checks-action@v2

Also 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 1 gives the graph; tools such as cargo-guppy-based determinator, or cargo hakari's companion tooling, compute affected packages. Then run cargo nextest run -p crate-a -p crate-b. Always keep a full --workspace run on main.
  • 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 main is 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):

bash
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

ToolInstallPurpose
cargo-nextesttaiki-e/install-action with tool: cargo-nextestfast, parallel, sharded tests
cargo-hackcargo install cargo-hack --lockedper-crate feature and MSRV matrices
cargo-denyEmbarkStudios/cargo-deny-action@v2advisories, licenses, bans, sources
cargo-auditcargo install cargo-audit --lockedRustSec advisories only
cargo-machete / cargo-shearcargo install cargo-machete --lockedunused dependencies, stable toolchain
cargo-udepscargo install cargo-udeps --lockedunused dependencies, nightly, precise
cargo-semver-checksobi1kenobi/cargo-semver-checks-action@v2breaking API changes
cargo-msrvcargo install cargo-msrv --lockedfind / verify the MSRV
cargo-hakaricargo install cargo-hakari --lockedworkspace-hack crate for unified features
release-plz / cargo-releasesee publishing referenceversion bumps and ordered publishing
sccachemozilla-actions/sccache-actionshared compilation cache
Swatinem/rust-cacheGitHub Actioncache ~/.cargo and dependency artefacts
More in Rust

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.