Home

The faults in our SemVer

Tue Jun 18 2024

Semantic 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:

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:

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.02, 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:

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:

XKCD 'Workflow' Comic where a user complains that they used CPU temperature to trigger actions, and that optimizing the software was a breaking change to them

Hyrum's Law at work

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:

1: Calendar Versioning, also sounds like "calvaire", which is french for "suffering".2: I have seen companies where this is written policy...3: Admit it, you also hate thinking of supply chain stuff for too long.4: One could argue that a tool doesn't need to be perfect to be useful, and I agree, but those are valid concerns.

Comments

Want to leave a comment? Open a PR with your comment in it!