Skip to content

The Rust Engineering Handbook / Chapter 44

Editions, MSRV, SemVer, and Release Engineering

Evolve Rust language, compiler, API, feature, and dependency contracts without surprising downstream users.

The compat-api release council has four changes on its agenda:

  1. migrate the crate from Rust 2021 to Rust 2024;
  2. replace a hand-written parser with a standard-library API stabilized after Rust 1.85;
  3. add a blanket trait implementation for every T: Display;
  4. expose a dependency’s identifier type instead of the crate’s own RecordId.

Calling all four “modernization” hides four different downstream contracts. The edition is a per-crate language choice designed to interoperate with other editions. The new library API raises the compiler floor. The blanket implementation can overlap with downstream implementations or change method selection. The public dependency type makes another package’s version and type identity part of this crate’s API.

A release decision must classify change across independent dimensions before choosing a version number:

Dimension Governing question Evidence
Edition Which language rules parse and lint this crate? migration diff plus configuration matrix
MSRV What oldest compiler is promised? clean CI on that exact toolchain
Public API Which downstream source and behavior remain valid? representative and adversarial downstream builds
Dependency graph Can compatible versions resolve under the compiler and feature policy? fresh resolution plus locked resolution tests
Artifact/operation Can the accepted source still be packaged, installed, and operated? Chapter 43’s build and artifact evidence

SemVer labels the expected compatibility of a package release. It cannot decide compatibility for you. Rust’s traits, inference, exhaustiveness, features, macros, and dependency type identity create hazards that a simple “removed versus added” diff misses.

Keep the release review in causal order: identify the contract being changed, compile a downstream witness, classify the break under the published policy, and only then choose migration mechanics and a version. The release train later in the chapter operationalizes that sequence; it does not substitute for it.

Editions are local language contracts

Each crate selects an edition in its manifest. Crates from different editions can coexist and interoperate in one dependency graph; an edition does not split the ecosystem or need to match a package’s major version. A workspace can migrate members incrementally, although a coordinated migration may simplify tooling and style.

The migration sequence keeps code compatible with both editions before flipping the manifest:

cargo +1.97.0 update
cargo +1.97.0 test --workspace --all-targets
cargo +1.97.0 fix --edition --workspace --all-targets
# inspect and commit source changes separately
# set edition = "2024" in each intended package
cargo +1.97.0 test --workspace --all-targets
cargo +1.97.0 fmt --all

Do not run this blindly in a dirty worktree. cargo fix edits source. Begin from reviewed, recoverable state; select packages deliberately; inspect every change. The Edition Guide notes that automated migration may not cover doctests, generated code, macros, or every manual semantic concern.

cargo fix --edition runs compatibility lints for the next edition while the manifest still names the old one, applies machine suggestions, and checks the result. It sees one configuration per invocation. Repeat it for meaningful features and targets:

cargo fix --edition --workspace --all-targets --all-features
cargo fix --edition -p compat-api --no-default-features
cargo fix --edition -p platform-crate --target aarch64-unknown-linux-gnu

Generated code and proc macros require their own lane. A generator may emit tokens whose meaning changes under the consuming crate’s edition, while a procedural macro is itself compiled as another crate with its own edition. Exercise checked-in expansions or representative consumers rather than assuming the generator’s tests cover downstream parsing.

Edition migration is normally compatible for dependents because it changes how this crate’s source is interpreted, not the identity of public items. But “normally” is not permission to combine unrelated API changes. Keep migration mechanical where possible, benchmark performance-sensitive changes, run doctests and all supported configurations, and describe any intentional behavior change separately in the changelog.

MSRV is a promise backed by a recurring test

The minimum supported Rust version is the oldest compiler release the project promises can build the supported package configuration. Declare it:

[package]
edition = "2024"
rust-version = "1.85"

Rust 2024 itself requires a sufficiently new compiler, so the edition places a lower bound on a possible MSRV. The rust-version field makes the policy machine-readable and lets Cargo reject an invocation whose compiler is too old. It does not prove the source and full dependency selection work on that compiler.

