Tech Blog #4: How to version all packages synchronously, in a monorepo using Lerna

Code

Author Coner Murphy, Dev Team Salable

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.

Unsuccessful Methods

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?

Our Versioning Solution

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.

{
	"version": "1.0.3",
	"packages": ["packages/*", "repositories/*"],
	"npmClient": "yarn",
	"command": {
		"version": {
			"message": "chore(release): publish %s [skip ci]",
			"conventionalCommits": true,
			"changelogPreset": "conventionalCommits",
			"allowBranch": ["main"]
		}
	}
}

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;

pipelines:
	branches:
  	main:
    	# ...Other steps mentioned above
      
      - step:
      	name: Versioning
        caches:
        	- node
        script:
        	- npx lerna version --conventional-commits --yes --force-publish='*' --no-push --no-git-tag-version

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.

Related blogs

Tweet from Neal Riley saying "Commercial freedom and flexibility is a must for any digitally enabled business. @SalableApp gives you the tools to build your SaaS business."

Developers love how easy it is to grow a SaaS business with Salable

Learn more →

Root [package.json] script

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.

const fs = require('fs');
const {execSync} = require('child_process');

const root = '..';
const packageJsonName = 'package.json';
const {version} = require(`${root}/lerna.json`);

let pName;

const updateVersion = (file, version, json) => {
  json.version = version;

  return fs.writeFileSync(file, JSON.stringify(json, null, 2).concat('\n'), {encoding: 'utf8', flag: 'w'});
};

if (fs.existsSync(packageJsonName)) {
  pName = require(`${root}/${packageJsonName}`);
  updateVersion(packageJsonName, version, pName);
}

execSync(`git add .`, (stdout, stderr) => {
  if (stdout) {
    console.log(stdout);
  }

  if (stderr) {
    console.error(stderr);
    process.exit(1);
  }
});

execSync(`git commit -m "chore(release): publish v${version} [skip ci]"`, (stdout, stderr) => {
  if (stdout) {
    console.log(stdout);
  }

  if (stderr) {
    console.error(stderr);
    process.exit(1);
  }
});

execSync(`git tag v${version}`, (stdout, stderr) => {
  if (stdout) {
    console.log(stdout);
  }

  if (stderr) {
    console.error(stderr);
    process.exit(1);
  }
});

execSync(`git push --atomic origin main v${version}`, (stdout, stderr) => {
  if (stdout) {
    console.log(stdout);
  }

  if (stderr) {
    console.error(stderr);
    process.exit(1);
  }
});

The only thing we have left to do is amend our Bitbucket pipelines to add the script.

pipelines:
  branches:
    main:
      # ...Other steps mentioned above

      - step:
          name: Versioning
          caches:
            - node
          script:
            - npx lerna version --conventional-commits --yes --force-publish="*" --no-push --no-git-tag-version
            - node ./scripts/versionRootPackage.js

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.

Conclusion

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.

Tweet from Neal Riley saying "Commercial freedom and flexibility is a must for any digitally enabled business. @SalableApp gives you the tools to build your SaaS business."

Tech Blog #5: Automatically sync multiple repositories versions using GitHub Actions

We have a custom JavaScript library for our pricing table, at a high level this JS library is responsible for displaying a product’s various pricing tables...

Coner Murphy
9 Dec 2022

Salable tech blog #1 Fixing the ‘Create New Product’ form

Earlier this week I picked up a new ticket to work on for our MVP, it was a bug regarding our ‘Create New Product’ form...

Conor Murphy
12/09/22

Tech Blog #2 Salable Package Architecture

Over the last few months, we’ve been working on a new Global Design System (GDS) for Salable. But as part of this process, we’ve also had to make several decisions...