"Small" semver-breaking changes

Semver-major version bump does 3 things:

  1. Prevents automatic updates
  2. Allows multiple copies of the crate in the dependency tree
  3. Makes types from each major version incompatible

For big breaking changes, like major API redesigns, the combo of all (1+2+3) features is usually very useful and desirable.

However, for small breaking changes that are technically-breaking-but-not-really (bug fixes with side effects, API changes that are mostly backwards-compatible except rare cases) only the first point is useful to prevent surprises, but (2) and (3) are unnecessary and may be even quite undesirable for crates that have something "global", like shared types for interoperability between crates, manage global process state, or use the link attribute.

Just reviewing compatibility of a dependency and bumping the version could be something that every user of the crate can do individually on their own schedule. However, when semver-major also makes APIs and types of the old and new versions incompatible, that creates a network effect that forces all users to coordinate the upgrade, or else applications may end up with multiple copies of the crate and type errors, conflicts or other side effects of that.

I wonder if there's a room for some Cargo feature for "small" breaking changes. Something that requires crates to opt-in to a technically-breaking update (1), but that doesn't break compatibility between new and old version (2+3).

2 Likes

One hack is to use tilde requirements to pin the minor version, but that's a user action, not something you can affect as the library author apart from suggesting this.

Indeed. And I don't have this possibility for 0.x versions.

Two possible solutions come to my mind. First one:

New and old version are within the same semver-major, but the new one has a flag that means "don't auto-upgrade me from this old version":

[package]
version = "1.2.0"
not-compatible-with = "<= 1.1.0"

So if user specified foo = "1", they'll get 1.1.0, but not 1.2.0. If user specified foo = "1.2", then they get 1.2.0. I'm not sure how to deal with conflicts in the dependency tree — choose newer version and warn?

Second option:

Allow versions to differ by semver-major, but let new version to opt-in to being treated as an older semver-major.

[package]
version = "2.0.0"
allow-downgrade-to = ">= 1.0.0"

With this, if there are any crates in the dependency tree that still require 1.x, then that version is chosen, even for crates that want 2.x! When all crates in the dependency tree want 2.x, then 2.x can be used.

It should probably be mentioned that, iiuc, the dtolnay semver trick (i.e., making v1 of your library reexport v2’s types) already allows you to control on a per-type basis how much consequence #3 actually impacts your library. I’m not sure any novel cargo feature could compete with that.

Consequence #2 mixed with some of the other features you mentioned, like the link attribute, does seem like an unsolved problem though. My knee jerk thought is that a crate which runs into this conflict may be better off with the “global thing” in its own tinier crate. We might need a concrete example to dig into.

3 Likes

The semver-trick is cool when it works, but it's limited. It can't help with breaking changes in inherent impls. It can't help making changes to the type definition itself. Orphan rules may also get in the way.