Test at least two lanes:

  • MSRV lane: exact oldest compiler, declared feature configurations, supported targets where available, docs or examples required by policy, and a dependency resolution compatible with that compiler;
  • current-stable lane: current warnings, lints, tools, all targets, and the broadest supported feature matrix.

Nightly may provide early warning, but it must not redefine a stable/MSRV promise accidentally. Pin the MSRV toolchain exactly; a floating “stable minus N” label can silently move.

The fixture’s workspace uses no registry dependencies so Rust 1.85.0 directly tests its source floor. Real graphs are harder. A dependency can publish a compatible SemVer release that raises its own rust-version. A lockfile created on current stable may then contain a version the MSRV compiler cannot build.

Cargo’s resolver can consider rust-version compatibility, and current resolver/configuration policy offers fallback behavior for incompatible Rust versions. That is selection help, not proof. Metadata may be missing or incorrect; target-specific and optional dependencies may escape the exercised graph; build dependencies and proc macros also need compatible compilers. Test a fresh compatible resolution and the committed lockfile strategy you actually ship.

Library and application policies often differ:

  • a library commonly publishes a version range and tests a representative oldest dependency set because it does not control downstream lockfiles;
  • an application commonly commits Cargo.lock, tests that exact graph, and updates dependencies through a controlled process;
  • a workspace containing both must state which lockfile and resolution evidence supports each released artifact.

When intentionally raising MSRV, treat it as a user-visible compatibility event. The Cargo SemVer guide notes that projects use different policies; some consider an MSRV increase minor within a documented window, others require a major release. Publish the policy before the change, state the new floor in release notes and metadata, and provide the last release line supporting the old compiler. Do not hide the increase in a transitive dependency refresh.

Make dependency selection part of the MSRV lane

A robust CI design answers two different questions. The committed-lock lane asks whether the graph the maintainers reviewed still builds. A fresh-resolution lane asks whether the published version requirements select a graph that honors the compatibility promise today. Run both on a schedule and before release when registry dependencies are involved.

For a library, also test representative minimal dependency versions when the project promises them; Cargo otherwise tends to select the newest compatible releases. Minimal-version testing has ecosystem caveats and may require unstable tooling depending on the exact workflow, so label the method and do not present a green result as a Cargo guarantee. The durable rule is to test the dependency policy users are told they may rely on.

When resolution fails on MSRV, do not immediately pin a transitive crate forever. Determine whether the direct dependency’s range is too broad, its rust-version metadata is absent, the lockfile update crossed a support boundary, or your own policy is inconsistent. Prefer a direct, documented constraint or coordinated upstream fix over an unexplained lockfile incantation. Record who owns revisiting the constraint.

Four independent compatibility rails—edition, MSRV, public API, and dependency graph—must pass one release contract before classification, matrix testing, package audit, publication, and observation. An incident takes a fix-forward path; yanking prevents new selection but does not erase the package.

Figure 44-1. A version number follows compatibility analysis; it does not replace it.

Rust API compatibility is contextual

The Cargo SemVer Compatibility guide is intentionally detailed because Rust changes that look additive can break downstream compilation. Use its classifications as guidance, then test the usage patterns your contract permits.

Trait implementation additions can steal coherence or inference space

Suppose compat-api owns RecordId and adds:

impl<T: std::fmt::Display> From<T> for RecordId {
    fn from(value: T) -> Self {
        Self::parse(&value.to_string()).expect("displayed value must be a valid id")
    }
}

The API looks convenient and additive. It is also broad, semantically dubious, and may overlap with existing or future implementations involving owned types. Even where coherence permits the library’s implementation, method resolution and type inference in downstream generic code may change or become ambiguous. A narrow From<String> or named fallible constructor expresses more and occupies less implementation space.

Adding an implementation can also break a downstream crate that was allowed to implement your trait for its own local type. If your new blanket impl now covers that type, the downstream impl conflicts. Public traits are extension surfaces; reserve blanket-implementation space deliberately and exercise representative downstream impls in compatibility tests.

