Skip to content
rusthacks.dev

Semver Checks for Libraries With Cargo Semver Checks

cargo-semver-checks compares your public API with the last release and fails when a change needs a higher version number. Install it, run it in CI and learn what it catches and what it cannot.

6 min read
Summarize in AI
Flat illustration for the post “Semver Checks for Libraries With Cargo Semver Checks”, composed around ownership.

Semver in Rust is a promise about the compiler, not about behavior: if your code compiled against 1.4.0, it should still compile against 1.5.0. Humans are bad at checking that promise by eye. The change that breaks it is rarely a deleted function; it is a new variant on a public enum, an added trait method with no default, or a generic parameter that went from T: Clone to T: Clone + Send. cargo semver-checks automates the check. It is the single highest-value tool for a library maintainer because the mistake it prevents lands on other people. This post shows it catching a real break and missing another, how to read its report, how to wire it into CI, and where it stops. Written against Rust 1.99 and cargo-semver-checks 0.51.

How it works

The tool asks rustdoc to emit a JSON description of a crate's public API, a feature that lives behind a nightly flag but is wrapped by the tool so you do not need nightly installed. It does this twice: once for the baseline version (by default the latest release on crates.io, downloaded into a temporary directory) and once for your working copy. A set of queries then looks for differences that count as breaking and prints the ones it finds, grouped by lint name, with the minimum release type that would make the change legal.

Shell
cargo install cargo-semver-checks --locked
cargo semver-checks
cargo semver-checks --baseline-rev v1.4.0
cargo semver-checks --release-type minor

The default compares against the most recent registry release. --baseline-rev compares against a git tag or commit, which is useful for crates that have not been published yet or for unreleased branches. --release-type says what you intend to ship, and the tool then only complains about changes that exceed it: if you plan a minor release, a major-level break is an error. Without it, the tool uses the version in your manifest compared against the baseline's to infer the intent.

Watching it catch a break

Start with a library at version 1.4.0 that exposes a small enum and a function:

src/lib.rsrust
/// How a request should be retried.
pub enum Retry {
    Never,
    Fixed(u32),
}

pub fn describe(r: &Retry) -> &'static str {
    match r {
        Retry::Never => "never",
        Retry::Fixed(_) => "fixed",
    }
}

pub fn clamp_attempts(n: u32) -> u32 {
    n.min(10)
}

After publishing it, you decide to add a third strategy, change the type of clamp_attempts, and tidy:

src/lib.rsrust
pub enum Retry {
    Never,
    Fixed(u32),
    Exponential { base_ms: u64 },
}

pub fn describe(r: &Retry) -> &'static str {
    match r {
        Retry::Never => "never",
        Retry::Fixed(_) => "fixed",
        Retry::Exponential { .. } => "exponential",
    }
}

pub fn clamp_attempts(n: u64) -> u64 {
    n.min(10)
}

Each of those edits looks harmless in a diff. Bump the version to a patch release, commit nothing, and run the tool against the tag for 0.1.0:

Text
    Checking retry v0.1.0 -> v0.1.1 (minor change)
     Checked [   0.009s] 202 checks: 201 pass, 1 fail, 0 warn, 58 skip

--- failure enum_variant_added: enum variant added on exhaustive enum ---

Description:
A publicly-visible enum without #[non_exhaustive] has a new variant.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.51.0/src/lints/enum_variant_added.ron

Failed in:
  variant Retry:Exponential in src/lib.rs:4

     Summary semver requires new major version: 1 major and 0 minor checks failed

That is the output from cargo-semver-checks 0.51 on this exact pair of files, run with --baseline-rev v0.1.0 against a git tag. Each failure has a lint name, a description, a link to the Cargo semver reference and the location. The summary line is the useful part: it tells you the smallest bump that makes the diff legal. The new variant breaks any downstream match that listed every variant, so the tool demands a major version, and the 0.1.1 in the manifest is wrong.

Look closely, though, at what is missing. The edit also changed clamp_attempts from u32 to u64, which breaks every caller that passes a u32 variable, and the report does not mention it: 201 checks passed. The tool's lint list grows with each release, but at this version a changed parameter type is not on it. That is the honest limit of any lint set. A clean run means none of the lints it knows fired, so read the public signatures in the diff yourself as well.

Fix it the cheap way

Both breaks have non-breaking spellings, and finding them is the real value. The enum one is flagged by the tool; the signature one you must catch yourself. Mark enums you expect to grow as #[non_exhaustive] from the start, so adding a variant later is a minor change:

src/lib.rsrust
#[non_exhaustive]
pub enum Retry {
    Never,
    Fixed(u32),
}

The cost is that callers outside your crate must add a wildcard arm, which is the point: they agree to handle variants they do not know. For the function, add a new name and keep the old one, or make the parameter generic with a bound the old type satisfies:

src/lib.rsrust
pub fn clamp_attempts(n: u32) -> u32 {
    n.min(10)
}

pub fn clamp_attempts_u64(n: u64) -> u64 {
    n.min(10)
}

Adding is always safe; changing and removing are not. If the old name should go, mark it #[deprecated(note = "use clamp_attempts_u64")] and remove it in the next major version. What counts as a breaking change catalogues the cases the tool knows and a few it does not.

Run it where it counts

.github/workflows/semver.ymlYAML
name: semver
on: [pull_request]
jobs:
  semver:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: obi1kenobi/cargo-semver-checks-action@v2

Run it on every pull request for a published library, not just at release time, so the author learns about the break while the change is fresh. Running it only at release means the person cutting the release discovers a four-week-old decision. If you use release-plz it runs the same check automatically and picks the version bump for you; see release-plz and cargo release compared. Feature flags matter: check with the feature set your users enable, using --all-features or --features, and combine it with the matrix ideas from testing feature combinations, because an API gated behind a feature can break while the default build looks fine.

Review the signature diff yourself

Because no lint set is complete, a ten-second manual pass over the public API diff is worth doing before every release. Rustdoc can print the API surface, and a plain text diff of two runs shows what changed:

Shell
cargo install cargo-public-api --locked   # needs a nightly toolchain installed for rustdoc JSON
cargo public-api diff latest..HEAD

The output lists each added, changed and removed public item with its full signature, including generic bounds and the where clauses that are easy to miss in a code review. Read the "changed" section line by line and ask of each, "could existing callers still compile?" A parameter type change, a stricter bound or a return type that no longer coerces all show up there even when a semver lint does not exist for them yet. Use the two tools together: cargo semver-checks as the automatic gate that every pull request must pass, and the public-API diff as the human review attached to the release pull request.

What it cannot see

The tool compares type-level API shapes. It does not run code, so it misses every behavioural change: a function that used to return an empty list and now returns an error, a parser that begins rejecting input it accepted, a stricter validation, a performance cliff. These break users as surely as a removed function, and need a human to decide the bump. It also does not cover every language-level break: some trait-coherence and inference changes depend on code outside your crate, which no analysis of your crate alone can prove safe. And it needs a baseline, so for the first release it has nothing to compare against. Treat a clean report as "no mechanical break found", write behavioural changes into the changelog yourself, and keep a test for the public contract of each function you care about.

Also inTesting
Taggedsemver
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.