Lerna Independent Mode with Semver
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.