SemVer versioning

Pavlik Kiselev
ING Blog
Published in
7 min readMar 1, 2022

Lifehacks on how to keep both publishers and consumers happy.

At ING, not only do we have many public open-source libraries, but also internally, we embrace the same model, both on the frontend and backend sides. We have a dedicated marketplace of all frontend components and libraries, can see their code, request features or file issues, make forks and pull requests, precisely like at GitHub.

With great power comes great responsibility. With this approach, creators of the libraries become maintainers while having consumers. Maintainers can’t change the library in any way they want because it can break the consumers’ code. At the same time, not changing the code at all is also not an option since bug fixes and new features are needed. Therefore, we use versioning. There are different strategies for versioning, but in NPM, SemVer is used.

SemVer (Semantic Versioning)

Semantic Versioning or SemVer is a standard on versioning with strict rules. Essentially any version is a required combination of three numbers plus an optional tag.

Given a version number MAJOR.MINOR.PATCH, increment the:

- MAJOR version when you make incompatible API changes,
- MINOR version when you add functionality in a backward-compatible manner, and
- PATCH version when you make backward-compatible bug fixes.

One rule to rule them all: consumer-first

It might be evident for some, but the main idea is to look at a versioning perspective from the consumer perspective. In the other areas, this is quite a popular approach called “User-first.

The implications of this rule for versioning are:

  1. Avoid breaking changes as much as possible
  2. Follow the “consumer-view” approach to determine what number to bump
  3. To determine whether something is breaking, assume the worst case

In the following, I will refer as “rule” to “consumer-first” idea and as “implication first/second/third” to the list of implications mentioned above.

1. How to avoid breaking changes

Of course, it’s not always possible to avoid breaking changes. However, the number of times when it’s possible can be higher than you think.

For example, you have a function onUpdate that accepts three arguments: target, old value, and new value. It turned out that you need to pass also the fourth argument whether the update happens for the first time or not. One way to do it is to change the list of arguments to object and pass the arguments as keys there. That would be breaking. Another option is to add a fourth argument. It’s not breaking, but 4 arguments could be too much. The third option is to create new functions onFirstUpdate and onSubseqUpdate that are triggered for the first time and for the rest updates.

Another way is to introduce the breaking change behind a flag. For example, you move a function to a new place. In the beginning, leave the call also in the old place but add console.log that this function is deprecated and consumers need to use this function from a new place. Don’t forget about the possibility of disabling this notice (for example, with anENV variable in the build). You can also add an ENV variable to throw an error instead of logging it. Then your consumers can verify that their code will keep working after a major update. This is a gradual approach of introducing breaking changes that gives more time to your consumers to prepare and to start moving earlier.

Alternatively, you can ship a codemod (more about them you can read in the article about theory and another one about practice). Then the breaking change is covered with an automatic update that simplifies the migration.

2. Consumer-view approach

SemVer says that a major is a breaking change, a minor is backward-compatible with new features and a patch is a backward-compatible bugfix.

I want to decouple the definition from features/bugfixes and focus on compatibility:

  1. Major — this change is backward-incompatible (as in official SemVer documentation)
  2. Minor — this change is is backward-compatible BUT forward-incompatible
  3. Patch — this change is is backward AND forward-compatible

Backward-incompatible means that the next version is not compatible with the previous version (meaning version 2 is not compatible with version 1). An example of this is when a function from an API is removed or renamed: the function add is renamed to addNumber.

Forward compatible means that the previous version is not compatible with the following (meaning version 1 is not compatible with version 2). An example of this is when a function is added to API: function add is now available.

3. Hope for the best, but prepare for the worst

Also, a note about compatibility — compatible or not always depends on one’s particular code. But for the sake of simplicity, the worst case is considered. Therefore, “maybe incompatible” means “incompatible”.

Validation of this rule

