Categories
Technical

Easy npm version synchronization for monorepos

TL;DR: Head to the Bespoke…er? section.

I sometimes have to unexpectedly operate a monorepo because of how some of my projects work (i.e., has a backend and a frontend). This has happened to my PAGASA Parser Web project and much recently, my Zoomiebot Wikipedia bot. Everything works fine and dandy at first, until eventually I get hit by a truck: how am I supposed to synchronize version numbers between both frontend and backend?

Let’s look at the structure first: the repository has a backend and frontend folder, each with their own package.json files and their own set of dependencies.

Broke

The “broke” way of doing this would be to manually run npm version on every workspace. This, unfortunately, does not scale up. You shouldn’t spend more than a minute updating version numbers, and definitely not for a monorepo with +20 packages. Not to mention the amount of Git commits you’d be making without setting --no-git-tag-version on.

Woke

So what’s a better method? You can, of course, rely on a script to do this for you. This works decently as well — you can use a JavaScript file (for cross-platform compatibility) to just rewrite all the package.json files, add them into the Git commit with an npm hook, and then call it a day. Sure, this works. I even did it once. Is it the best solution? Hell no. You need a few extra scripts and some hacky workarounds with child_process. Works? Yes. Good? No.

So let’s try to find a better method. Introducing: npm workspaces. You’ve probably heard of these before, and you might even be using it for your own monorepo-related business. In short, you can set up a workspace with a package.json at root of the repository, and then declare those smaller packages as workspaces of the root package.

// /package.json
{
    // ...
    "workspaces": [
        // Each of the folders in this array have their own
        // package.json files.
        "package-a", // /package-a/package.json
        "package-b"  // /package-b/package.json
    ],
    // ...
}

So now, we can just easily run npm version on the root repository and it’ll update everything right? WRONG! It’ll only update the root repository’s version. Frustrating, I know. But it’s just because you’re running it wrong right? Right?

The npm version documentation indicates that you need to use the --workspaces flag to update the version on all workspaces. Easy enough, right? WRONG AGAIN! This will update the version on all workspaces, but never actually update the version for the root package. Frustrating again, I know. But we’re almost there!

Bespoke

If you wanted to run some task after the npm version command on the root repository, you’d usually use a hook. In this case, we want a hook to update the versions of all workspaces. Conveniently, npm provides a hook right before making the Git commit but after the root repository’s version has been update: version. Also conveniently, npm exposes the current package.json being run as environment variables! In this case, we’re specifically looking for $npm_package_version (all lowercase, yeah, I know, right?) which also conveniently updates even in the middle of the version command! How… convenient!

npm workspaces also consolidates your node_modules folder so that you don’t end up reinstalling dependencies across packages. There’s many other advantages workspaces have, but I digress for now.

So, with that in mind, our version script will now look like this in the root repository’s package.json, right?

// /package.json
{
    // ...
    "workspaces": [
        "package-a",
        "package-b"
    ],
    "scripts": {
        "version": "npm version $npm_package_version --no-git-tag-version --workspaces && git add ."
    },
    // ...
}

Yes. Not kidding this time; this will actually work. Will it work for everyone though? Still no. There’s one glaring problem here that you might have already noticed if you develop on Windows: the shell $ symbol is used to reference the version environment variable. That’s a big no-no for fancy ol’ Windows which needs %s. So how do we address this? Unfortunately, you will need a dev dependency for this, but one that you probably already had if you had any sort of cross-platform npm script: cross-env.

As it turns out, cross-env also provides a very useful script aside from cross-env, and that’s cross-env-shell. With cross-env-shell, you can write environment variables in their shell format and it’ll just work on Windows. You can install cross-env as a development dependency with the following command:

npm install --save-dev cross-env

Bespoke…er?

So what’s the real solution? If you came here without reading the other sections, install cross-env first. With that out of the way, here it is:

// /package.json
{
    // ...
    "workspaces": [
        "package-a",
        "package-b"
    ],
    "scripts": {
        "version": "cross-env-shell \"npm version $npm_package_version --no-git-tag-version --workspaces\" && git add **/package*.json"
    },
    // ...
}

An explanation to what the script does is below:

  • Latching on the version hook has the script run after the version is updated on the package.json file but before the Git commit and tag is made.
  • cross-env-shell runs the following command, replacing any $ environment variables if needed (because Windows support is a thing).
  • npm version $npm_package_version will set the npm versions with the following conditions/arguments:
    • --no-git-tag-version ensures that an extra Git commit won’t be made for every version update.
    • --workspaces tells npm to run this command on all workspaces.
  • git add **/package*.json will add in the modified package.json files to the staged Git changes. You can get away with using . if you feel fancy, since npm checks if the Git repo is clean before making any version changes, but this just seems safer for me.

After the hook runs, all versions across all packages and the root package will be synchronized. How exciting! Running npm version patch on the root repository will now properly bump the version number and update the version for all dependencies!

C:\Users\chlod\Workspace\haha_you_looked>npm version major

> version
> cross-env-shell "npm version $npm_package_version --no-git-tag-version --workspaces" && git add **/package*.json

package-a
v3.0.0
package-b
v3.0.0
v3.0.0

C:\Users\chlod\Workspace\haha_you_looked>git status
On branch master
nothing to commit, working tree clean

This doesn’t prevent anyone from running npm version on a subpackage and updating the version of that specific package. If you want to ban that entirely, just attach a command that always exits with a non-zero code to the preversion hook. You should already know how to do this, so I’ll leave that up to you!

That’s all for now, folks! Enjoy your version-synchronized monorepo packages.