Adding a required item to a public trait breaks every downstream implementation. A provided method with a default is often source compatible, but can collide with an existing extension-trait method or change call resolution. Adding an associated item can affect dyn compatibility. Review implementors, dyn Trait users, method names, and supertraits separately.

Stronger generic bounds reject callers

Changing:

pub fn persist<S: RecordSink>(sink: &mut S, id: &RecordId) -> Result<(), SinkError>

to require S: RecordSink + Send + 'static is a major compatibility change for callers that use a borrowed or thread-confined sink. The implementation may need Send internally after a refactor, but exporting the new bound transfers that architectural choice to every caller.

Weakening an input bound is usually more permissive; weakening an output guarantee can still break consumers. For example, returning impl Iterator without a promised Send bound does not let a later release remove Send if downstream code legitimately relied on the documented return contract. Write bounds as API promises, not as whatever the current body happens to need.

Enum, struct, and match evolution need escape space

Adding a public enum variant breaks exhaustive matches unless the enum is #[non_exhaustive]. Adding a public field can break construction or pattern matching unless the struct was designed as non-exhaustive or constructed through functions. Removing or renaming items is plainly breaking, but changing types, visibility, auto-trait behavior, constness, unsafe contracts, or panic behavior can be equally material.

#[non_exhaustive] reserves evolution space at a usability cost: downstream code needs wildcard matches or constructors. Apply it when evolution is genuinely part of the contract, not mechanically to every type.

Feature changes alter more than manifest syntax

Features should be additive capabilities. Removing a feature name breaks manifests. Removing an optional dependency can break users who enabled its implicit feature name unless dep: had already hidden that name or a compatibility feature remains. Adding a default feature changes what existing users compile, possibly adding dependencies, MSRV requirements, system libraries, behavior, or build time.

An enabled feature must not invalidate code that worked without it. Test no-default, each supported capability, all-features, and representative unions from Chapter 41. Document deprecated feature names, leave forwarding aliases when feasible, and remove them only under the project’s stated major-version policy.

Public dependency types export type identity

This signature couples users to another crate:

pub fn lookup(id: dependency_crate::RecordId) -> dependency_crate::Record;

If consumers create, store, pattern-match, implement traits for, or exchange those types, the dependency is public in architectural fact whether the manifest has a special annotation or not. Updating across a SemVer-incompatible dependency version can create two nominally similar but incompatible Rust types in one graph.

Choose deliberately among:

  • re-exporting the dependency and promising its version/type surface;
  • wrapping it in an owned domain type such as the fixture’s RecordId;
  • accepting a narrow standard or local trait boundary;
  • serializing across a protocol boundary when process or version independence matters.

Wrappers cost conversions and maintenance but contain version identity. Re-exports reduce friction but make the dependency’s changes part of your compatibility review. Hiding the dependency in Cargo.toml documentation does not hide it from the type system.

Classify with downstream witness programs

API diff tools are valuable inventory, not final judgment. For every proposed change, write the smallest credible downstream program that relied on the old contract. Include implementors, generic callers, exhaustive matches, feature combinations, macro invocations, doctests, and public dependency values as relevant. Compile the same witness against old and new packages.

Use this release classification:

  • patch: compatible bug or documentation correction within stated behavior; no new downstream action expected;
  • minor: backward-compatible capability or deprecation; existing supported downstream source continues to build and behave within contract;
  • major: supported downstream source, type relationships, feature selection, MSRV policy, or documented behavior can require migration, according to the project’s published version policy.

Pre-1.0 Cargo compatibility conventions have different version-compatibility boundaries than post-1.0 packages. Do not assume 0.x means compatibility is irrelevant. State the policy and use Cargo’s requirement semantics accurately.

Keep a small compatibility policy beside the manifest. It should say:

  • current edition and how edition migrations are reviewed;
  • exact MSRV, support window, and whether increases are major or minor;
  • supported targets and feature combinations;
  • whether default features may grow;
  • which traits downstream crates may implement and which blanket-impl space is reserved;
  • which dependency types are intentionally public;
  • deprecation window and removal policy;
  • pre-1.0 versioning convention, if applicable;
  • security-release and yank authority.

