Type for search...
codete Versioning with Sem Ver and Conventional Commits 1 main cbc3f2ab4d
Codete Blog

Semantic Versioning(SemVer) with Conventional Commits

avatar male f667854eaa

05/03/2019 |

20 min read

Maciej Komorowski

In this article, you will learn why it's important to version your project and how you can introduce versioning with Conventional Commits and standard-version. Let's start with refreshing our knowledge about one of the most popular versioning conventions.


Semantic Versioning Explained (a quick recap)

You have probably already seen some kind of versioning many times. If you have ever been fiddling around files like package.json you must have seen versioning annotations like for example >=2.3.1

The point is, there are other ways to differentiate releases than simply increasing a number. Each package manager has its own flavor of versioning annotation, but all of them have one thing in common – Semantic Versioning or SemVer for short. 1.0.0-beta1 is a full example of a SemVer version. 

Translating it to the common language would go like this, it's the second beta of a first major release. The template for such a version is MAJOR.MINOR.PATCH (e.g 0.2.13). Optionally, a version can have a pre-release flag like so MAJOR.MINOR.PATCH-PRE

Let's examine what each version segment means:

  • PATCH - Whenever you make a backward-compatible bug fix, no new functionality is added.
  • MINOR - Whenever you add new functionality in a backward-compatible manner, old functionalities are preserved.
  • MAJOR - Whenever you make incompatible API changes known as "breaking change". That means, that the old part of your software will work differently and users of your software must accommodate the new ways
  • PRE - It stands for pre-release. Using the PRE segment is optional, but could be quite useful. If you are working on the next big release of your project, you may not get it stable on the first try. It will take at least a few iterations to get it right. You could use alpha ~> beta ~> rc (release candidate) methodology to tag your progress on the semantic release. Each of those tags could be incremented, so from rc1 you can go to rc2. That will give you very granular control over your releases.

The simplest decision tree of how to increment a semantic release would look like this:

incrementing semantic version ceb9e8bbe2

Changing version numbers should always have an incremental form. 

That's why it's called bumping. You cannot go from 1.1.14 to 1.0.15. That could be very confusing. You can only go up. So, from 1.1.14 you can go either 1.1.15, 1.2.0 or 2.0.0

Lastly, remember that the PRE segment is meant to annotate versions before the final release. Version order 2.0.0 ~> 2.0.0-rc0 ~> 2.0.0-rc1 is invalid. More details about SemVer rules could be found in Semantic Versioning Specification.

Alright, that looks like a no-brainer, so you may ask...


Why should I even care about versioning?

I guess that for more experienced developers the answer to that question is pretty obvious, but if you still have doubts I highly encourage you not to skip this chapter.

The core value of versioning is assurance that a given version of the software will always represent one and unique state of that software. That means two things. Firstly, for a given version everyone will get exactly the same piece of code. In other words, all copies of the software with the same version will be exactly the same. Secondly, if you upgrade from version `A` to `B` and afterward you decide to go back to `A` you will certainly get the exact same version of the code that you used before. 

That two properties of versioning, uniform code distribution, and release (represented with version) immutability are the foundations of modern software distribution and are directly guaranteed by the versioning.

The other equally valuable advantage of versioning is providing users with information on what they might expect from the version change. If the software proprietor applies to SemVer rules, the utilizer will have no doubts if they can update the software safely. 

Imagine that you are using an external package to calculate currency exchange. You are currently using version 1.2.10 of that package. Although, there had already been many releases since that version you were not keen to update because no bug fixes/features that apply to you were introduced. Suddenly, version 2.2.0 is released and it has support for a whole bunch of new currencies that you just need in your project. 

Now, you cannot just upgrade the package. The difference in major versions means that a breaking change was done and the new version might cause an execution failure. Before upgrading, you need to investigate the breaking changes and how you need to accommodate them.

Side note: If you are planning to create some piece of software that will be distributed among a broad audience, the good practice would be to add deprecation warnings in the following minor versions before breaking anything and going major. All mature frameworks do that (Rails or React to name a few).

