08Aug
Setting Up Automated Semantic Versioning For Your NodeJS Project
Setting Up Automated Semantic Versioning For Your NodeJS Project

Over the course of writing a piece of software, you will find it incredibly helpful to be able to track different fixed states your project is in. One of the largest problems this solves is dependency hell.

Dependency hell can take many forms, but generally occurs when your project and a library in your project rely on different incompatible versions of the same dependency. If your system doesn’t allow more than one instance of the library to be installed, you’d have to either drop the library or rely on an old version of the dependency. In doing so, other projects that rely on the same dependency would have to be updated… and so on.

That’s obviously not ideal.

And the saddest part is no matter what kind of project you have, as long as it has more than a couple dozen dependencies, dependency hell is inevitable.

The simplest and most common solution to this problem is semantic versioning.

What is Semantic Versioning?

Software versioning in general refers to assigning numbers or codenames to certain fixed states of your software. This version number is usually incremental depending on the kinds of changes introduced into the software.

Semantic versioning (semver) refers to a system of versioning software where releases of your software can be grouped as either major, minor or patch. This is represented using numbers in the format X.Y.Z.

  • Bug fixes that don’t change the API increment the patch version. eg. 0.1.1 -> 0.1.2
  • Additions that don’t change the API but add new functionality increment the minor number. eg. 0.1.1 -> 0.2.1
  • API-breaking changes increment the major number, eg. 0.1.1 -> 1.1.1

While this is incredibly useful on its own for keeping track of changes in our code, the benefits of using semantic versioning in our project don’t end there. Some features you will find incredibly helpful as a result include:

  • Generating a changelog.
  • Making your commit messages more readable.
  • Automatically bumping the package version and uploading it to npm.

In order to track the changes to our API in an automated way, we need a formal introduction to another popular spec: conventional commits.

An introduction to conventional commits

Conventional commits refers to a way of writing commit messages so that each can be tracked as a patch, a fix or a breaking change. If you’ve ever explored the Angular Commit Guide, the rules behind it are the same.

  • A patch is indicated using the prefix fix: eg. fix: corrects typo.
  • A minor change is indicated using the prefix feat: eg. feat: introduces login functionality
  • A major change is indicated using the text BREAKING CHANGE anywhere inside the commit, ie. a major change can be of any type.
  • An optional scope can be provided with every commit message, eg. fix(Login): fixes bug where users have to login twice
  • More prefixes are allowed by the spec, eg, refactor, patch, chore, docs, perf, test… etc, that may or may not trigger a new release.

With these tools in our arsenal, we have enough weaponry to tackle this problem head-on.

Automated semantic versioning with semantic-release

The semantic versioning spec is incredibly useful for documenting changes in your code. However, a major bottleneck that still exists is the attachment of human emotion to the process of versioning the software. In other words, a developer might succumb to the temptation of not incrementing their version numbers for some reason, or, more likely, they might just forget.

Semantic-release is a NodeJS library built to tackle that exact problem. Rather than letting the developer manually bump the package version, this library (with the help of plugins) analyzes your commits and automatically increments the relevant package version for you with every new release.

This project relies on express-starter, which sets our initial project version to 0.0.0.

Let’s kick it off with our initial commit:

git add . 
git commit -m "feat(): initial commit"

Next, install semantic-release

npm install --save-dev semantic-release

Then create a <code.releaserc and add the following settings

module.exports = {
    repositoryUrl: 'https://github.com/Bradleykingz/semantic-release-tutorial',
    branches: ['master'],
}

Before we’re ready to set up our first release, we need to create a Github token with the following permissions:

Github access tokens permissions
Github access tokens permissions

Then export it in your command line

export GH_TOKEN=<your github token>

If we commit the configuration file and run npx semantic-release

git add . 
git commit -m "chore(): adds semantic release config"
npx semantic-release

We get the following output:

Console output
Console output

Notice that:

feat triggered an incremental major release.
chore doesn’t trigger a release, ie, the package version is not incremented.
This doesn’t actually have an effect on our package.json because semantic release runs  a dry run by default. In order to actually work, we need to set a CI environment.

To see semantic release in action, let’s set up a dummy project with Github Actions.

Create a ‘.github/workflows/release.yml’ file with the following input

