Skip to content
← All articles

Workspaces, publishing, MSRV and docs.rs

Every non-trivial project ends up a workspace, and publishing is where the version, feature and SemVer rules are finally enforced — partly by tooling, mostly by convention.

This is the “you are now a member of the ecosystem” item. Nothing here can be run on this site — there is no cargo, no network and no second package — but everything here is where the last four items stop being theory.

Workspaces

# Cargo.toml at the repository root
[workspace]
resolver = "3"
members = ["crates/*"]
exclude = ["crates/experimental"]
default-members = ["crates/cli"]

A root manifest has a [workspace] table alongside its own [package]. A virtual manifest has [workspace] and no [package] at all — the root is just an organiser, which is usually what you want.

What the members share:

  • one Cargo.lock, so every member resolves the same versions,
  • one target/, so a dependency compiled for one member is not recompiled for the next,
  • one dependency resolution, which means feature unification (10.16) happens across the whole workspace.

What is root-only, and this catches people:

[patch], [replace] and [profile.*] are honoured only in the workspace root. A [profile.release] in a member’s manifest is silently ignored with a warning, and people lose hours wondering why their opt-level setting does nothing.

The inheritance tables

Recent enough that older tutorials still duplicate metadata across a dozen manifests:

# root
[workspace.package]
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.85"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }

[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
# crates/core/Cargo.toml
[package]
name = "thing-core"
version.workspace = true
edition.workspace = true
license.workspace = true

[dependencies]
serde.workspace = true

[lints]
workspace = true

Version bumps become one edit instead of twelve, and a dependency can only be at one version across the repo because there is only one place to write it.

💡Somebody splits a 40-second crate into six workspace members and reports that the clean build still takes 40 seconds. Were they lied to? click to reveal

Partly. The claim that gets repeated is “splitting a crate improves compile times”, and the accurate claim is narrower:

Splitting a crate does not by itself make a clean build faster. It makes incremental rebuilds more granular.

A clean build still has to compile all the same code, and it now has crate boundaries that constrain how much can happen in parallel and prevent some cross-crate inlining. What changes is that editing one member only recompiles that member and its dependents, instead of the whole thing. If most of your time goes on repeated edit-rebuild cycles in one area, that is a large win; if you mostly do clean CI builds, it is close to zero.

The other real benefits are architectural — enforced layering, since a member can only use what its dependencies expose — and those are usually the better reason to split.

Publishing

cargo publish needs name, version, description and a license field. Run cargo publish --dry-run and cargo package --list first, every time.

Three facts about crates.io that shape everything else:

Publishing is immutable. A published version can never be changed or deleted. Not a policy you can appeal — the whole ecosystem’s reproducibility depends on it.

Yanking is not deletion. cargo yank --version 0.3.1 stops new resolutions from picking that version. It does not remove the files, does not break existing Cargo.lock files, and anybody who has it pinned keeps building. Yank a version that shipped a bug; do not expect it to un-ship it.

You cannot publish a path-only dependency. A member that depends on { path = "../core" } has nothing crates.io can resolve. The workaround is the dual form:

thing-core = { path = "../core", version = "0.4.0" }

Local builds use the path, published builds use the version. Every workspace that publishes more than one crate does this.

publish = false in a member’s [package] marks it as never-publishable, which is the right setting for internal test-support crates.

MSRV in practice

rust-version is now enforced by the resolver rather than merely documented: the MSRV-aware resolver (resolver 3, implied by edition 2024) prefers older dependency versions over ones your declared toolchain cannot compile.

Raising your MSRV is treated as a minor bump by convention. It also breaks real users on pinned toolchains, and the community has never fully agreed about this. The current compromise is: declare rust-version honestly, treat raising it as a notable change worth a changelog entry, and let the resolver do what it can for people who cannot upgrade.

docs.rs

Docs are built automatically on publish, for every version, in a sandbox with no network. Two knobs matter:

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub async fn fetch() { … }

Without that, a feature-gated item either does not appear at all or appears with no indication that it needs a feature — and your users file an issue saying the function does not exist. With it, docs.rs renders a little “Available on feature async only” badge.

cargo-semver-checks, and its limits

cargo semver-checks compares your working copy against the last published version and reports breakages, using the rules from 10.17. It is excellent and you should run it in CI.

It also cannot see:

  • auto-trait changes. Adding an Rc to a private field can silently make your type not Send, breaking every downstream thread::spawn. There is no syntax in the diff to point at.
  • behavioural changes. A function that starts returning None in a case where it used to return Some compiles fine everywhere and breaks everything.
  • Drop semantics. Adding a Drop impl changes when things happen and can even make previously-valid code fail to compile.

So: a mechanical guard that catches many breakages, not all of them. The rules in 10.17 are still something you have to know, because the tool cannot know what your library means.