Migrating to Lerna - Part 3

Posted on May 11, 2019  -  👨🏽‍💻 11 min read

In this post we’ll talk about how to actually go about publishing everything. We’ll go out of our way and explore a versioning method not natively supported by lerna.

Publish all of the components!

This is part 3 of a 3 part series


Lerna versioning

Before we talk about publishing, we need to talk about the two different versioning modes lerna has.

  • Fixed - where all of the packages are bumped together in each release with the same version.
  • Independent - where each package is bumped individually and you have to manually set that version somehow.

Just from the description it doesn’t sound like we want to use the fixed versioning, why would we bump a minor for Button if DatePicker adds a new feature? For our case, a component library, it doesn’t make much sense. It’s also part of the reason we did this whole thing in the first place.

Independent versioning it is. When we head to the docs to see how to configure independent versioning we encounter this:

Independent mode Lerna projects allows maintainers to increment package versions independently of each other. Each time you publish, you will get a prompt for each package that has changed to specify if it’s a patch, minor, major or custom change.

Now that’s not what we want either, we don’t want to be greeted by a prompt for 100+ packages everytime we want to publish.

To save you a bit of digging through the docs, lerna publish has a positional argument called bump that can have a value of from-package.

Similar to the from-git keyword except the list of packages to publish is determined by inspecting each package.json and determining if any package version is not present in the registry. Any versions not present in the registry will be published. This is useful when a previous lerna publish failed to publish all packages to the registry.

That looks better. We can use that to avoid the 100+ prompts. This also means that we now have to calculate each and every version of a pacakge ourselves. It might sound daunting but in reality it’s not so much, we’ll dive into it in a minute, but before that I’ll just mention a simpler alternative.

There’s lerna-semantic-release, it uses commitizen to detect the changes made to each package and updates the versions accordingly. It may be simpler for you to choose that if your project can shift to (or is already using) commitizen, but in the case that it’s a deal breaker for to base the versions on the commits - you should keep on reading.

We know that we’re going to use from-package in order to publish, for that we’ll create a script in our root package.json. Something like: ./node_modules/bin/lerna publish bump from-package --yes --no-git-rest --no-push --no-git-tag-version - You can read about the arguments in the docs, but the main thing is here that we don’t want some extra git behavior, we want to manage everything ourselves. Later we’ll update this command but that’s a good start.

Detecting changes

So, how do we detect changes? in my project what we’re doing is maintaing a file that contains all the changes, we call it NEXT_VERSION.md and it looks something like this:

#### Breaking Changes
* @OriR - Component(Button) - wrapping with React.forwardRef.

#### Features
* @OriR - Component(TextInput) - adding new prop for className.
* @OriR - Component(Button) - adding new prop for predefined styles.

#### Bugs & Tech debts
*

As you can see, each section contains the changes made to components relevant to it. This allows us to keep track of what components changed and more importantly - we can know how to bump their version, each section maps nicely to major, minor and patch bumps in semver.

You can have your own format, keep it wherever you want, it doesn’t even have to be a file - the only thing we actually will need is a list of changed components and their semver bumps.

OK, this gets us a lot further, all we have to do now is use that list - iterate over the packages with changes, bump them accordingly and publish.

Climbing up the tree

This will probably be enough for some of you, but I felt like I skipped something in the process, quite literally.

What happens if you have this dependency chain in your components: ConfirmationModal -> IconButton -> Button and you’re bumping a new major version of Button that doesn’t affect IconButton?

Again, some of you might say it’s fine, nothing should happen here, no need to update anything if it doesn’t change anything. Others might say that you want all your inter dependencies to always be up to date with the latest version no matter what, and in that case you have to “climb up” the dependency tree and at the very least do a patch of a pacakge if there’s no other bump for it already.

At this point you might to quit, you didn’t sign up for graph theory and trees and stuff, all you wanted is to publish your packages! Don’t worry, it’s not complicated, we’ll do everything together.

Let’s write the pseudo code for the algorithm:

const packages = getPackageGraph();
const changes = getChangesList();

const updatePackage = package => {
  // If this package doesn't have it means it wasn't changed, no need to do anything.
  if (!package.bump) return;

  for (let dependentPackage of package.dependents) {
    // find the change corresponding with that package.
    const dependentChange = changes.find(
      change => dependentPackage.name === change.component
    );

    if (dependentChange) {
      // if a change already exists for this package, use that or a minor bump.
      dependentChange.bump = dependentChange.bump || 'minor';
      dependentPackage.bump = dependentChange.bump;
    } else {
      // if a change doesn't exists for this package, create one with minor bump.
      changes.push({ component: dependentPackage.name, bump: 'minor' });
      dependentPackage.bump = 'minor';
    }

    // Update the package.json of this package to reflect the changes -
    // its new version and the versions of its updated dependencies.
    dependentPackage.updatePackageJson();

    // Go up the dependency tree and do the same thing.
    updatePackage(dependentPackage);
  }
};