There are many different experiences of maintaining open-source projects (and thus versioning). Lately, I’ve read a nice article about SemVer (highly recommended) from an open-source maintainer and decided to validate the rule against the lessons from there since they feel quite reasonable to me.
The summarizing lessons from this article, related to the SemVer (it’s a quote, spelling and punctuation of the author preserved):

  1. Internal changes, like optimizations and refactorings, get either a patch bump or a minor bump if they’re substantial, whatever that means.
  2. Bump the highest component in releases with multiple changes: bug fix + feature = minor.
  3. A breaking change in a patch may be OK if it fixes a bug, and users are unlikely to depend on the broken behavior.
  4. Features don’t change the API can fit into a patch.
  5. If a bug fix touches the API, it’s a feature, so it gets a minor bump.
  6. Updating dependencies affects your version as much as you expose their API.
  7. Updating peer dependencies is breaking.
  8. Dropping browser / runtime compatibility is breaking.

Let’s check how the rule “customer-first” works for the lessons above. The rule consists of three implications that we will check for correctness:

  1. Internal changes are backward and forward compatible, so it should be a patch version. Why is it important to have a patch and not a minor? Because, if the changes contain a mistake, consumers should know they can safely decrease the version (according to implications 2 and 3).
  2. One new feature and one bug fix give a forward incompatible feature, that’s why the minor should be increased (according to implications 2 and 3).
  3. Unfortunately, not according to implication 3. Even if this is an obvious fix like from the example when onClosed is called when the component gets opened, with many users you can assume somebody relies on it. The trick though is that you don’t necessarily need to make a breaking change. Even in this scenario, you can add new openCallback and closeCallback that are called in the correct time and deprecate onClosed. In most cases, it’s possible to avoid breaking changes (implication 1).
  4. Do the changes with such features keep forward compatibility? Then absolutely yes, according to implication 2 they can be put in the patch.
  5. The same. API changed in a non-forward compatible way — it should be a minor bump according to implication 2.
  6. Indeed. Exposed API of the underlying components become your API and then implication 2 applies.
  7. Peer dependencies specify reverse constraints. The child package specifies the dependency that should be installed by the hosting package. Following implication 3, we know that the hosting package depends on the previous version or peer, and updating it is breaking.
  8. Absolutely great point in the article. Publicly mentioned supported platforms/environments/browsers are part of the features set, promised by the package. Changing them is essentially changing API and must be considered accordingly.

The rule successfully passed the validation against all lessons above (you did not expect anything else, did you?). The implications of the rule go along with the lessons learned after years of open-source maintenance.

Versioning in JavaScript

There are not many JavaScript package managers that are not working on top of NPM. To be honest, I know only Bower that is deprecated, so we focus on NPM.

NPM uses semantic versioning, so it’s pretty simple here. There is a command npm version that allows bumping the needed version.

If you want to specify the acceptable versions, you can use the syntax below (according to their documentation):

  • Patch releases: 1.0 or 1.0.x or ~1.0.4
  • Minor releases: 1 or 1.x or ^1.0.4
  • Major releases: * or x

Exception or NPM caveat you should be aware

Caret Ranges (^1.2.3 or ^0.2.3 or ^0.0.3) are usually used to specify the allowance of non-breaking changes. Therefore, the expectations or the version ^1.2.3 is `≥1.2.3 && <2.0.0 that is absolutely correct. And for ^0.2.3 the expectations are ≥0.2.3 && <1.0.0. However, that is not correct. According to the definition of caret ranges in the README.md of the NPM packages that does the resolution, the caret works as follows:

Allows changes that do not modify the left-most non-zero element in the [major, minor, patch] tuple

Therefore, the resolution for 0.2.3 is ≥0.2.3 && <0.3.0 (0.2.X) and for ^0.0.3 is 0.0.3 only.

Summary

The key points of this article:

  1. Think consumer-first. To determine what version you should bump in your package, think about compatibility from the consumer perspective. Is it backward/forward compatible? Then the best is to bump the patch version even though it could be that it’s a new feature. Also, consumers don’t want to update their code (try to avoid breaking changes) and use your package most unexpectedly (always assume the worst)
  2. Be careful with the versions below zero when you publish or consume them because of caret resolution. It’s even better/easier to avoid them altogether.

--

--