In my previous post on Moving to a Monorepo I discussed a journey of taking several existing npm-based git repositories and migrating to a Lerna monorepository, alongside some common techniques for testing, building and deploying code within that context. One of the topics I briefly covered, but not in a huge amount of detail, was Lerna's independent versioning mode. This is largely because in my day job I'm currently running in a fixed mode monorepo. I'm now considering making the switch to using independent versioning and I thought I'd document some of the benefits on why this would be a good idea.

It would be best to follow along as much as possible with this and as such I'll provide lots of detail at each step.

Setup

We're going to set up an incredibly simple Lerna monorepo, with two packages, one depending on the other. There's nothing fancy here. First, initialise a lerna repo in independent mode into a new directory:

mkdir lerna-demo
cd lerna-demo
lerna init --independent

This will create the necessary top-level files (lerna.json, package.json) as well as initialise an empty git repository. Examine your lerna.json file to notice that the version field is not hard-coded, instead it's set to 'independent':

{
  "lerna": "2.11.0",
  "packages": [
    "packages/*"
  ],
  "version": "independent"
}

Next we'll create the two packages:

mkdir packages/app packages/lib
lerna create app
lerna create lib

And link lib package as a dependency of the app package:

lerna add lib --scope=app

The lib package will export a simple greeting function, in packages/lib/index.js:

function greeting(name) {
    return "Hello, " + name + "!";
}
module.exports = greeting;

The app package will consume this function, call it, then log the output (packages/app/index.js):

const greeting = require('lib');

console.log(greeting("Sam"));

Add a start script to the packages/app/package.json:

{
    "scripts": {
        "start": "node ."
    }
}

For completeness, don't forget to add a .gitignore:

**/node_modules
lerna-debug.log

So that when we run lerna run start, this produces in your terminal:

Hello, Sam!

How Does Independent Versioning Work?

If we take our sample code above and commit it under the following format:

git commit -m "feat: Log a name"

It is possible to run the lerna publish command, with an additional --conventional-commits flag. This will look at the files changed in each package, combined with the associated commit messages, to work out what packages to publish and their versions.

So as we wrapped up all the above into one commit of type feat, running lerna publish --conventional-commits produces:

Changes:

  • app 1.0.0 -> 1.1.0
  • lib 1.0.0 -> 1.1.0

If you accept the changes, then Lerna will publish to npm (unless you add the --skip-npm flag). It will also create a new commit in your repo, whose mesage contains the names of the versions that you're updating (unless you add the --skip-git flag):

Publish

  • app@1.1.0
  • lib@1.1.0

A tag is created for each package that was built, pointing at this commit:

git tag -l

app@1.1.0 lib@1.1.0

The git commit message itself can be configured in one of two ways: either with a flag:

lerna publish -m "chore: release"

Or alternatively via your lerna.json file:

{
  "commands": {
    "publish": {
      "message": "chore: release"
    }
  }
}

Finally, in each package a CHANGELOG.md file is created/appended to containing the details of exactly what's changed in this version. This is a handy utility to pass to your QA, release team and customers if necessary, making the change process relatively self-documenting. An example from above:

1.1.0 (2018-08-16)

Features

  • Log a name beeab41

Semantic Versioning

The key advantage with independent mode over fixed mode is (naturally) that the versions of your packages can be updated independently, and therefore communicate semantic information with their version numbers. Larger monorepo solutions, which may host several "top-level" application packages as well as a collection of utility/library packages can therefore benefit from evolving independently, whilst using version numbers as a guide as to what has functionally changed within a

Say, for instance, that we make a change to the lib package above, so that the format of the argument expected in the greeting function is now different:

function greeting(value) {
    return "Hello, " + value.name "!";
}

This is of course a breaking change, so we'll commit this change to the repo. Using the Conventional Commits specification specification, we can clearly communicate breaking changes:

git commit -m "feat: Re-work the greeting function

BREAKING CHANGE: The argument to greeting is no longer an object, just a string"

Secondly, we'll need to adjust the app package to work to this new format:

console.log(greeting({ name: "Sam" }));

Now, depending on your personal opinion, you may class this as a breaking change to the consumer of lib. I personally don't, because the output of the consumer hasn't changed - it's just changed what it needed to do in order to get that output. There's no right answer to whether a breaking change in a library is a breaking change in a consumer, it will depend on a case-by-case basis. But what's great is you have the choice on a case-by-case basis, you're not constrained to dedciding this up-front. So for this instance I'll class it as a chore, which is a minor version bump:

git commit -m "chore: update to new lib version

Now when I run the next publish command, I'll see the following change:

Changes:

  • app 1.1.0 -> 1.1.1
  • lib 1.1.0 -> 2.0.0

Notice that only the minor version of app has changed, but the major version of lib has changed.

You'll notice to get this to work I had to implement the change as two separate commits. Fans of single, atomic changes may find this slightly frustrating, but if everything is rolled into one commit, you're constrained to declaring all packages affected by that change under the same semantic version type - both app and lib would be bumped a major version to 2.1.0. This may make incremental commits break if ran in isolation, unfortunately this is something you'll have to live with if you go down this route. In my opinion the benefit outweighs the marginal cost.

Conclusion

Working with independent mode in Lerna is pretty easy and pretty powerful. With the combined usage of Conventional Commits you can effectively utilise semantic versioning for your package versions, to communicate the reaason for changes to a package and automatically generate a changelog file. You could further combine the use of Conventional Commits with a tool such as Commitizen, which will ensuring that commits to the repository all utilise the correct syntax.