name: Hello World

on:
  push:
    branches: [ master ]

jobs:
  Test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout source code
        uses: actions/checkout@v2
      - name: Use NodeJS v12.16
        uses: actions/setup-node@v1
        with:
          node-version: 12.16
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm run test
      - name: Create new release
        env:
          GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
        run: npx semantic-release

All this does is checkout our source code, run tests and trigger a release. Notice that these steps will only be run when a new branch is pushed against master

In this workflows, pushing directly to master is restricted except when merging. Basically, a new release will only be created when merging to master.

For the purposes of this test, we’re going to create a dummy test using mocha and chai-http.

npm install mocha chai-http -D
git commit -m "chore(): adds test dependencies"

Since our release will only be triggered on merge, we also need to checkout a new branch.

git checkout -b feat/add-tests

Then create a new test/dummy.js test file.

let chai = require("chai");
let chaiHttp = require("chai-http");
let server=require("../app");
let should = chai.should();
chai.use(chaiHttp);

describe("Root", function(){
  describe ("Get /", function(){
    it("should return a 200 code", done=>{
      chai.request(server)
          .get("/")
          .send({})
          .end((err,res)=>{
            res.should.have.status(200);
            res.body.should.be.deep.equals({message: "You did it!"})
            done()
          })
    })

  })
})

This queries our dummy server and ensures the message it returns is the same as what we expect.

Of course, don’t forget to commit that:

git commit -m "test(): adds new test"

And push it

git push origin feat/add-tests

So that when we eventually merge to master, a v1.0.0 is created

Bonus: creating pre-releases

A pre-release is a ‘release that comes before the actual release’. To understand it, we have to change our workflow a bit.

Before, we only had the master branch and additional branches named in the format ‘feat/*’. These latter were for working on individual features that are then merged into master. Pushing to master directly is restricted, and new code is checked out from the master branch.

We need an additional branch – beta. Pushing directly to master is still forbidden. However, so is pushing directly to beta. Additionally, we can no longer merge our code to master directly. All code must first be merged into beta and only then can it end up on beta. This allows us to carry out integration tests and respond to feedback before our code ends up on the production branch.

Let’s go ahead and create it:

# from the master branch
git checkout -b beta
git push origin beta

And slightly modify our .releaserc.js to add the following content:

module.exports = {
  plugins: [
    "@semantic-release/commit-analyzer", 
    "@semantic-release/github",
    ["@semantic-release/npm", {
      npmPublish: false
    }],
    ["@semantic-release/release-notes-generator"],
    ["@semantic-release/git", {
      "assets": ["package.json"],
      "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
    }]
  ],
  branches: ['master'],
  preset: 'angular'
}

These are going to run in the order they are listed here. A rough overview of what happens is:

  1. It goes through your commits and decides whether the changes made should lead to the creation of a new release.
  2. It pings github using your GH_TOKEN to find out what the latest releases on your project are (you can, naturally, use a git provider of your choice. We use Github.)
  3. It bumps your package.json version (and doesn’t publish to npm)
  4. It scans through your commits and creates release notes.
  5. It commits your recently-changed package.json file with a message.

Now, any branch with the matching the pattern feat/* will be recognized and released in the alpha distribution channel. In this case, our release should be v1.0.0-alpha.1 since we have never made a release before.

Once we merge onto the beta branch, a new pre-release will be created as well, and published on the beta distribution channel. The version number will be v1.0.0-beta.1.

And, finally, we can create the v1.1.0 release by merging the beta branch to master.

The code can be found at: https://github.com/Bradleykingz/semantic-release-tutorial. Be sure to head over to the ‘actions’ tab to see the logs in action.

Note: The project might have a few more releases than v1.1.0 because of various bug fixes that had to be made.

Conclusion

This article served as an introduction to Semantic Versioning and Conventional Commits. However, it just scratches the surface of all the amazing things semantic-release can do for you. In a more advanced setup, you will probably want to have:

  • A semantic-release plugin to ensure every commit corresponds to the Conventional Commits spec.
  • A semantic-release plugin to automatically create changelogs.
  • A plugin to publish your library to a registry like npm or Github Releases.
  • A Github plugin to ensure pull request is made with a conforming message.

Leave a Reply