For certain projects, versioning it's a must. If you ever published an NPM package, then you must know that providing a version is a requirement. Moreover, you are obligated to change the version whenever you make any changes. And it is OK, cause folks need to be sure that pulling packages with the same version at different points in time will always have the same contents. 

In short, if you are creating some kind of package or lib that will be shared with others, versioning is not just good practice but a necessity. You must remember about an incremental way of changing your version and stick with SemVer rules during that process. Users of your software will be grateful.


GET /health_check

I know that many web developers hesitate to version their projects, but if you decide to version, it's a good idea to be open about it. Participants of the project (clients, QA devs, your PM, and many others) should be able to know what is the current version of the product that they are using. 

Sole health check endpoint is a subject for a different article, but I think it's a good place to publish your version number. You could mount it under the /health_check endpoint, but there is no strict convention for that and you could simply decide what works best for you.

Simplest /health_check response could look like this.

  "version": "1.0.0"

Once you have your health check endpoint running you can do many useful things with it. For example, add the version check after deployment to your integration scripts to verify if it was truly successful.

By the way, if you ever wonder how to get a version from package.json in your JS app. Here is a quick snippet:

import { version } from "./package.json";

Yeah, that is surprisingly easy, but let's don't forget that JSON format was designed to have native JavaScript support.


Conventional Commits

Revision control and versioning are like bread and butter. I cannot imagine proper versioning without good RC (e.g Git). Conventional Commits is a convention for converting human-written commit messages into machine-readable ones. It also happens to nicely follow the convention for SemVer.

It turns out that versioning and smart usage of Git come in handy. There is a set of rules regarding how you should commit your code called Conventional Commits that might bring you great benefit. 


Why use Conventional Commits?

Benefits of using Conventional Commits, according to Conventional Commits website, include: 

  • generating CHANGELOGs automatically,
  • automatically determining semantic version bumps (based on commits types),
  • informing stakeholders (e.g. teammates, the public) about the introduced changes and their nature,
  • setting off build and publish processes,
  • making contributing to your projects easier thanks a better structured commit history available for exploration.

Looks pretty nice, doesn't it? Let's learn how to use Conventional Commits properly. The foundation of CC is the right commit message that has the following structure:

<type>[optional scope]: <description>
[optional body]
[optional footer]

The specification will tell you more about those keywords.

A simple example of such a commit message might be:

feat: Search products by EAN code

If you want to be more verbose about your commit, you could include an optional body and footer that might be included in the CHANGELOG.

fix: Sorting products by a price
Sorting was not working properly cause prices were compared as string values instead of compared as floats.
Resolves TICKET-201

You might also specify a scope of the change by writing the scope name in the parenthesis like so:

fix(ui): Display top bar correctly on iPhone X

I personally believe that scoped commits are great for Monorepos. For example, if you are using Lerna to manage your codebase, you could use the name of the package in my-lerna-repo/packages dir as a scope name. Scoping a commit is not a must, but might give you a better insight into the changes between consecutive versions.

Lastly, if you anticipate that your commit might break backward compatibility you **must** add BREAKING CHANGE: text at the beginning of its optional body or footer section. Doing so will have some serious implications, because as SemVer rules, any breaking change will require releasing a new major version.

You should always plan and notify ahead before realizing a major version. Keep a considerable time span between your major versions. People might be struggling to keep up with breaking changes if you release a major version every second week. If you are creating software that will be a dependency for other systems (say NPM package), I would propose that the time span should be at least one year, but it may vary drastically. Let's see, how "breaking change" commit might look like:

feat: Rename 'sort_by' function to `sortBy`
`BREAKING CHANGE:` Rename 'sort_by' function to the camel case form in order to be consistent with the rest of the API

Such a commit message will cause a major version release and that will be information for the utilizers that they need to see the CHANGELOG and find what breaking changes were made. If they don't accommodate them, script execution might be a failure, since the sort_by method does not exist anymore.