This record prevents each release from renegotiating compatibility under deadline. SemVer classification can then refer to an established promise rather than the preferences of whoever is operating the pipeline.

Classify the council’s four changes:

Change Likely classification Required proof
Edition-only migration patch or minor by project policy migration diff; all configs; no API/behavior change
API requiring compiler newer than declared MSRV major or policy-defined MSRV increase new rust-version; MSRV matrix; downstream notice
Broad blanket impl potentially major overlap, inference, method-resolution, and downstream-impl witnesses
Public dependency type replacing owned type major downstream construction/storage/type-identity witnesses and migration

“Likely” matters. The exact answer depends on the existing documented contract and version policy. The rationale belongs in review and changelog, not only in the release operator’s memory.

Deprecation is a migration service

Deprecation should give a downstream engineer enough information to finish the migration:

#[deprecated(
    since = "1.5.0",
    note = "use RecordId::parse; it reports invalid input instead of normalizing it"
)]
pub fn record_id(input: &str) -> RecordId {
    RecordId::parse(input).expect("legacy input contract requires a valid identifier")
}

Name the replacement, semantic difference, version introduced, and removal policy. Keep the old path functional during the announced window. Supply examples or codemods for large changes, and test both old and new paths until removal. A compiler warning without a migration route merely transfers work to users.

Changelogs serve humans making upgrade decisions. For each user-visible change, say who is affected, what breaks or improves, required action, MSRV or feature impact, and security/operational significance. Link to a migration guide for major changes. Generated commit lists can support provenance but do not replace this analysis.

Build a release train that can stop safely

A release pipeline should reproduce review decisions, not invent them. Separate preparation from the irreversible registry action:

  1. Classify. Freeze scope; record API, behavior, feature, MSRV, dependency, unsafe, and operational changes.
  2. Matrix test. Run current stable and MSRV, declared targets, no-default/all/representative features, downstream witnesses, doctests, and migration tests.
  3. Audit the package. Verify version and metadata; inspect cargo package --list; build/test the packaged source; check licenses, generated files, secrets, and included assets.
  4. Reproduce artifacts. Apply Chapter 43’s locked toolchain, clean-build, inspection, and provenance policy where binaries are released.
  5. Prepare release facts. Update changelog and migration notes, tag/signing plan, owners, rollback/fix-forward steps, and monitoring.
  6. Publish once authorized. Use least-privilege credentials, protected automation, environment approval, and registry safeguards. Never publish from an unreviewed developer working tree.
  7. Observe. Confirm registry metadata and documentation, install/consume from a clean project, monitor downstream CI and issue channels, and keep the release owner available.

Automation should fail closed on a dirty tree, mismatched tag/version, uncommitted generated changes, missing MSRV result, package-content drift, or absent authorization. Pin third-party CI actions and isolate registry tokens from untrusted pull-request code. Produce provenance and checksums from the artifacts actually distributed.

Make the approval packet inspectable before credentials become available. It should contain the exact package archive digest and file list, source commit, version/tag, toolchains and targets, resolved dependency graph, MSRV/current results, API-change report plus witness results, changelog/migration text, license and advisory scan, and the identity of the approver. The publish job should consume that packet without rebuilding a subtly different archive.

Release automation must not derive authority from a version-looking Git tag alone. Protect tag creation, require an environment approval or equivalent human gate, scope the registry token to the package when supported, and prevent pull-request code from reaching secrets. Dry-run or package steps may run broadly; the registry mutation belongs in the smallest trusted job.

The fixture supports preparation without publishing:

cargo +1.97.0 metadata --format-version 1 --locked --offline
cargo +1.97.0 package -p compat-api --allow-dirty --locked
cargo +1.85.0 test --workspace --all-targets --locked --offline

--allow-dirty is appropriate only for inspecting this uncommitted teaching fixture. A real release gate should reject dirty state.

