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.
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:
This will create the necessary top-level files (
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’:
Next we’ll create the two packages:
And link lib package as a dependency of the app package:
The lib package will export a simple greeting function, in
The app package will consume this function, call it, then log the output (
Add a start script to the
For completeness, don’t forget to add a
So that when we run
lerna run start, this produces in your terminal:
How Does Independent Versioning Work?
If we take our sample code above and commit it under the following format:
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
lerna publish --conventional-commits produces:
- 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
A tag is created for each package that was built, pointing at this commit:
The git commit message itself can be configured in one of two ways: either with a flag:
Or alternatively via your
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:
- Log a name beeab41
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:
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:
Secondly, we’ll need to adjust the
app package to work to this new format:
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:
Now when I run the next
publish command, I’ll see the following change:
- 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
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.
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.