Continuous Integration for Rust Test Format Lint Build
A good Rust pipeline runs the same few checks in the same order every time. Format, clippy, tests, docs and a cache cover most projects in about forty lines of YAML.

A Rust continuous integration pipeline is a short list of commands that must pass before code merges, run in the same order on every push. The commands are not the hard part; any team can write cargo test into a workflow file. The hard part is choosing the set that catches real problems without becoming a twenty-minute wait that people learn to ignore, and knowing which failure each step exists to catch. This guide builds that pipeline around a small crate: formatting, clippy, tests, documentation, a minimum supported Rust version (MSRV) check and a dependency policy check. Every step was run locally, a failure was provoked on purpose for each one, and the real output is shown, so you know what a red build will look like before you meet one. The toolchain is rustc 1.99.0, cargo-deny 0.20.2, edition 2024, on an Apple M1 Max. The YAML for GitHub Actions at the end was not run on GitHub's servers; the commands inside it were all run here, and the guide says where that distinction matters.
The crate under test
The subject is a tiny library that counts words. It has a unit test, a doctest, and a documented public function, which is enough to give every check something to do:
//! Word frequency counting.
use std::collections::HashMap;
/// Counts how often each lowercase word appears.
///
/// ```
/// let counts = ci::count_words("the cat the hat");
/// assert_eq!(counts["the"], 2);
/// ```
pub fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
counts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_case_insensitively() {
let c = count_words("Go go GO");
assert_eq!(c["go"], 3);
}
}Two files outside the source pin the environment. A rust-toolchain.toml names the compiler and its components, so the version on a laptop and the version in CI cannot drift apart. And the manifest declares rust-version, the oldest compiler the crate promises to support:
[toolchain]
channel = "1.99.0"
components = ["rustfmt", "clippy"][package]
name = "ci"
version = "0.1.0"
edition = "2024"
license = "MIT"
rust-version = "1.85"
[dependencies]
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"Pinning the toolchain to an exact version is the single most useful decision in the file. A pipeline on stable fails on a day nobody touched the code, because a new compiler release added a lint. That failure is real information, but it should arrive as a deliberate update pull request, not as a surprise on someone else's unrelated change.
Step one: formatting
cargo fmt --check exits non-zero and prints a diff if any file differs from rustfmt's output. It changes nothing, which is why it belongs in CI; the fix is for a developer to run cargo fmt locally. To see what the failure looks like I removed the spaces around one =:
Diff in src/lib.rs:9:
/// assert_eq!(counts["the"], 2);
/// ```
pub fn count_words(text: &str) -> HashMap<String, usize> {
- let mut counts=HashMap::new();
+ let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}The exit code was 1. Run this first. It takes under a second, it needs no compilation, and a formatting failure should never cost a contributor a full build before they learn about it. The same ordering logic applies to the rest of the list: cheapest and most likely to fail goes first.
Step two: clippy as errors
Clippy catches code that compiles but is probably not what you meant. In CI it should fail the build, otherwise warnings accumulate until nobody reads them. The flags matter. --all-targets includes tests, examples and benches, which a plain cargo clippy skips. -- -D warnings promotes every warning to an error. I introduced a classic: calling .into_iter() on something that is already an iterator.
error: useless conversion to the same type: `std::str::SplitWhitespace<'_>`
--> src/lib.rs:13:17
|
13 | for word in text.split_whitespace().into_iter() {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: consider removing `.into_iter()`: `text.split_whitespace()`
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#useless_conversion
= note: `-D clippy::useless-conversion` implied by `-D warnings`
= help: to override `-D warnings` add `#[allow(clippy::useless_conversion)]`
error: could not compile `ci` (lib test) due to 1 previous error
A note on the pinned toolchain and clippy: the help link in that message points at rust-1.99.0 documentation, because the lint list is versioned with the compiler. Another reason to pin. The output above is trimmed to the first error; clippy printed a second, identical one for the library build.
Step three: tests, including doctests
cargo test runs unit tests, integration tests and doctests in one command, which is why nobody should replace it with a custom script. Doctests are the part teams forget: they compile your documentation examples as tiny programs, so a README-style example that no longer builds fails here. I changed the expected count in the unit test from 3 to 2:
failures:
---- tests::counts_case_insensitively stdout ----
thread 'tests::counts_case_insensitively' panicked at src/lib.rs:26:9:
assertion `left == right` failed
left: 3
right: 2
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::counts_case_insensitively
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sTwo flags are worth adding in CI. --locked makes cargo fail if Cargo.lock would need to change, which catches a dependency edit committed without its lockfile update. And for a library crate you also want cargo test --no-default-features and --all-features runs if the crate has features, because the default feature set is the one combination nobody forgets to test.
Why --locked earns its place
The --locked flag deserves a demonstration, because the failure is quiet in its absence. I added a new line to the dependency table, fastrand = "2", without regenerating the lockfile, then ran the tests the way CI would:
Updating crates.io index
error: cannot update the lock file Cargo.lock because --locked was passed to prevent this
help: to generate the lock file without accessing the network, remove the --locked flag and use --offline instead.Without the flag, cargo would have resolved the new crate on the build machine, written a lockfile nobody committed, and gone green. The next developer to pull the branch would then resolve differently. With the flag, the pull request fails until the manifest and the lockfile agree. My first attempt at this experiment, adding itoa, did not fail, and the reason is a useful caution: itoa was already in the lockfile as a dependency of serde_json, so the lock did not need to change. The flag only objects when resolution would produce a different file.
What the cache holds, and what it cannot
After the runs above the target directory of this tiny crate was 79 MB. Almost all of it is the compiled output of serde, serde_json, syn, quote and proc-macro2, none of which changed between runs. That is what a CI cache restores, and it is why the 6.47 second cold run collapsed to 0.74 seconds on the second run. Three habits keep the cache honest:
- Key it on the lockfile and the compiler. A cache saved under one compiler version is useless under another, because compiled artefacts are not portable across versions. This is a second reason to pin the toolchain.
- Do not cache your own crates. Workspace members change on every commit, so restoring them only grows the archive. The cache action named in the workflow prunes them for that reason.
- Keep the flags identical across jobs. Cargo fingerprints the flags that built each artefact. A job that sets
RUSTFLAGS="-D warnings"and a job that does not will not share compiled dependencies, so a mismatch silently halves the benefit of the cache.
The cache has one limit that no setting fixes: the first build after the lockfile changes is cold. A pull request that updates a core dependency pays the full price once, and every later run is warm again. Plan around it and do not read one slow run as a regression.
Making the workflow itself safe
A workflow file is code that runs with credentials, and a pipeline for an open-source crate runs on pull requests from strangers. The YAML above is the functional part; these changes harden it, and none of them alter what the checks do:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: truepermissions: contents: read reduces the default token to read-only, so a compromised step cannot push code or edit releases. concurrency with cancel-in-progress stops the previous run when a new commit arrives on the same branch, which saves minutes on a busy pull request. For stricter supply-chain hygiene, pin third-party actions to a full commit SHA rather than a tag, since a tag can be moved. Only publish jobs should ever see a secret; a test job needs none, and a pull request from a fork does not receive them at all.
Reading a red build
The value of a pipeline is decided on the day it fails, so shape the output for that day. Keep each check as its own step with its own name, so the first red line in the summary says "clippy" or "docs" without anyone opening a log. Print the diff for formatting (the default does). Keep test output on failure only; the standard harness already hides the output of passing tests. And resist the pull to put every command into one make ci line: a single step that does seven things fails with seven possible meanings. Separate steps cost nothing in time because they share the same machine and the same cache.
When a check fails only in CI, the first suspects are always the same three: a different compiler version (check the pin), a different feature set (check --all-features and the default features), and a different operating system. A pipeline that logs rustc --version --verbose and the exact test command at the top of the job makes the first of those a ten-second check. Add that line; it is one line of YAML and it ends more arguments than any other.
Step four: documentation that builds clean
Broken intra-doc links are silent in a normal build and show up as dead text on docs.rs. cargo doc with RUSTDOCFLAGS="-D warnings" turns them into failures. --no-deps skips documenting every dependency, which is slow and not your problem. I linked to a function that does not exist:
error: unresolved link to `missing_fn`
--> src/lib.rs:5:57
|
5 | /// Counts how often each lowercase word appears, see [`missing_fn`].
| ^^^^^^^^^^ no item named `missing_fn` in scope
|
= note: `-D rustdoc::broken-intra-doc-links` implied by `-D warnings`
= help: to override `-D warnings` add `#[allow(rustdoc::broken_intra_doc_links)]`
error: could not document `ci`Step five: the minimum supported Rust version
If the manifest says rust-version = "1.85", something must check it, or the number is a guess. The check is to build with exactly that compiler. The first instinct, to set the MSRV to whatever is oldest that still works, runs into a floor set by the language edition. I tried 1.84.0 on this crate:
error: failed to parse manifest at `Cargo.toml`
Caused by:
feature `edition2024` is requiredEdition 2024 shipped in 1.85, so no compiler older than that can read the manifest, and 1.85 is the honest floor for this crate. With cargo +1.85.0 check the build finished cleanly in 5.49 seconds on a cold target directory. In CI, install that exact toolchain in a separate job and run cargo check --locked --all-features with it. Use check, not test: dev-dependencies are allowed to need newer compilers than the library itself, and a compile check is enough to prove the promise you made to users. Raising the MSRV is a normal, deliberate change, but it should be a change someone made on purpose, which is what the job enforces.
Step six: dependency policy with cargo-deny
Everything so far checks your code. cargo deny checks what you depend on: licences, known security advisories, banned crates and duplicate versions. A minimal deny.toml that allows only two licences looks reasonable and fails on the first run:
[licenses]
allow = ["MIT", "Apache-2.0", "Unicode-3.0"]
[bans]
multiple-versions = "warn"With only MIT and Apache-2.0 on the list, cargo deny check licenses bans printed bans ok, licenses FAILED. One cause was my own crate: it had no license field, and cargo-deny reports "ci = 0.1.0 is unlicensed" as an error. The other was a transitive dependency of serde under Unicode-3.0, which I had not allowed. After adding the field and the licence to the list:
bans ok, licenses okThis is the most useful lesson from the step: a policy you write from memory is wrong the first time you run it, and the failure tells you something about your real dependency tree. Run it once locally, fix the list, then commit it. The advisory check (cargo deny check advisories) needs to fetch the RustSec database, so it is the one check that fails for network reasons, not code reasons; schedule it nightly in addition to running it on pull requests, because a new advisory can appear against code that has not changed.
Putting the order together
The local version of the whole pipeline is a short script. Order is by cost and by likelihood of failure:
set -e
cargo fmt --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --lockedI timed it twice on this crate, after adding serde and serde_json as dependencies. With an empty target directory the whole script took 6.47 seconds. Run again immediately, with the build artefacts in place, it took 0.74 seconds. The gap between those two numbers is the entire argument for caching in CI: almost all of a Rust pipeline's time is compiling dependencies that did not change. The crate here is tiny, so the cold run is short; on a project with a few hundred crates the same ratio turns six seconds into ten minutes.
The workflow file
This is the GitHub Actions version. I did not run it on GitHub; each command in it is one I ran above, and the structure follows the actions' documented inputs. The decisions to notice are the pinned toolchain, one cache action, a separate MSRV job and --locked everywhere:
name: ci
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.99.0
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
- run: cargo doc --no-deps --locked
env:
RUSTDOCFLAGS: "-D warnings"
msrv:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.85.0
- uses: Swatinem/rust-cache@v2
- run: cargo check --locked --all-features
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2Why each choice. RUSTFLAGS: "-D warnings" at the job level makes the compiler's own warnings fatal, so an unused import fails the build the same way a clippy lint does. The cache action keys on the lockfile and the compiler version, restoring ~/.cargo and target, and it prunes workspace crates so the cache does not grow without bound. Running msrv and deny as separate jobs lets them run in parallel with the main job instead of extending it, and gives each a clear name when it goes red. One more change to avoid is putting cargo build --release in the pull-request path; a release build is for the deploy job, and it doubles the wall time without testing anything the debug build did not.
Run the same thing before you push
A pipeline that only runs in the cloud trains people to push and wait. The commands in this guide are all plain cargo, so the cheapest improvement is to make the local and remote versions identical. Keep the four-line script from earlier in the repository and call it from both the workflow and a git pre-push hook, or from a task runner target everyone knows. The point is not enforcement. It is that a contributor who sees a red clippy error on their own machine, in under a second on a warm build, fixes it before it becomes a review comment, and a pull request that arrives already green spends its reviewers' attention on the design instead of on whitespace. When the local script and the workflow ever disagree, treat that as a bug in one of them, because the script is the definition of passing and the workflow is only the place it is enforced.
Where this pipeline stops
A pipeline of these six steps is a floor, not a ceiling, and some advice does not fit every project:
- Passing is not coverage. The steps prove the code compiles, lints clean and passes the tests it has. They say nothing about the tests it lacks. Add coverage reporting or mutation testing if you need that answer, and treat the percentage as a question, not a target.
- Pinning the compiler is a trade. You get reproducible builds and you accept the chore of bumping the version. Some teams run a second, scheduled job on
stableandbetato see new lints coming; that job should be allowed to fail without blocking merges. - Applications and libraries differ. A library should test with
--no-default-featuresand--all-features, and should not commitCargo.lockto be consumed. An application should commit it and use--lockedeverywhere, which is why the script above uses it. - One operating system is a choice. If your code reads paths, spawns processes or uses sockets, a Linux-only pipeline will not catch a Windows or macOS failure. Add a matrix, and pay for it in time.
- Fast checks first, but not only fast checks. Failing fast on formatting is good; it is not a reason to skip the slow integration tests, which belong in the pipeline even if they run last.
The measurements that justify the shape are the ones above: formatting fails in under a second with a readable diff, a clippy error tells you the lint and the replacement, a failing test prints both values, a broken doc link names the missing item, and a pinned MSRV makes a language-edition floor visible instead of a surprise. The cold run of 6.47 seconds against a warm run of 0.74 shows why the cache step pays for itself, and the failed licence check on the first attempt shows why the dependency policy should be run on your real tree before you commit it.


