Sementic Versioning Doesn't Support Rolling Deprecation

Posted

I like SemVer. However, there is one important use case that I wish it supported better. This is what I’ll call “rolling deprecation”.

The idea is simply that instead of removing APIs in a single compatibility-breaking version, you first deprecate an API in one version, then remove it in a later version. This gives time to migrate off of the deprecated API without being cut off from new features. Let’s look at a quick example:

  1. You start a new project and release a public API that contains awesome_function(). All excited you cut a release.
  2. You realize that awesome_function() has a few flaws. Maybe it silently ignores an important error, or it would work much better with an extra argument. You don’t want to break backwards compatibility, so you release the improved version as awesome_function_2() but leave awesome_function() around. (Maybe it is reimplemented using awesome_function_2(), but your users don’t need to care.) You mark awesome_function() as deprecated and release.
  3. We add some unrelated feature and make a new release.
  4. A some point in the future you decide that keeping awesome_function() around isn’t a good idea anymore. Maybe it adds an internal requirement that is expensive to maintain, or you just want that wart gone. You’ve given people enough time to move off. You delete it and cut a new release.

So what does SemVer say about this?

  1. v1.0.0 - First release.
  2. v1.1.0 - Added functionality, backwards compatible.
  3. v1.2.0 - Extra feature, backwards compatible.
  4. v2.0.0 - Breaking change.

Pretty straightforward. Users have between release 2 and release 4 to move off of the deprecated functionality. But it isn’t optimal.

What if a user created their project with release 2 (v1.1.0 according to SemVer) and didn’t use the deprecated functionality? What they upgraded from v1.0.0 and moved away from all deprecated APIs? In both of these cases their code is compatible with the latest release, but SemVer can’t express this.

Wouldn’t it be great if you could easily maintain compatibility with multiple “major” versions? This means that an ecosystem could gradually migration from one major version to the next without any incompatibilities during the transition period.

This is a real problem with SemVer. Every major release is a “flag day”. Sure, maybe your ecosystem of choice will let your dependency tree use both v1 and v2 at the same time (for example Cargo does this) but that doesn’t mean that the experience is seamless. It is common to have one dependency using foo v1 and one dependency using foo v2. Interacting with both of these dependencies at the same time can be difficult because your top-level code can only use one version of foo (without getting creative). It gets especially painful if you need to move data of type foo::Data between the two (not possible unless you can convert it).

Some ecosystems support expressing compatibility as a range. For example Cargo lets you specify >=1.4.0, <3.0.0, but this moves the complexity back to the users of the API. One of the great things about SemVer is that it moves most of the complexity onto the package author. The author has a responsibility to indicate their compatibility via the version number and clients (mostly package managers) can calculate if they are compatible. If Cargo sees foo = "1.2.3" it assumes foo v1.6.2 will be compatible. Specifying dependency ranges pushes that back onto the user. Every user will need to specify the range which requires understanding the versioning scheme that every dependency uses. Most importantly if any library you depend on gets it wrong you will have a version conflict, one mistake ruins the whole system.

Specification

I have an example versioning scheme here to illustrate how the problem could be solved. It isn’t intended as a real proposal. I honestly think SemVer is pretty good and close enough for most things. But it is interesting to see what the simplest form of this versioning scheme would look like.

Let’s stick with dotted numbers for familiarity. We also won’t worry about patch versions for now. We’ll just have two components, the base version and the best version.

You bump the best version every time you make a release. Any time you make a backwards incompatible API change you set the base version to the best version of the first release in which that API was marked as deprecated.

How this looks with our example:

  1. v1.1 - First release.
  2. v1.2 - Fully compatible, marks awesome_function() as deprecated.
  3. v1.3 - Fully compatible.
  4. v2.4 - Breaks API that was marked deprecated in v1.2.

One interesting is that both components of the version only go up. There is no “reset” for the best version when the base is incremented. (Although there probably would be if we added patch versions.) If we wanted to release a completely incompatible version it would be v5.5.

Let’s clarify how to check compatibility. Software that doesn’t use any deprecated APIs on vA.B is compatible with version vX.Y if XBYX \le B \le Y. (A is irrelevant)

So v1.1 would be compatible with v1.3 because 1131 \le 1 \le 3 but not v2.4 because 2142 \nleq 1 \le 4. v1.2 is compatible with v1.3 because 1231 \le 2 \le 3 and v2.4 because 2242 \le 2 \le 4.

SemVer Compatibility

Interestingly this scheme is backwards compatible with SemVer! This means that you could start publishing using this scheme and software that expects to receive SemVer would do something reasonable. Most notably it would never consider two versions compatible that aren’t. (However it will tell you that some versions are not compatible when they are.) This is obviously true because the “major” version is bumped on any change that could break anyone and the “minor” version still requires ordering. The main difference is that a “major” version bump doesn’t mean breaking everyone, it specifies which versions were broken. The only downside for using this scheme for SemVer clients is that this scheme would encourage more frequent “major” releases, which would be annoying for SemVer users.

Patterns

Last N Compatibility

Some projects have “deprecation compatibility” for a fixed number of releases. In this scheme it would just look like the base version trailing the best version by that fixed amount.

For example for “last 3 versions” compatibility.

  1. v1.1
  2. v1.2
  3. v1.3
  4. v2.4
  5. v3.5
  6. v4.6

…and so on.

No Compatibility

For projects wishing to express no backwards compatibility the base and best versions would always be the same.

  1. v1.1
  2. v2.2
  3. v3.3