Yanking is selection control, not deletion

If a package version is defective after publication, first determine blast radius: affected versions and targets, exploitability or data risk, whether users with existing lockfiles remain exposed, and whether credentials or signing material are involved. Preserve logs and the published artifact.

Yanking tells Cargo not to select that version for new resolutions. Existing lockfiles can continue using it, and the package remains available. Therefore a yank does not recall bytes or repair users. It is useful when a release should not gain new consumers, especially while a fixed version is prepared.

Prefer a fix-forward release when compatibility permits. For a security issue, coordinate an advisory and disclosure timeline; for an API break, restore compatibility in a patch or publish a corrected line; for a bad MSRV declaration, release corrected source/metadata under the registry’s allowed rules and state the actual compiler floor. Notify affected consumers through established channels and keep instructions concrete.

Do not reuse a version number or move a tag. Immutability lets downstream users connect a version to known bytes and review history. If the registry or signing credentials are compromised, rotate and revoke them, audit publication history, and follow registry response procedures rather than treating the event as an ordinary code bug.

After containment, add the missing witness, matrix lane, package audit, or permission boundary. An incident release is complete only when the release system learns why the defective change passed.

Exercise: decide the version before touching it

Level: Compatibility review. For each proposal below, classify it as patch, minor, or major under a policy you state. Where context changes the answer, name the context instead of forcing certainty.

  1. add a defaulted method to a public trait implemented by downstream crates;
  2. add Send to a public function’s generic parameter;
  3. add a variant to a public enum with and without #[non_exhaustive];
  4. add a new default feature that enables TLS and raises one dependency’s MSRV;
  5. remove an optional dependency while retaining an empty deprecated feature of its old name;
  6. upgrade a type appearing in public signatures across a dependency’s incompatible version boundary;
  7. migrate only private implementation source from Edition 2021 to 2024;
  8. raise rust-version from 1.85 to 1.88 under a documented “latest eight stable releases” policy;
  9. add a blanket implementation that overlaps a downstream implementation;
  10. change an error variant’s documented retry classification without changing its shape.

For every answer, deliver:

  • old contract and proposed contract;
  • at least one downstream witness program or behavioral test;
  • edition, MSRV, feature, target, and dependency configurations affected;
  • version rationale and changelog entry;
  • deprecation or migration path where needed;
  • release-matrix additions and package audit;
  • incident plan if the classification proves wrong after publication.

The exercise succeeds when another maintainer can disagree with a classification by pointing to an explicit contract or witness—not by appealing to intuition about whether the diff “looks small.”

Compatibility is a maintained system

Edition, compiler floor, package version, API surface, features, and resolved dependencies are coupled in a release, but they are not the same axis. Migrate editions configuration by configuration. Declare and continuously test MSRV. Reserve trait, generic, enum, and feature evolution space. Own any dependency type that crosses the public boundary. Use downstream witnesses to classify changes, and make deprecation, package inspection, release observation, yanking, and fix-forward response part of normal engineering.

The workspace now has a governed dependency graph, target and feature policy, constrained build-time authority, identifiable artifacts, and a compatibility-aware release train. The next part can design public APIs on top of those foundations, beginning with the ownership choices exposed in function and type signatures.

Sources and verification notes

  • Rust Edition Guide: What are editions?, Transitioning an existing project, and Advanced migrations.
  • Cargo Reference: Rust version, Dependency resolution, SemVer compatibility, Features, and Publishing; Cargo command reference: cargo yank.
  • Executable source: examples/rust-engineering-handbook/part-07/release-build-lab/, including rust-version = "1.85", compat-api, a downstream trait implementation, profile builds, metadata/package inspection, and current-stable/MSRV commands.
  • Edition 2024 source, public/downstream compilation, feature behavior, package contents, metadata, and MSRV are verified with Rust/Cargo 1.97.0 and 1.85.0. Those checks establish the fixture’s compiler and package behavior; SemVer classification remains contextual review guidance, as the official Cargo reference states.