In this post we will be exploring how to set up automatic versioning of packages inside of a monorepo using Lerna. There are many ways in which you can do this with different technologies and ideas, but mostly it comes down to a single decision; do you want to version the entire repository as one, or have the individual packages within the monorepo be individually versioned from each other?
We wanted the former, to be kept in sync with each other regardless of there being changes within that package or not. The main reason for this was organisation; if for example a v1.2 didn’t work, we would know that applied to all parts of the codebase (instead of having to work out what versions v1.2 correlates to in each of the packages.)
There is a concern that this would get messy and lead to a lot of unrequired versions and tags being generated. But because we’re not publishing any parts of the monorepo, it’s largely a void issue. This is because no one outside of our team will see the tags generated and as Lerna automatically updates dependencies, we don’t need to worry about manually updating versions to keep packages in sync.
The first thought we had — when it came to versioning a monorepo — was to utilise the same package we use for our Open Source packages, which is semantic-release. This is a great package and handles all of the releasing and tagging for you, among other things. But unfortunately as documented in this long-running open GitHub issue on their repository, they don’t support monorepos.
Now, there have been a few attempts to combine semantic-release with other technologies like Lerna over the years or even add monorepo support into the package like this one and this one. But alas it seems these attempts haven’t stood the test of time and are no longer supported, or haven’t kept pace with the main package itself.
This ruled out the use of semantic-release for our monorepo. But looking around at the attempts to combine semantic-release and Lerna, did get me thinking; could we just use Lerna?
And use Lerna we did!
In the end, after some research, it turned out Lerna had the perfect tools for us. It can run on CI environments, auto-detect versions using conventional commits, generate changelogs based on commits, bump versions in sync with each other and more. The only thing we had to do outside of Lerna was bump the root [package.json] version number via a script (which we’ll cover later in this post).
Lerna can bump the root version by including [“.”] in the [packages] option in the config file but doing this caused numerous issues and problems in our CI environment with memory allocations being exceeded, so we separated it out into its own script to improve the reliability.
To start with Lerna, you need to install it into your project and then create a [lerna.json] file in the root of your monorepo, ours looks like the JSON below but please customise it to your needs.
With this root config file created, we need to add the Lerna command to our CI configuration file. We’re using Bitbucket pipelines on the Salable monorepo, so if you’re not using that you may need to customise the code below to work with your own CI environment.
As the config file above alludes to, we run the Lerna command on the [main] branch. More specifically, on merges into the [main] branch. Our [main] branch’s pipeline runs an install command, jest and cypress tests, builds the application, seeds the database with data and then finally versions the monorepo if all of that was successful.
I’ve omitted all of the other steps for brevity, but it looks like this;
As you can see we run our [npx lerna version] command with a few flags, so let’s quickly run over what each of those means.
[- -conventional-commits] this tells Lerna to use the conventional commits specification when calculating the next version to bump, based on the commit messages since the last release.
[- -yes] this flag passes ‘yes’ to any user prompts that would normally be shown.
[- -force-publish=”*”] this flag means to publish all packages defined in the config file regardless of there being changes or not in that package since the last version. This flag is how we keep all of the packages in sync between versions.
[- -no-push] this flag tells Lerna to not push to the remote destination which is the default behaviour. The reason for this is we still need to update the root [package.json] file and as mentioned earlier, we will do that in a standalone script where we will also handle the pushing to the remote.
[- -no-git-tag-version] this flag prevents Lerna from tagging the commit it creates with the new version. Normally you would want this behaviour but because we still need to amend the root version, we want to hold off tagging until that is complete. We will create our own git tag in the root versioning script.
Developers love how easy it is to grow a SaaS business with SalableLearn more →
To update the root [package.json] file we run a script on our CI environment following the [npx lerna version … ] command, this script will take the new version from the [lerna.json] file and then update the root [package.json] version with it. Then it commits all of the changes, creates a new git tag for the version and then finally pushes that commit and the newly created git tag to the remote destination.
The only thing we have left to do is amend our Bitbucket pipelines to add the script.
With our pipelines config updated, our monorepo is set up and ready to be versioned whenever new code is merged into the [main] branch. When a merge occurs, this pipeline will run and (provided all the steps prior to the versioning one are successful) the versioning step will run. Lerna will calculate the next version for us to bump to automatically and a commit will be made bumping all the versions.
In this post, we’ve covered how to handle and set up versioning in a monorepo using Lerna and Bitbucket pipelines to ensure all [package.json] files across the entire monorepo are kept in sync.