The faults in our SemVer
Tue Jun 18 2024Semantic Versioning (SemVer for short) is a versioning convention that has gained much traction over the recent years, notably thanks to its integration within npm
and cargo
.
While it is a serious step up from CalVer1, and that I prefer it to most other version numbering systems I've encountered, I still have quite a few gripes with it; to the point that I've tried to come up with a few compatible systems that attempt to fix the things I dislike about it.
This post is focused on summarizing SemVer's shortcomings, so that later posts explaining alternatives can focus on that rather than the ranting.
A recap of SemVer
The way SemVer works is by defining versions as a set of 3 integers, generally formatted MAJOR.MINOR.PATCH
. While its rules are often described as a set of "when you do X, you should increment this integer to be SemVer compliant".
I find this way of describing SemVer to be counter-productive. Instead, let's focus on the spirit of SemVer; by looking at it not from the author's perspective, but from the API consumer's:
- Any two versions of a software package with a common
MAJOR
andMINOR
MUST be entirely compatible. - Any version with a given
MAJOR
MUST be compatible with all of its predecessors.
These rules are simple in concept, and easy for package consumers (be they developers or automated tools). However, they can get pretty nerve wracking for producers (also developers).
As a disclaimer, I'll state that while I have gripes with SemVer, which I'll expand on for the rest of this post; it's still "the worst form of [version numbering], except for all the others" in my opinion: it just needs a few touch ups now that we've learned on the field.
The problems with SemVer (or how we're using it)
Note that a lot of these gripes are not necessarily due to semver.org's intent. However, I do find that the wording in that document is often counter-productive by being overly prescriptive; and I personally find their stance the whole "where does an OSS developer's responsibility start and end for work that they are making available for free" debate too extreme, but I'll try to avoid that subject so as to not detract from the point.
The 1.0.0
falacy and the 0.x.y
trap
SemVer explicitly encourages you to stay in 0.x.y
until you've defined and stabilized your project's API; waiting until your API is defined and stable to declare 1.0.0
.
While the spirit behind this is to provide projects with a grace period in which everything flies, what this creates instead is a "fear" of getting to MAJOR=1
: "Once I hit 1.0.0
, I'll be stuck with the choices I've made until now... Am I really ready for that?".
In turn, this is very observable when looking at popular projects on crates.io: of the top 10 most downloaded crates, 4 are still on 0.x.y
despite their API having been notoriously stable:
rand
hasn't received a new version for over 2 years!hashbrown
is part of the standard library, and as such is straight up forbidden from making changes that would force aMAJOR
change, lest it gets demoted from that position as "Rust's standard HashMap"...libc
has been planning0.3.0
release... for more than a year!
You'll notice when looking in closer at rand
and libc
that these projects do intend on making some breaking changes, some of which are already in the pipeline. But all of these projects are factually stable considering they're being dependended on by most of the rest of the Rust ecosystem!
And here lies the greatest abheration about SemVer: you're not deemed "stable" or "ready to use" before 1.0.0
2, but you shouldn't go to 1.0.0
until your API is stable.
The MAJOR
bump phobia
The problems don't stop once you reach 1.0.0
: MAJOR
bumps scare developers and users alike!
Here's a little anecdote highlighting and exaggerating an issue I have observed a few times:
- The devs push from
2.3.0
to3.0.0
, reshaping the entire API. - The users look at the scale of this update in horror, having to pick between staying on the now unsupported
MAJOR=2
branch; or dedicate time to upgrading toMAJOR=3
, possibly delaying other aspects of their project. - The dev look at the chaos this caused in their community and over-correct their course: swearing to themselves never to up the
MAJOR
again. - A year later, newcomers ask if they can make a small breaking change in order to gain performance: they are instead informed that management dislikes
MAJOR
upgrades for fear of losing customers, as they had been very negative about the lastMAJOR
bump.
And just like that, the project's future innovations are stunted, doomed to carry around any tech debt that slipped through.
Multi-package projects
Some projects encompass multiple packages: bindings in various languages, plugins, associated custom debug tools...
Most of these projects then opt to keep versions synchronized between these packages to make their cross-compatibility more readable. This also helps mutualize documentation between bindings by being able to make sweeping statements about a given version.
This leads to an additional conundrum: is a breaking change in one of the packages desirable enough that all other packages would see their version bumped as well?
What even is compatibility
SemVer decrees that your version should be guided by what you define as your public API. However, the classical definition of API is not enough to encompass what some projects may define as their API, or even what users expect the projects treat as such:
Hyrum's Law at work
- Invariants: you can keep a public API identical in every regard but for a few invariants that need to be upheld. Does changing these invariants constitute an API break? (YES)
- IO protocols: can the user expect two SemVer-compatible version of a package to be able to communicate with each other on the network? Or to load each-other's save/config files?
- ABI: can the user expect that swapping out a package's binary for one of a SemVer-compatible version would work? While this question is irrelevant to a lot of languages, it is relevant to Rust.
- Side effects: every now and then, you hear of "that client" who parses your internal
debug
in a background thread to raise events based on them.
SemVer is often assumed...
One of SemVer's greatest boons to us is how tooling can use it to do smart stuff around versions, allowing them to have to think less about them3.
The value of this boon can be argued on grounds of "this opens you up to low-visibility changes in your program", increasing your vulnerability to supply-chain attack.
But even without malicious intent, this boon can easily become a curse if a dependency fails to uphold the SemVer contract they tacitly agree to by publishing themselves on SemVer-based distribution systems.
...but rarely enforced
The problem here is that it's extremely easy to accidentally break one of your APIs, and just as easy to voluntarily break one, and forget about having done that by the time you actually do the release.
I've been personally bit by this several times: a dependency releases a "breaking patch" and your CI starts emailing you; and once you pin a version to avoid future issues, you become implicitly incompatible with other packages that pinned a different patch version for the same reason. Why this incompatibility? Because cargo
assumes that all crates respect SemVer and that a situation where 2 distinct patches of a given dependency should not be allowed in the same binary; which makes sense until you remember how fallible humans are, and how easy it is to break your API.
In Rust, we even have the notion of semver-hazards: API properties that can accidentally break through subtle acts. One such example is the Sync
trait: this trait indicates that it is safe to access an object from another thread by reference; it's automatically implemented by the compiler for types that are composed only of Sync
fields, which means that adding a field that isn't Sync
to a previously Sync
type is an API breaking change.
Sync
's existence is a good thing, it lets us prove that a given type is "thread-safe"; and the fact that one doesn't need to systematically remember to implement it is a good thing; but it does mean that adding a raw pointer to your struct is a breaking change unless you remember to manually implement Sync
for it (provided it is still Sync
).
One could argue that this is a tooling issue: "you should use tool X that lets you know what your changes since commit Y qualify as". I agree, but how would that tool measure invariants and networking protocols?4 Could such a tool warn you of semver-mines you've just set at your own feet?
Non-compliant spin-offs
Due to it being misunderstood and the stigma around MAJOR
bumps, people will often misuse SemVer to associate their own meanings to it.
I was notably guilty of this with stabby
, originally positing that "small" breaking changes would only get a minor bump, as I was back then under the (false) impression that cargo
would only implicitly upgrade patch-level changes.
OK, you done ranting? What do you propose to fix this then?
Well, a couple of things actually, both opting to work with a strict subset of SemVer to stay compatible with it while providing more information:
- Humane SemVer is a trivial concept: don't bump your release by 1, but by a rough qualifier of how much has changed.
- SemVer Prime: I heard you liked versions, so I put versions in your versions so you can version your versions.