Skip to content
rusthacks.dev
Testing

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.

14 min read
Summarize in AI
Flat illustration for the post “Continuous Integration for Rust Test Format Lint Build”, composed around serde.

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:

src/lib.rsrust
//! 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:

rust-toolchain.tomlTOML
[toolchain]
channel = "1.99.0"
components = ["rustfmt", "clippy"]
Cargo.tomlTOML
[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 =:

Text
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.

Text
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:

Text
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.00s

Two 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:

Text
    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:

.github/workflows/ci.yml (additions)YAML
permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

permissions: 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:

Text
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:

Text
error: failed to parse manifest at `Cargo.toml`

Caused by:
  feature `edition2024` is required

Edition 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:

deny.tomlTOML
[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:

Text
bans ok, licenses ok

This 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:

pipeline.shShell
set -e
cargo fmt --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --locked

I 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:

.github/workflows/ci.ymlYAML
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@v2

Why 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 stable and beta to 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-features and --all-features, and should not commit Cargo.lock to be consumed. An application should commit it and use --locked everywhere, 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.

Taggedci
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.