1. 48
  1. 20

    It’s important to say out loud the sordid incentives that keep people from bumping major versions. You spend a lot of time making a change to your software. You want people to use it and find out how awesome it is. If you bump the major version they often have to opt in to using it, which makes it harder to find out about new features. If you don’t bump the major version you can just foist it on them and they’ll be more likely to run into new features they never asked for.

    Even though open source software is often in principle a gift economy, it often isn’t that different from commercial software in practice. You end up with the same incentives of competing on features, wanting to avoid fragmentation, etc. It’s almost like there’s an emergent PM somewhere tracking engagement metrics for the new feature.

    1. 4

      I think there are a few reasons why the treatment of major versions is still the same with semver:

      • Breaking change is often a price for making big improvements: fixing an old design mistake that turned out to be limiting, switching to a better but incompatible third-party library, fixing a bug that erroneously allowed invalid inputs…
      • Few people will want to adopt a release that offers them nothing in exchange for spending time on breaking changes, so grouping incompatible changes with big improvements is a good strategy for removing old cruft without ruining your relationship with downstream users.
      • Software marketing is a big deal: you have to compete for people’s attention, now more than ever.

      Sometimes I wonder if Debian’s idea of a “versioning epoch” could be more widely applicable and more fitting than the current semver. Semver even calls version components major.minor.patch—features, no matter how big, are seen as a “minor” change. From the compatibility point of view, they are indeed minor, but for users, compatibility and improvement are equally important when they decide whether to upgrade or not.

      Debian uses an epoch number when a package changes its versioning scheme, e.g. there can be foo 2001.08 and foo 2:1.2.3 if a package switched to semver from a custom versioning scheme. It looks quite ugly, but it would definitely draw attention to a breaking change instantly, if it was used as a compatibility flag (like 1.2.3 going to 2:1.2.4).

    2. 18

      I’ve found that semver seems to work better for libraries than applications. I used to do semver for programs, but stopped, and now just use an incremental number. So my blog engine is at version 44 (I used to use semver, but switched a few years ago) and my gopher server is at version 13.

      1. 8

        Yeah, semver only makes sense when there’s a clearly defined notion of backwards compatibility; for something that is intended to be used by a human directly it’s just too fuzzy.

        Fwiw, sandstorm’s package metadata format just has a single numeric appVersion field; the platform needs to know when one package is an upgrade of another, and we expect apps to always be able to upgrade their own storage. There’s not really any need for anything finer grained. (Though we also provide an entirely separate appMarketingVersion field, which is just an arbitrary string that gets used for display and nothing else).

        1. 2

          Yeah, semver only makes sense when there’s a clearly defined notion of backwards compatibility; for something that is intended to be used by a human directly it’s just too fuzzy.

          So define it. For example: https://github.com/pump-io/pump.io/blob/master/CHANGELOG.md#pumpio-changelog

        2. 3

          It may work, but it definitely depends on the application. In soupault I’m using a rather radical definition of a breaking change: if any old website setup stops building or outputs functionally different HTML, it’s a breaking change that requires a major version update.

          Since 2019, I incremented it three times: one time to make a big design change, the other two times for rather minor cruft removal. It’s an intentional overreaction to SSGs that love to casually make changes that break old themes. However, a static site generator has a straightforward notion of compatibility. I agree for other projects it may be completely infeasible to even define their public interface.

        3. 10

          Either SemVer means something, or it doesn’t. I choose to believe that it does.

          I choose to believe it doesn’t.

          Or, well, more precisely, I believe that semver at most communicates developer intent about the changes that went into a release. Bumping a minor version number is equivalent to the claim that “we think this is backwards compatible”. Actually proving that a change is backwards compatible in practice can get rather difficult and I don’t believe it’s ever done.

          I had an idea a few years ago for a Go tool that would estimate a lower bound on the semver version bump between two version of the same codebase, by looking at the exported types and functions and comparing signatures. I then wanted to run it on the claimed public releases of some open source projects and file issues arguing that their versions should be much higher than the official ones. In the end I decided that the effort outweighed the pleasure to be had from the shitposting.

          1. 8

            The canonical example here is all the people who got extremely upset when a popular Python cryptography package switched from implementing its critical stuff in a C extension, to implementing it in a Rust extension. Many angry commenters felt this was a violation of SemVer (which, to be clear, the project had never claimed to follow in the first place) and should have required a major version bump despite the fact that the public API of the module had not changed.

            I’m obviously biased, but also personally a fan of the approach Django settled on, which is that the version number is not the thing you care about. Instead it’s whether the releases you’re looking at are designated for long-term support or not. Django’s deprecation and release cycles are set up so that:

            • Every third feature release (every couple years at the current pace) is an LTS with a longer support period (currently averaging around 30 months from initial release, compared to around 18 for a non-LTS).
            • All API that exists and doesn’t raise deprecation warnings in the current LTS will also exist in the next LTS.

            So if you want infrequent upgrades and maximum stability, run on the current LTS, and your upgrade process when the next one comes out is to clear deprecation warnings. Or if you want to always have the latest features — which may come at the cost of dealing with deprecation/removal more often — you can jump to every release as it comes out.

            1. 1

              The Python cryptography thing happens every time any library adds a dependency (or upgrades a dependency) that can be conflicted with. In Python (and most other languages) that’s every dependency, because the entire dependency tree can contain at most one version of a thing. It just so happens that adding a Rust compiler as a dependency is way more incompatible than average.

              It would be hard to prove that a release of something was backward-compatible, in general, but it’s not hard to prove that these changes aren’t. I think this suggests that major version changes are a big deal and should be avoided: They’re likely to cause a huge amount of churn in your transitive dependants.

              More generally, as long as SemVer works on the meh/meh/you’re-screwed model, people are going to want major version bumps every time any of them is screwed, and major version bumps are going to screw people.

              1. 3

                It just so happens that adding a Rust compiler as a dependency is way more incompatible than average.

                As far as I’m aware, the cryptography module has always provided pre-compiled binary packages on PyPI. They certainly were providing them when they made the switch to doing the extension in Rust.

                Part of the outrage was people claiming that this broke support for certain platforms Rust doesn’t support (though I believe most or all of those are also platforms Python itself does not officially support). Part of it was just people being angry because being anti-Rust is a culture-war thing for certain segments of the programming population.

                I have never actually heard from someone who was a user of the module, on a platform and architecture that Python itself supports, and who was unable to install and use it after the switch.

                1. 2

                  It hit me on Debian stable at some point. For whatever reason there wasn’t a prebuilt wheel pip wanted to install, and when I tried rustc memory pressure slowed the system to a crawl (would it have finished eventually? I have no idea; I couldn’t afford to lag out the other services that were swapping, so I chickened out). I didn’t particularly blame the package maintainers for this situation, but I did swear at the Python ecosystem collectively a bit for not managing to install a binary. I don’t know if it was missing or if pip just didn’t find it. We didn’t wait to see if the problem would be resolved, just dropped the functionality that depended on cryptography and moved on with our lives.

                  1. 2
              2. 1

                despite the fact that the public API of the module had not changed

                Did it change which OSes the package could run on? I know that Rust isn’t supported everywhere yet…

                1. 2

                  Did it change which OSes the package could run on?

                  This was the crux of the disagreement. It changed what platforms the package could potentially compile on.

                  As far as the package’s authors were concerned, the rust replacement was capable of compiling on every platform they had previously claimed to support, so nothing had changed. As before they distributed precompiled binaries for supported platforms.

                  But users of, say, a completely unsupported AIX (I don’t remember the actual platform in question, just picked AIX out of the air, it was on that plane of obscurity) port of python complained that this broke the cryptography package for them.

                  The disagreement was fundamentally between: the user base assumption that if they could convince something to compile, this meant it worked and therefore anything that broke their ability to continue to convince it to compile was a breaking change, and the maintainers belief that SemVer applies to what you actually claim to support, and that just because something happens to compile on an unsupported and untested platform doesn’t mean it will meaningfully work, except by accident (and that “works by accident” is a horrible thing to depend on for cryptography, since for all anyone knew the AIX compiler might be screwing up security critical code).

                  (I’m not very sympathetic to the users’ camp in the argument; SemVer is only meaningfully feasible if the notion of support for compatibility exists within well-defined, maintainer-determined boundaries)

            2. 8

              A data point: in Elm semver is enforced, and there are some packages with major version numbers in tens.

              1. 5

                Major version numbers can’t do double duty as both breaking-change indicator and marketing hook.

                Yes! How often we talk about “single responsibility” and then forget when it comes to things like versions.

                There is a real danger in saving up all your significant changes and releasing them in one whopping major version once a year.

                I’ve repeatedly learned this lesson in my career, and I can’t think of a reason that open source libraries are any different. The bigger the release, the less likely it’s smooth.

                He makes a lot of great points, however, my biggest issue with semver is the flippant attitude toward breaking changes it sometimes results in. If a team of 10 developers create a breaking change in a library used by 10,000 user devs, that’s a 1000x fan out. An empathetic developer will work to reduce fanout of breaking changes.

                Being honest is critical to clear communication. Creating a marketing “epoch” is a great way to reduce cognitive dissonance around major version bumps.

                1. 5

                  This feels very backwards to me. Breaking changes are bad. If a library has a breaking change that takes 10 minutes to fix and is used by 60,000 developers, that’s 10,000 hours down the drain. The version numbers are important only insofar as they signal “here comes a waste of time.” If for marketing reasons, you need to promote a release, that’s fine, but that’s a different issue from when and how you should make a breaking change.

                  1. 11

                    Breaking changes are bad.

                    Breaking changes are by themselves value neutral. It can easily be the case that a breaking change yields more benefit to the software project than cost to its consumers. And it’s not like upgrading dependencies is obligatory, right? It’s always an opt-in decision.

                    1. 9

                      It’s always an opt-in decision.

                      Only until there’s a security issue in the package and the fix is not released for the older major versions. Then it’s upgrade or potentially do lots of custom fork work.

                      (I don’t disagree with your post though - breaking doesn’t mean bad)

                      1. 4

                        But surely that’s true no matter what version numbering scheme the package uses: old releases of whatever numbering structure will eventually not get security patches.

                        1. 3

                          Compatible versions don’t take significant effort to update.

                      2. 2

                        It can easily be the case that a breaking change yields more benefit to the software project than cost to its consumers

                        One might argue that very frequent major version bumps mean that the authors are bad at getting their design straight. Of course like all such things that’s not true in every case, but it can be an indication for something going wrong in a project. What’s frequent itself depends on the context of course, and of course what the breaking changes are like is another topic. If it’s about removing hacks for things deprecated a decade ago that’s different from a v2 (v3, v4, …) that can’t hold up with what v1 already brought on the table, yet requires rewriting whole systems on the user’s side.

                        As others pointed out security issues or other types of bugs might mean that you are forced to upgrade (or fork).

                        I’d say breaking changes are not neutral, but but bad, which might however be completely overshadowed by any kind of benefit it brings alone. In some cases it’s actually worsened by a worse design, lack of feature completeness of v2 or some company deciding they wanna have a stronger grip to maximize profits, artificially creating incompatibilities (Mapbox, etc.).

                        1. 1

                          I’d say breaking changes are not neutral, but but bad, which might however be completely overshadowed by any kind of benefit it brings alone.

                          Breaking changes are bad for consumers, but generally beneficial for authors.

                      3. 7

                        This is entirely the reason why SemVer gates them behind major versions - to communicate them. The rest comes down to management and upgrade pressure. As a positive example, let’s look at the “nom” library in Rust. Each major version is a different take on the same concept and api - also sometimes because new language features lead to better ergonomics or tie better into new features of the Rust language. Still, it’s intended use is that you pick a version of nom and then don’t upgrade.

                        The other option would be what was the norm in the Ruby community for a while: you had take X on a concept, let’s call the library “apples”. It becomes 1.0 and never changes. Suddenly, someone forks it, rips half of it out and calls it slightly different, say “oranges”. So suddenly, you ended up in a game of community telephone, which library was currently the most recent take, considered modern.

                        1. 2

                          The other end of the spectrum is bindgen that bumps major incompatible version whenever their output or clang changes slightly.

                        2. 4

                          I recently released a breaking change to a library. The breaking change was to change behavior so that people would stop having their Unicode characters silently stripped from data, and being confused at weird slugs

                          The change will mean that new users will stop having this issue by default. Existing users, when upgrading, might also realize they had a problem and will have a fixed bug. And of course I have a setting in place to maintain the old behavior (but it’s opt-in because the behavior is bad)

                          I feel like basically every well managed project does breaking changes to make stuff better, include on-ramps and off-ramps, and just is doing stuff practically. There’s definitely pain! But good projects all seem to handle this well.

                        3. 3

                          Though it makes sense that a major version bump to a SemVer library shouldn’t mean anything more than the existence of breaking changes, my feeling is that in practice most people will take it to mean the presence of big changes. I think the author would agree with this: he added a code name to RedwoodJS’s version, and I think that was to prevent people from thinking the framework is making major changes frequently.

                          That “code name” strategy for marketing big changes to software seems fine for RedwoodJS, but I don’t think it’s an ideal solution for software projects in general. Thinking of code names is tough, especially if you try to make the chronological order of code names self-evident by working your way through the alphabet.

                          The article mentions a versioning scheme with a fourth marketing-owned “epoch” part, like epoch.major.minor.patch, but complains that the resulting versions like “2.3.1.0” are too visually complex and incompatible. I’m wondering if the following versioning scheme would be a viable replacement: epoch.major.minor. I feel like I’ve seen some popular software that already follows that versioning scheme, but I don’t remember which software it was or whether they had named that scheme.

                          1. 3

                            my feeling is that in practice most people will take it to mean the presence of big changes

                            …that’s what the author is saying, and that it’s simply cultural, and it must change.

                            1. 2

                              What I meant to say was that I doubt we can change the culture after so much history of using version numbers a certain way. But it would be nice if I were wrong.

                          2. 3

                            Haskell’s package versioning policy is roughly semver with a two-part major version. It seemed a little redundant to me at first, and I suspect it’s mostly an artifact of history, but it is interesting to see what people do with it; formally the two numbers don’t have any separate meaning, but people seem to use the primary number to communicate “big” breaking changes, which is necessarily subjective. But, for example:

                            • text 2.0 changed the encoding of the data type from UTF-16 (or one of it’s siblings, can’t remember) to UTF-8. - aeson 2.0 made the map type that json objects decode to opaque; previously it was a transparent alias for HashMap, which is vulnerable to hash flooding attacks. Folks tend to use the second number more often and for more minor breaking changes.

                            compare to aeson 1.5, which mostly changed parts of the library I didn’t know existed before I looked it up for the sake of this example.

                            1. 2

                              This is a problem because SemVer encourages you to conflate interface and implementation versions. If I want to deprecate an API, the clean way of doing this is to create a new version of my implementation that supports the old API (1.0) and the new API (2.0). With SemVer, I have no way of expressing the idea that my library supports these two interfaces. The excuse given in the library (not many people are using the interfaces) then this would be easy to address if you supported multiple interfaces in a single library and you could bump the major version only of the one that few people are using (or explicitly introduce it with a 0.x version where there’s no stability guarantee).

                              1. 2

                                SemVer’s handling of that situation doesn’t seem too bad. If you were on v1.3.4 and then introduced an incompatible API without removing the existing API, your next SemVer version should bump the minor version, making it v1.4.0. You only bump the library’s version to v2.0.0 when the existing API is removed. It’s true that SemVer’s label “v1.4.0” doesn’t express your plan to eventually release a v2.0.0 that removes support for the old API, but I don’t know of any versioning scheme in use that expresses that. SemVer’s other flaw is that “v1.4.0” doesn’t market your new features very well, but I don’t think that’s what you meant by “[conflating] interface and implementation versions”.

                                I don’t understand your long sentence starting “The excuse given in the library … then this would be easy to address”. I think that sentence is missing a few words somewhere.

                                1. 5

                                  You only bump the library’s version to v2.0.0 when the existing API is removed

                                  The problem here is that the bump to 2.0.0 is not a breaking change for anyone who has moved to the new APIs. If you had versioned interfaces and express implementations as sets of interfaces then you have:

                                  • {1.3.4}
                                  • {1.4.0, 2.0.0}
                                  • {2.0.0}

                                  Someone using your library can move from the first to the second without breaking anything. If their import then specifies that they are on API v2, then this works fine.

                                  Generalising this to supporting multiple interface names in the same implementation is a small step forward. For example, for something like libzstd, which has some experimental APIs that are hidden behind a feature macro, you might say something like:

                                  • {compress { 1.0.0 }, decompress { 1.0.0 }, custom_allocator { 0.0.0 }}
                                  • {compress { 1.2.0 }, decompress { 1.0.0 }, custom_allocator { 0.5.0 }}
                                  • {compress { 1.3.0 }, decompress { 1.3.0 }}
                                  • {compress { 1.3.1, 2.0.0 }, decompress { 1.3.0 }}
                                  • {compress { 1.3.2, 2.1.0 }, decompress { 1.3.0 }}
                                  • {compress { 1.3.3, 2.1.1 }, decompress { 1.3.0 }}
                                  • {compress { 1.3.4, 2.2.0 }, decompress { 1.3.0 }}
                                  • {compress { 2.2.0 }, decompress { 1.3.0 }}

                                  Here, there’s an experimental API for providing a custom memory allocator that goes through two unstable revisions before being folded into the stable APIs for the compress and decompress paths. If my build system doesn’t say that I need custom_allocator, then it won’t set the macro that exposes these APIs. If it does, then when I try to build with the second release I’ll get a build failure if I update the dependency because the version has changed (with SemVer, any release in the 0.x series may be a breaking change). It’s then up to me to verify that my use is okay. Eventually, that unstable API goes away and I’m left with just using the stable one. Now the project is committed to maintaining that for a while. Eventually it decides that the compression APIs were badly designed and so brings in a replacement. The next version removes them.

                                  At no point after an API is declared stable is a single version upgrade a breaking change for a user that moves to the new versions in a timely manner but jumping from non-adjacent versions may be. This means that your CI can pull in new versions and test on a nightly / weekly basis and warn you if there’s a newer API version than the one that you’re on for any API that you’re using. You then have a bit of time to manage the upgrade and you avoid a flag day.

                                  The depressing thing is that Windows has done more or less this with COM interfaces since the mid ’90s and some critical *NIX libraries in C have done the same thing with symbol versioning.

                              2. 2

                                I’d say it’s also important to try to avoid breaking changes in the first place. Deprecate all you want, print warnings, all of that… but if you make a breaking change in a library, you’re always risking dependency hell for your dependents (and their dependents).

                                Rich Hickey had a bold suggestion: If you want to change what do_foo does, just add a do_foo2 next to it and deprecate the old one. Not always applicable, but an approach I always keep in mind as an option.

                                1. 2

                                  if you make a breaking change in a library, you’re always risking dependency hell for your dependents (and their dependents).

                                  As long as you bump the major version with each breaking change, there is no consequent hell for any consumer. Anyone depending on major version N remains entirely unaffected. When consumers upgrade from major version N to major version N+1, they must, of course, verify that their code continues to work as expected, which always requires explicit attention.

                                  If you want to change what do_foo does, just add a do_foo2 next to it and deprecate the old one. Not always applicable, but an approach I always keep in mind as an option.

                                  This approach optimizes for minimizing breaking changes, and therefore minimizing major version bumps. But it also results in APIs which are strictly worse than the alternative. If foolib v1 has 100 consumers, and barlib v1 has 100k consumers, is the cost of a breaking change equivalent for them both? If you are a new consumer, how much better is a foolib with only a single, “correct” do_foo function, versus one with a deprecated do_foo and a “correct” do_foo2”?

                                  The cost or benefit a breaking change is a function of a lot of variables. It’s not strictly negative.

                                  1. 2

                                    As long as you bump the major version with each breaking change, there is no consequent hell for any consumer.

                                    This is incorrect for many large codebases with complicated dependency trees. Take the situation where you have two dependencies, A and B, each of which depends on C. A starts depending on a newer major version of C due to a newer feature, but B still requires the old major version due to breaking changes. Now you’re stuck on this version of A, and getting farther out of date. Or worse, you’re trying to set up your dependencies in the first place and can’t find a compatible combination.

                                    This is a simplified situation. It gets worse in larger, more complicated dependency trees.

                                    (And what’s even worse is when you have pinned some dependency somewhere because of breakage, and then other things get pinned as a result, and then three years later you have to start unwinding the trail of pins and going through multiple version upgrades just to get everything back to latest.)

                                    So yeah, there are reasons to not make breaking changes in the first place, regardless of versioning schemes.

                                    (Maybe if you use Nix or something you can get out of this, since A and B can each have their own C…)

                                    1. 1

                                      I understand this scenario intimately 😉 but this is (a) an uncommon hell, and (b) of the consumer’s making. IMO it’s something that should influence the major-version-bump cost calculus, absolutely, but not dominate it.

                                      1. 2

                                        I’m not sure how you can say it’s of the consumer’s making. If I need to depend on A and B, it’s not my fault that they at some point each depend on incompatible versions of C.

                                        Anyway, there’s a middle ground: Announce the deprecation well in advance, emit warnings, allow a good amount of calendar time (and a few versions) to pass, and finally make the breaking change. This reduces the chances of problems.

                                        1. 2

                                          I’m not sure how you can say it’s of the consumer’s making. If I need to depend on A and B, it’s not my fault that they at some point each depend on incompatible versions of C.

                                          That’s true. But, in practice, when this happens, there are usually a lot of mitigating factors. There’s almost always some (prior) versions of A and B which have compatible requirements against C, which you can fall back to. If you’re prevented from using those versions for whatever reason — security vulnerabilities, etc. — then it’s usually just a short period of time before the maintainers of the dependencies update them to a compatible state. And even in the situation where this dep graph is truly unsolvable, you always have the fall-back option of forking and patching.

                                          My point isn’t to diminish the real pain introduced by the points you raise, it’s real! But I think these conditions are basically always pathological, usually quite rare, generally ephemeral, and can often be solved in a variety of ways.

                                          I think it’s important that software authors can write and release software that delivers value for other people, without necessarily binding them to support each released version into perpetuity. Software is a living thing, no API is perfect, and no package can be expected to be perfect. The cost of a breaking change isn’t determined solely by the impact on consumers, it’s a complex equation that involves consumers, producers, scope, impact, domain, and a bunch of other variables.

                                          1. 2

                                            True, at work we’ve had to fork and patch a few times. It usually does work as a final escape valve.

                                2. 1

                                  I’m proud of my record for following SemVer in SBJson. For the first 4 major versions I targeted RFC 4627. For version 5 I switched to targeting RFC 4627, which changed to recommend allowing scalar values at the top level. As this was a change in behaviour (at least if you relied on “naked” scalars being disallowed) I chose to bump the major version.

                                  I didn’t worry about it being tedious to adopt a new major version, as years earlier I had made the decision that I wanted to simplify upgrading SBJson in your application incrementally. For a large app you can use several major version of SBJson in your application at various stages of migration. To support this I renamed all classes and public symbols to include the major version. This was tedious in Objective-C – but worked.

                                  I started doing that as Facebook included SBJson in their SDK, which was very popular. This was a double-edged sword for me. A lot of users probably ended up using SBJson because they got it “for free” with their Facebook SDK. But Objective-C being what it was, that meant it was impossible for people to use the Facebook SDK and a different version of SBJson than FB shipped: you’d end up with a version conflict. I never had any luck with getting Facebook to upgrade the version of SBJson they shipped, so I had to work around it with the tools at my disposal.

                                  1. 1

                                    Interesting obliquely related recent thread https://lobste.rs/s/vr7m10/contractual_semver