Commit types fix and feat are restricted and thoroughly documented in CC specification, but those are not the only ones that you can use. Among these, you can come up with your own types, like chore, docs and refactor. As long as the prefix list is short and consistent you are good to go. 

Personally, I just use chore. These additional commit types are for changes that do not alter behavior, therefore they will not bump the version number. So, if I made a change in the README, I would write chore: Add Usage section to the README. During the next release, this commit will be omitted in the CHANGELOG and will have no impact on the next version number.


Automate versioning with the standard version 

Ok, now we know what the deal is about. Let's use it for practice. Thankfully, there is a tool that will come in very handy. Say hello to the standard-version.

standard-version does the following:

  1. bumps the version in metadata files (package.json, composer.json, etc.)
  2. uses conventional-changelog to update CHANGELOG.md
  3. commits package.json (et al.) and CHANGELOG.md
  4. tags a new release


SemVer x NPM: semantic versioning in NPM

Let's start from the beginning and install standard-version. This short tutorial will assume that your project is using package.json and you have NPM installed on your machine. If your project does not contain a package.json file, take a look at the end of this chapter, where I included a guide for standalone usage of standard-version.

npm i --save-dev standard-version

The command above will install the tool and add it to devDependencies in the package.json. Now, add the bump script in the same file.

    // ...
    // omitted part of the package.json
    "scripts": {
        "bump": "standard-version"

We are ready now for an initial release. Run the following command to create the first release. Note that --first-release will not bump the version number. The initial release will be marked with whatever version you have right now in the package.json. In my case, it's 0.1.0.

npm run bump -- --first-release

So, a few things happened now. First of all, we have a brand new CHANGELOG file. It should look more or less like this:

# Change Log
All notable changes to this project will be documented in this file. See standard-version for commit guidelines.
<a name="0.1.0"></a>
# 0.1.0 (2019-01-06)

Secondly, we have a new commit of the release.

git log --name-status HEAD^..HEAD
commit de1f8fb385d9512dcda66d38f61e754c7ad5fca4 (HEAD -> master, tag: v0.1.0)
Author: Maciej Komorowski <mckomo@gmail.com>
Date:   Sun Jan 6 21:40:01 2019 +0100
    chore(release): 0.1.0
A       CHANGELOG.md

Finally, there is a new tag v0.1.0.

git tag

To push the new commit with the tag you can use git push --follow-tags origin master. If you keep your remote repository in GitHub, the presence of the new tag will create a new release. You will learn more about that in the next chapter where I will present my whole Git workflow that utilizes Conventional Commits and standard-version.

git push --follow-tags origin master
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 8 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 604 bytes | 604.00 KiB/s, done.
Total 4 (delta 1), reused 0 (delta 0)
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To github.com:mckomo/my-app.git
   c49bc43..de1f8fb  master -> master
 * [new tag]         v0.1.0 -> v0.1.0

From now on, you can bump your version with npm run bump. And that's it. You don't need to specify what part of the version you want to bump. If you utilize Conventional Commits correctly, standard-version will figure out how the version should be bumped. The goal of this post is not to present standard-version in depth. It has many other useful options. I recommend taking a peek at CLI usage of standard-version.


Not a JS developer?

You might have noticed that standard-version is pretty oriented in the JavaScript ecosystem. So far it has support for package.json, bower.json, manifest.json, and composer.json that is used by PHP. If you have any of those files, you can still use standard-version, just install it globally with NPM.

npm install --global standard-version

Now you can use it at the root of your project:

standard-version [optional_args]

So far I have not encountered similar tools oriented on other languages. I really hope that with the increasing popularity of Conventional Commits other developer communities will come up with their own tooling. Maybe you heard about something interesting. If so, let us know in the comments section.


Simple Git workflow

I think it's the highest time to use all that knowledge and see how it works from end to end. I will present here a Git workflow that utilizes standard-version and Conventional Commits. I use something very similar in my everyday work and I perceive this approach as simple and efficient.

Let's assume that we already have an existing application that has a package.json file. Now, we are going to carry out a series of changes in our project that will help us to understand how to use introduced techniques and tools. Our objective is to deliver a user registration feature. Naturally, I will omit code implementation as we are going to focus on our Git activity.

As always before starting work on a new feature I want to branch out from the current master.

git checkout master && git pull
git checkout -b feat-user-registration

We have a new branch, so let's get to work. The feature will require touching many areas of our project, thus we will probably create at least a few commits during the process of implementation. Here is how the list of commits may look like:

git log --pretty=format:"%h %s"
497dbcd3e UI for login and registration page
8f6bd245b Working on Account model
af6ece777 Fishing up Account model + tests
19883ac37 Create session controller for authentication
41bc6067c Add routing for registration
612f07009 Add SMTP package
0c9423678 Mail users after successful registration

As you can see, my commits are not very tedious and verbose. I actually think of it as a log of my changes, that will help me to track my changes. Still, I have one WIP (work-in-progress) commit, but that's fine. These commits will be squashed later on, so I'm trying to come up with something that works for me. I have a habit of pushing my code before the end of the day, so WIP commits are acceptable for me if the only commit message contains a subject of the changed component. 

Another thing I like to do is to keep my commits as small as possible. Working on the feature can drag for many days and I want to have an easy way to see my changes from previous days. Imagine that I want to remind myself how I implemented routing for user registration. If I kept all changes together and unorganized, finding the diff in the routing file might be difficult and time-consuming, but since I have the commit just for routing I can easily check my new routes.

git diff 41bc6067c^!
diff --git a/app/routing.ts b/app/routing.ts
index 19883ac37..41bc6067c 100644
--- a/app/routing.ts
+++ b/app/routing.ts
@@ -1,2 +1,5 @@
import home from 'controllers/home'
+import session from 'controllers/session'
app.use('/', home);
+app.use('/account', session);

Ok, it seems like we are ready to create a new Pull Request. Let's go to GitHub and create one.

github pull request c86e7bdf4a

The most important thing to observe is the PR title. I've used the Conventional Commits prefix and that's the only place it should be used. After I will squash and merge my PR to the master, the PR title will appear as a merge commit message and based on that message standard-version will create a new version release.

A few moments passed by and our pull request got approved by our coworkers. It's the highest time to release it. Let's go to GitHub one more time and merge our PR using the "Squash and merge" button.

squash and merge button b3b48d80a4

Once you click on the squash button, a new window will appear, where you can enter the optional commit body. Note that in the title of the PR additional (#2) string appeared. That's the GitHub reference to your PR. The hash number means an ID of your pull request. You can remove it, but I find it helpful. For now, let it be.

confirm squash and merge 15c2354d2d

The merge was done, don't forget to remove the feature branch. Now, let's see what our squash looks like in the Git log.

git checkout master && git pull
git log
commit 1e26dcfb7b29e60dd594f73b5e620a2862839c10 (HEAD -> master, origin/master, origin/HEAD)
Author: Mckomo <mckomo@gmail.com>
Date:   Sun Jan 13 13:48:56 2019 +0100
feat: User registration (#2)
Add user registration functionality. The registration form is available for guests under "/account/register" path.
commit de1f8fb385d9512dcda66d38f61e754c7ad5fca4 (tag: v0.1.0)
Author: Maciej Komorowski <mckomo@gmail.com>
Date:   Sun Jan 6 21:40:01 2019 +0100
chore(release): 0.1.0

You can see only one new commit. There is no merge commit, all previous commits are gone as well. Everything is packed in the one squashed commit. After our PR found its place on the master branch we are ready to release a new version. That's deadly simple. The only thing we need to do is to run standard-version and push a new version (don't forget about --follow-tags).

npm run bump
> my-app@0.1.0 bump /Users/mckomo/Projects/my-app
> standard-version
✔ bumping version in package.json from 0.1.0 to 0.2.0
✔ bumping version in package-lock.json from 0.1.0 to 0.2.0
✔ outputting changes to CHANGELOG.md
✔ committing package-lock.json and package.json and CHANGELOG.md
✔ tagging release v0.2.0
ℹ Run `git push --follow-tags origin master` to publish
git push --follow-tags
Enumerating objects: 10, done.
Counting objects: 100% (10/10), done.
Delta compression using up to 4 threads
Compressing objects: 100% (6/6), done.
Writing objects: 100% (6/6), 104.81 KiB | 701.00 KiB/s, done.
Total 6 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To github.com:mckomo/my-app.git
   1e26dcf..d9bf988  master -> master
 * [new tag]         v0.2.0 -> v0.2.0

A few things happened now:

  1. we bumped the version from `0.1.0` to `0.2.0`;
  2. we have a new entry in the changelog;
  3. we have a new tag `v0.2.0`.

This is how my CHANGELOG looks like now:

Change Log
All notable changes to this project will be documented in this file. See standard-version for commit guidelines.
<a name="0.2.0"></a>
# [0.2.0](https://github.com/mckomo/my-app/compare/v0.1.0...v0.2.0) (2019-01-13)
User registration ([#2](https://github.com/mckomo/my-app/issues/2)) ([1e26dcf](https://github.com/mckomo/my-app/commit/1e26dcf))
<a name="0.1.0"></a>
# 0.1.0 (2019-01-06)

If we pushed our changes with --follow-tags then the new tag should appear on GitHub as a new release:

new release 5f48860396

And that's it. The whole workflow is complete now. You can use the exact same workflow for fixes or breaking changes. Just remember about the correct Conventional Commit prefix.


Always squash commits

Conventional Commits advocates to always squash your commits and I understand that you might be a little bit defensive about that. Surely, you have done so many great commits while working on a feature. You spend so much time thinking about the right commit message and now you are about to destroy all that hard work of yours. 

To be honest, I wasn't a huge fan of that idea either, but after a short while, I don't see a reason to go back. There are probably many discussions about why squashing commits is good (or bad), but I would like to point out a few reasons that come from my own experience.

  1. Squash commits result in a much cleaner Git history. As presented in this short tutorial, squashed PR condenses all your Git activity in one meaningful commit.
  2. Some may say that squashing commits means losing information for the previous commit messages. I highly argue with that. When squashing a few commits you need to think of a message that will describe all changes you conceived this will be general. This generality will provide you a context later on when someone else will research your code by checking Git history. For example, the commit message Add SMTP package does not give me any extra information, because you can clearly see that when looking at commit changes. But if the package was added in the commit with the message "feat: User registration" you will probably figure out that SMTP support was required cause maybe we send users an email after we are registered and that's at least something.
  3. Lastly, we avoid all nasty commits like the infamous WIP commits. On the other hand, we don't have to think that much about correct commit messages, because all of them will be squashed at the end of the day. Any commit message that works for us will be just fine. Isn't it nice?


Continuous Delivery

Versioning and Continuous Delivery (CD) might not go well together if you do not apply the correct configuration. If your CD is triggered every time you push something to master, then utilizing the presented Git workflow may be problematic. The simplest solution is to change your CD configuration to be triggered only for release commits. That way you will avoid unwanted CD triggering.


Versioning is an open subject and we are always willing to hear about your approach. Do not hesitate to leave your feedback in the comments section below. :)

Rated: 5.0 / 1 opinions
avatar male f667854eaa

Maciej Komorowski

Senior Software Engineer

Our mission is to accelerate your growth through technology

Contact us

Codete Przystalski Olechowski Śmiałek
Spółka Komandytowa

Na Zjeździe 11
30-527 Kraków

NIP (VAT-ID): PL6762460401
REGON: 122745429
KRS: 0000696869

  • Kraków

    Na Zjeździe 11
    30-527 Kraków

  • Lublin

    Wojciechowska 7E
    20-704 Lublin

  • Berlin

    Wattstraße 11
    13355 Berlin

Copyright 2022 Codete