#6 - SemVer or Not to SemVer
A major bump warns of change, but the real disagreement is whether breaking an API is acceptable.
17 min read
This time Johan Abildskov, a Senior Consultant with Praqma/Eficode, joins us to talk about SemVer (Semantic Versioning), and we finally get to hear what Julien has to say about it. We get to explore different options regarding versioning and how it helps humans communicate. At the end of the podcast, everyone gets to share their approach and recommendations for versioning things.
Nobody agrees what your version number means
You tag a release 2.0.0 and push it. Downstream, someone with a delivery date on Friday has to decide what that number costs them: an afternoon, a week, or nothing at all. They can’t read your mind, and more often than you would like they can’t read your changelog either, so they guess. Guess cautiously and they sit three versions behind on a dependency with a known bug. Err the other way and they find out in production, which is the expensive place to find out anything.
Semantic Versioning 2.0.0 is supposed to remove that guess. Three numbers, three rules: major for incompatible API changes, minor for backward compatible additions, patch for backward compatible fixes. The rule that quietly decides whether any of it works is the first one in the spec: “Software using Semantic Versioning MUST declare a public API.” Many companies don’t have a clear definition of their public API. Without one, the three numbers are just a counter that goes up.
Johan Abildskov makes the case for the spec, and he has the scars to back it. He’s a senior consultant at Praqma, the Scandinavian consultancy Eficode acquired in May 2019, and his work is helping companies move to Git and work out how to version what they ship, hands-on some weeks and strategic others. He also maintains the Git katas exercise set. Julien takes the opposite position, and the disagreement turns out to be about something larger than numbering: whether breaking a published interface is ever an acceptable thing to do.
Versioning is a message you send to other humans
Start with what the number is for. It does two jobs: it feeds a dependency resolver, and it tells a person what an upgrade is going to cost them.
What does a version number buy you that a commit hash doesn’t?
A rule a machine can act on and a person can read: you can pin a dependency at greater than or equal to 3.1.0 and less than 4.0.0, and a human can tell you what that constraint is worth.
The spec’s own introduction is written around the machine half: specify dependencies too tightly and you get version lock, “the inability to upgrade a package without having to release new versions of every dependent package”; too loosely and you get version promiscuity, “assuming compatibility with more future versions than is reasonable”. The resolver does its job as long as somebody decided what the numbers promise, and that decision is the half that comes apart.
Think about what a release team spends its week doing: working out what is going into the release, then working out what that means. The question is reasonable. The fact that it takes a team to answer it is the problem.
A version boundary also changes who decides when a change lands. Rigid versioning makes delivery pull based rather than push based: the consumer picks the moment, instead of having your Tuesday afternoon arrive in their build. That cuts both ways. People do sit on old versions because they don’t want to accept incoming risk, and some of them sit there for years. It also means a team can look at 3.1.4 going to 4.0.0 two days before their own release and say, not this week. Being able to decline an upgrade on purpose is the feature.
The number your engineers use and the name your marketing team uses don’t have to match. Internally you ship 3.5.1; on the website it’s whatever the product people want to call it. Conflating the two is how you end up with a major bump that exists because a conference is coming up.
The cost of getting this wrong is not theoretical. We heard plenty of complaints after the macOS Catalina upgrade from people who had no idea what they were taking in. Release notes don’t reach everyone who depends on you, and a graphics artist in the middle of a job is exactly the reader who skips them. The version number is the part of the message that is hardest to skip.
You can’t version an API you can’t describe
Which brings up the part of the spec most teams skip.
What counts as the public API of the thing I ship?
Whatever you declare, because declaring it is rule one, in the code or in documentation, “precise and comprehensive”.
The word “public” misleads people here. It doesn’t mean an API exposed on the internet. It means the documented, publicly referred methods of whatever you hand over: a library’s exported functions, a CLI’s flags, a service’s endpoints. A library on a private artifact repository has a public API in exactly this sense.
The declaration is what turns “is this breaking?” into a question with an answer. Ship a container image and the contract plausibly covers how the image is run: change the entry point and the docker run commands that relied on the old one stop doing what they did, whatever happened to the code inside. If that invocation sits inside the contract you declared, breaking it is what asks for a major bump, once you are at 1.0.0 or later. Under 0.y.z the spec asks for nothing, because clause four lets anything change at any time. We remember an image changing its entry point without a major bump, and as far as we could tell it was Prometheus, though none of us could pin the release. Without a written contract, two reasonable people argue about whether a change was breaking and neither of them can win.
Some projects put the version in the interface instead of on the release. Kubernetes versions each API group separately as alpha, beta and GA, and its deprecation policy says API elements may only be removed by incrementing the version of the API group. What that buys you depends on the track. Once a deprecation is announced, the old version has to keep being served for a minimum: 12 months or 3 releases for GA, whichever is longer, 9 months or 3 releases for beta, and zero releases for alpha, which can therefore go in the next one. A client written against GA resources gets a year of warning at worst; one built on beta resources gets nine months and may never see the notice. The release number still carries information, just not the compatibility promise for the APIs inside.
Write the boundary down first. The numbering argument is unresolvable until somebody does, which is why it fills so many meetings.
Breaking it means admitting you got it wrong
Here the two positions split, and the split is the content.
Is a major version bump a responsible way to ship a breaking change?
Johan says yes, it’s the honest signal that something changed. Julien says no, it’s permission to break people.
Julien’s argument runs through contracts. If your bank removes a clause, you don’t get version two of your contract, you get a new contract, and you have to agree to it. Software pretends otherwise. HTTP/1.0 traffic kept working for decades. Ethernet grew by orders of magnitude without breaking the name. Browsers retire a feature slowly and in public. Chrome’s published plan for FTP ran over three releases: off by default for non-enterprise clients in 80, off for every installation in 81 with the --enable-ftp command line flag still able to switch it back on, and the code itself gone in 82. The plan then came apart twice. Chrome 82 was cancelled on March 26, 2020 when Google reworked its release schedule. Two weeks after that, Google put FTP back on the stable channel: a Google engineer wrote on April 9 that “in light of the current crisis” Google would “undeprecate” FTP, with the turndown to resume once the crisis had passed. Deprecation on a timetable is a different act from a breaking release, even when the timetable slips.
So his alternative is to put the two in the name, not in the version. A new namespace, a new package, the old one left standing. Python is his exhibit: someone says they’re a Python developer and the next question is always two or three, because the split outlived everyone’s patience with it. Python 2 reached end of support on January 1, 2020, roughly eleven years after Python 3.0 shipped.
Johan’s answer isn’t that breaking is fine. It’s that he expects to be wrong:
Mattias pushed from the other side with CentOS. Five, six and seven are rebuilt to the point where your tooling from five won’t run on seven, and it’s still CentOS, because it’s recognisably the same system. Renaming it would communicate less, not more.
The sharpest objection to the spec is none of these. It’s that a major bump says something might break, somewhere, and you won’t know where until you try. A guaranteed break is almost easier: someone else has hit it, and there’s a fix in a thread. Maybe-broken is the state you can’t plan around.
Who the rules are written for
Neither position comes from misreading the spec, so the disagreement has to sit somewhere else.
Why do careful engineers still disagree about something this mechanical?
One reading is that they’re designing for different people. The other is that discipline has nothing to do with it.
Andrey’s reading is that Johan engineers for a world where people are unreliable, do their best and fall short anyway, while Julien asks for the discipline that would make breaking changes unnecessary in the first place. Discipline is partial in all of us, Andrey included: enough of it to train for an Ironman, not enough to stop eating sugar.
Julien rejected that framing. His objection isn’t that people should try harder, it’s that the problem is too complicated for trying harder to fix. React and Prometheus are staffed by people who know what they are doing, and they still version things their own way, the way every team runs its own flavour of agile and still calls it agile. What he’s defending is an obligation: you don’t break the people who use your API, and a spec that explains how to break them tidily makes it feel routine.
Andrey’s counter is the one nobody enjoys. Old interfaces cost money to keep alive, and the decision to drop one often isn’t an engineer’s to make. A vendor with a small group of customers on an expensive legacy path will eventually decide the maintenance isn’t worth what they pay. An acquisition can retire a promise the original team made in good faith. Those breaks happen above the code.
There’s a general rule here, easiest to see in process frameworks: you’re allowed to run Kanban once you’ve mastered Scrum. Rules first, because they show you which parts you actually dislike and why. Throw them away afterwards, with reasons.
Consistency inside your ecosystem beats agreement across the industry
The practical consequence is lower stakes than the argument suggests.
Does my scheme have to be SemVer for anyone to understand it?
No. It has to be consistent inside the ecosystem it ships into, and documented.
npm, a corporate artifact repository, one platform team’s internal charts: each of those is an ecosystem, and consistency inside one is what makes a version a contract. Git SHAs work. Calendar versioning works, and tells you something SemVer can’t, which is how old the thing is. Random strings of Pokémon names would work, as long as everyone knows the ordering and the rule for what changes.
Even teams who do know the rule improvise. React went from 0.14 straight to 15.0 in April 2016, on the reasoning that the leading zero was telling people the library wasn’t production ready when it had been for years. Terraform shipped substantial configuration language changes in 0.12, with an upgrade guide and a dedicated upgrade command, which looks like a breaking major release wearing a minor number. Both are defensible under the spec, because clause four says “Major version zero (0.y.z) is for initial development. Anything MAY change at any time.” Staying on 0.x is itself a message, and a long run of them says the authors are not finished promising.
The risk here is real and worth naming. Google’s SRE team puts roughly 70% of outages down to changes in a live system. Versioning doesn’t reduce the number of changes. It decides whether the people absorbing them can see them coming.
Match the scheme to what you’re shipping
Which is why the question “should we use SemVer” has no answer in the abstract.
Why does one versioning scheme feel wrong on two different projects?
Because the consumers are different. A library versions a promise to strangers; a service versions an artifact for your own operators.
Three cases, three sets of requirements. A library for public use is consumed by humans making decisions, so the number has to carry meaning. A command line tool is the same, with flags and output formats as the contract. A backend service deployed into your own cluster is consumed mostly by machines, and often nobody downstream makes a decision from the number at all.
Where that holds, the version’s job changes completely. It stops being a promise and becomes an answer to two operational questions: what is in production right now, and what was in production before it. A customer opens a ticket, and you need to go from a running container to a commit without a scavenger hunt.
Whether to keep the contract and the artifact on separate numbers depends on who reads each one. They can move at different speeds: a CA certificate bundle refresh or a base image rebuild can leave the documented contract exactly as it was while the artifact underneath changes. Can, not does. A trust store update can remove trust as well as add it. Firefox 64 stopped trusting Symantec-issued TLS certificates. An outbound TLS call that worked on Tuesday can fail on Thursday without a line of your code changing. Run those rebuilds through your compatibility checks like any other change.
That’s where the two recommendations part. Andrey recommends keeping one sequence of numbers for the promise and another for the thing on disk, because a security patch or an infrastructure move says nothing about the contract. Johan recommends versioning the container and its contents as one thing when the deployment is a service nobody downstream pulls: patch and feature levels go straight to production with no communication around them, so a second number has no second audience to read it.
How we tag container images
So here’s the concrete question: how should a small team tag the images it ships to customers?
What should go into a container image tag?
The commit SHA, plus whatever answers “when” and “which major” for your situation.
Three answers, and the overlap matters more than the differences. Tag with the short commit SHA and a timestamp: the SHA says what’s inside, and the timestamp disambiguates the same commit built three times. Or: one single number for the major, plus the short SHA, so you can run two majors side by side during a deprecation window, watch which paths are still being called, and remove the old one when it goes idle. Or: date first, then the SHA, so the age is the first thing you read, and anything older than about three months is a prompt to read the code before trusting it.
Every one of those keeps a commit SHA in the tag. The disagreement is only about what goes in front of it, and that choice follows from what you’ll need to answer under pressure.
Pushing latest to production is a separate decision and not automatically a bad one, if you have continuous deployment and an agreement with whoever owns release management. The thing you cannot skip is traceability. latest alone gives you no way to say what two environments are running, or what changed between Tuesday and Thursday.
What this means for teams
The argument lands somewhere usable. The spec is not a law of nature, and neither accepting it nor rejecting it decides anything until the compatibility contract exists in writing: what you ship, which parts of it other people may rely on, and what they are owed when one of those parts has to change. The numbering scheme is how you announce that answer, so pick it second.
Four things worth doing this week:
- Write one sentence naming the public API of each thing your team publishes, and say explicitly whether the container entry point, the CLI flags and the config file format are in it.
- Check what your image tags can tell you under pressure. Pick a running container in production and time how long it takes to get from the tag to the commit.
- Work out who reads your artifact number and who reads your contract number. Where those are different people, split the two and let the artifact move faster; where the container and its contents are handed over as a single thing nobody pulls, versioning them together is the simpler answer.
- Take the next change on your roadmap that touches how people call your software, and decide in writing, before it ships, whether it breaks the contract you just wrote down. What surfaces in that argument is the compatibility promise you have been making by accident.
Common questions, answered
Do I need a major version bump when I change a container image’s entry point?
Yes, if the change breaks a supported invocation covered by your declared contract. The spec asks for a major bump on backward incompatible changes to the public API, not on every edit to a declared interface. Under 0.y.z anything may change at any time, so say which of the two you are.
The same test catches what else hides outside the source: required environment variables, the config file format, the listening port, the user it runs as. A docker run passing its own arguments may be unaffected; one that relied on the old default stops working.
How do I version a service that only my own cluster pulls?
Tag the image with the short Git commit SHA and a build timestamp when its consumers need build identity rather than compatibility information. Nothing downstream reads such a number before upgrading, so meaning in the digits is wasted effort and traceability is not.
Add a single leading number where something downstream does decide from the tag: two incompatible versions running at once during a migration, one to route to and one to retire.
Should we rename the product instead of shipping a 2.0?
There’s no settled answer, so here is the disagreement. Julien’s proposal is to put the two in the name rather than the version: a new namespace, a new package, the old one left standing, because something that breaks its contract is a new thing.
Mattias’s objection is that the name usually carries more than the break does. CentOS 5, 6 and 7 are rebuilt far enough apart that tooling from 5 won’t run on 7, and they’re still one system. Python kept its name through 2 to 3 and the user base split anyway. What you do control is whether the old interface stays available, for how long, and whether you said so first.
Related episodes
- Episode 2, GitOps covers the other pull based model: an agent inside the cluster pulls desired state from Git and reconciles it. That is a deployment mechanism; when a consumer accepts a dependency release is a separate decision, and the shared word hides the difference.
- Episode 3, Docker Secure Build is the companion to the tagging argument here: if every security fix is a rebuild, your tags have to tell you what went into each one.
- Episode 1, Infrastructure as Code is the earlier version of the same disagreement, about whether tooling or discipline is what actually changes outcomes.
Resources
- Semantic Versioning 2.0.0 settles what the spec actually requires: declaring a public API, incrementing major for backward incompatible changes, and the clause that permits anything to change under
0.y.z. - Kubernetes deprecation policy, pinned to the revision of the docs current in April 2020, settles how a project can version interfaces instead of releases, including the minimum serving windows after deprecation for GA, beta and alpha API versions.
- React v15.0, React blog settles the jump from
0.14to15.0and the reasoning behind it. - Announcing Terraform 0.12, HashiCorp settles what shipped in that release and that existing configurations needed an upgrade guide to survive it.
- Sunsetting Python 2, Python Software Foundation settles the end of support date and what it means for code still on Python 2.
- Deprecations and removals in Chrome 80 settles the original FTP timetable: disabled by default for non-enterprise clients in Chrome 80, for all installations in Chrome 81 with
--enable-ftpor--enable-features=FtpProtocolstill able to re-enable it, and code removal in Chrome 82. It does not cover the April 2020 reversal, which is the April 9 post on Chromium issue 333943, “Remove built-in support for FTP from Chrome”. - Site Reliability Engineering, introduction settles the roughly 70% of outages attributable to changes in a live system, and what Google does about it.
- Johan Abildskov on LinkedIn is where to follow our guest’s work on Git and delivery practice.
Since we recorded
Python 2 finished its exit on the day this episode went out: 2.7.18 shipped on April 20, 2020, the last release of the line. Terraform left 0.x behind in June 2021 with a 1.0 release carrying explicit compatibility promises for the whole 1.x series. Kubernetes never needed a 2.0, and in August 2020 it put a clock on REST API beta versions: three releases, then roughly nine months, to graduate to GA or introduce a replacement beta version, deprecating the old version. The spec itself has not moved, semver.org still publishes 2.0.0, and Johan came back for episode 26 on Git branching strategies, which picks up the same question one layer down.
