The semver promise you might break by accident
You publish version 1.4.0 of your crate. A user adds it to their Cargo.toml with ^1.4.0, which means "any 1.x version that's at least 1.4.0." A few weeks later you push 1.5.0. Cargo updates them. Their build breaks.
What went wrong? You renamed a public function. To you it was a small refactor. To Cargo's resolver, your version number said "non-breaking, safe to upgrade." You broke the social contract that makes the whole crate ecosystem hang together.
This is exactly the kind of mistake that's easy to make and hard to catch by eye. The public API of a real crate has dozens of items: types, traits, methods, generics, bounds, lifetimes. Almost any of them can be a breaking change if you remove or alter them in the wrong way. A function that gains a new parameter? Breaking. A struct field that goes from public to private? Breaking. A trait that adds a method without a default? Breaking.
cargo-semver-checks is a tool that walks your public API, compares it to the last version you published on crates.io, and shouts at you if you broke something. It runs in milliseconds, and it has saved real maintainers from real outages.
How it works under the hood
The tool builds a structured representation of your crate's public surface using rustdoc's JSON output. The same JSON also exists for whatever version of your crate is on crates.io. It then runs a set of declarative lint queries (written in a graph query language called Trustfall) over both representations and compares: was this trait public before? Is it still? Did this enum gain a non-#[non_exhaustive] variant? And so on.
The lints are kept up to date by the project. New ones get added as the community discovers new ways to break semver in surprising ways. When you run the tool, you're getting the collective wisdom of dozens of breaking-change near-misses.
Installing and running
# One-time install. Pick up new lints with `cargo install --force` later.
cargo install cargo-semver-checks --locked
# Run inside any crate that's been published to crates.io.
# It will fetch the previous version automatically and compare.
cargo semver-checks
You can run it without any prior setup. The tool figures out what version to compare against:
- By default, it compares to the latest version on crates.io.
--baseline-version 1.4.0pins the baseline.--baseline-rev <git-ref>compares against a git ref instead of crates.io.--baseline-rustdoc <path-to-json>compares against a rustdoc JSON you produced yourself.
The default is exactly what you want before publishing.
What a clean run looks like
Building rustdoc for current code
Building rustdoc for baseline (my-crate v1.4.0)
Comparing public API
Found 0 semver-major breaks
Done in 4.2s
Done. Ship it.
What a broken run looks like
Suppose you renamed a public function from parse to parse_str. The output looks like:
Comparing public API
--- failure function_missing: pub fn removed or renamed ---
Description:
A public function cannot be removed in a non-major release.
Removed function: my_crate::parse
Failed in:
function my_crate::parse, previously at v1.4.0
Concrete, actionable, and bounded. You don't have to read 50 pages of release notes to figure out what changed. The tool tells you exactly which item disappeared, with a link to the lint's documentation if you want the rationale.
The fix is one of three:
- Restore the old item, possibly forwarding to the new one.
- Plan a
2.0.0release instead of1.5.0. - Mark the change as intentional via a
#[deprecated]cycle.
Most of the time you'll do option 1 or 3.
Wiring it into CI
Running it locally is fine, but you really want it as a guard rail in CI so a careless commit can't sneak through.
# Snippet for GitHub Actions. The same idea works in any CI system.
name: semver
on:
pull_request:
push:
branches: [main]
jobs:
semver:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
# Cache the tool itself so each run doesn't reinstall.
- uses: Swatinem/rust-cache@v2
- run: cargo install cargo-semver-checks --locked
# Fail the job on any semver-major break in the public API.
- run: cargo semver-checks check-release
check-release is the subcommand designed for CI: it picks the right baseline (the most recent crates.io release), runs the comparison, and exits with a non-zero status if anything failed.
What it does and doesn't catch
Caught:
- Removed or renamed public items.
- Public types whose internals became private without
#[non_exhaustive]. - Trait methods added without defaults.
- Public re-exports that disappeared.
- Generic bounds that got more restrictive.
- Many other "the API contract changed" cases.
Not caught:
- Behaviour changes. If your function still has the same signature but now returns wrong answers, the tool will not notice. You still need tests.
- Performance regressions.
- Documentation regressions.
- Changes in unsafe invariants.
This is fine. The point is a fast, mechanical first pass over signatures, not a complete correctness check.
A workflow that actually works
The pattern that pays off in practice:
# Before bumping the version, run semver-checks against crates.io.
cargo semver-checks
# It says "no breaks". Bump to a minor: 1.4.0 -> 1.5.0.
cargo set-version --bump minor
# It says "found breaks". Either undo the breaking change, or bump major.
cargo set-version --bump major
# Either way, publish with confidence.
cargo publish
The cargo set-version subcommand comes from cargo-edit. It updates Cargo.toml and Cargo.lock consistently. Combined with cargo-semver-checks, the version-bump decision becomes mechanical: tool says fine? Minor or patch. Tool says break? Major.
When it gets noisy
A few situations that produce false positives or annoying real positives:
- You moved an internal item but kept a
pub usere-export. The tool may briefly complain about the original location, then accept the re-export. Usually fine. - You're using
#[doc(hidden)]items as a private API. The tool currently considers#[doc(hidden)]items as part of the public surface unless you set them to private. There's an open discussion about this; check the tool's docs. - You ship code that depends on the rust toolchain's
nightlyfeatures.rustdocJSON is unstable on nightly, so the tool occasionally needs a refresh after a toolchain upgrade.
None of these undermine the value. They're things to be aware of when reading the output.