for (let package of packageGraph) {
  updatePackage(package);
}

That’s about it*. Not so scary now, is it?

* - in the actual implementation there are a lot of small and subtle details that I didn’t want to cram into this pseudo code. You can imagine things like, getting the latest version of package from the registry, or clearing out the list of changes.

At the end, the result of this algorithm is that each package.json will have the updated version according to the changes in the changes list.

Remember the “main”?

In part 1, we talked about backward compatibility and for that we created a “main” package to re-export all of the components.

That was fine back then, when we just transformed the code, now we need to decide its dependency versions.

Back when you didn’t use lerna, everything was under one package so all of the dependencies were “shared”. Now, each package can have its own dependencies and even dependencies within the monorepo itself - that includes the “main” package.

In the package.json of the “main” package we’ll see something like this:

"dependencies": {
    "@my-scope/my-button": ...,
    "@my-scope/my-text-input": ...,
    "@my-scope/my-date-picker": ...,
    ...
}

But what version/range do we put for each dependency?

We’ve debated about this a lot. And there probably isn’t a right answer here, which means you need to do what better fits your needs.

The obvious way is simply put the latest version of each package and be done with it. kind of like this:

"dependencies": {
    "@my-scope/my-button": "1.0.1",
    "@my-scope/my-text-input": "2.0.0",
    "@my-scope/my-date-picker": "1.1.1",
    ...
}

This is probably the closest you’ll get to your previous API, the “main” package is a snapshot of all of the components at a certain version. The downside is that it’s not super flexible and kind of forces your consumers to install and maybe use multiple versions of the same component.

What we ended up doing (again, only for the “main” package) is something like this:

"dependencies": {
    "@my-scope/my-button": "<=1",
    "@my-scope/my-text-input": "<=2",
    "@my-scope/my-date-picker": "<=1",
    ...
}

What this gives means is “the main package can install all versions of my-button up to but not including 2.0.0, all versions of my-text-input up to but not including 3.0.0 and all versions of my-date-picker up to but not including 2.0.0“.

Why is this better than the previous method? because now your consumer can downgrade a certain component but actually install only one version of it.

In the consumer package.json this would result in only 1 version of my-button

"dependencies": {
    "@my-scope/my-components": "3.0.0",
    "@my-scope/my-button": "1.0.1"
    ...
}

whereas the same thing with the first approach would result in 2 versions of my-button.

This approach has some pitfalls, for instance, it would technically allow you to install “future” versions of an internal component without ever upgrading your “main” version.

We felt this was a fair tradeoff since it will probably never break your app, it’ll just either add more features or fix existing bugs (notice we never match a major version). You can however supply a full semver like "@my-scope/my-button": "<=1.0.1" that way it’ll only match version 1.0.1 or older, nothing newer.

So far we focused on the versions of the dependencies in the “main” package, the other packages have fairly simple dependency versioning - ^x.y.z. That’s because they act just as normal component packages.

We’re now ready to publish everything, but before we do, let’s talk about CI.

Publishing in CI

We have a lot of components, we also have a lot of contributions, each contributor wants to use their changes as soon as possible. So you might say you’ll publish once every two weeks, and then it’ll become every week, then twice a week - soon you’ll find that it’s too much time just doing this whole dance on every publish.

A better option is to publish in your CI. If you don’t have any CI at this point, I genuinely don’t know how you made it this far, but it’s about time to integrate one. In every CI you should be able to define your steps for a certain process - our process is when there’s a merge to master we want to publish all of the changed packages, afterwards notify the person of made the change so they can install the version(s) we just published.

That’s why there’s a function called getChangesList, because during CI you don’t want to get all of the changes from the previous release, you just want to use the changes made in the current merge.

There you’ll be able to git diff HEAD^ NEXT_VERSION.md (or something similar) and get just the lines that were changed - the changes you want to publish.

After that, the process remains the same, instead of taking all the changes we’re taking just a subset, and putting it in the data structure we built earlier, from then on the flow is the same, it doesn’t matter if it’s the entire set of changes or just one change - it does the same thing.

Note: lerna doesn’t like publishing uncomitted code, and in CI you probably can’t commit and push stuff to your repo, which means you’ll have to make due with just committing. Just do a simple “dummy” commit to make lerna happy.

Publish everything

Now that we talked about the CI process, we are ready to move on to the routine release process, where we flush out the contents of our change list, update the CHANGELOG, and basically reset everything so that we can have a fresh start for our next release.

At the end of the day, a routine release process is nothing more than flushing out our list of changes. we already published the current code during the latest CI process so there’s no need to do that again. So, we just need to aggregate the changes like we did before, save them in the CHANGELOG for each package, and reset our changes for the next release (NEXT_VERSION.md file) - that’s about it.

Wrapping it up

Let’s look at what we’ve accomplished:

I’d say, if you’ve come this far you’re more than ready to embark on your migration!

You’re now armed with everything you need to know in order to tame the beast.