Migrating to Lerna - Part 3
Posted on May 11, 2019 -  đ¨đ˝âđťÂ 11 min readIn 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
- Part 1 - Where do we begin?
- Part 2 - Supporting the development environment
- Part 3 - Publish all of the components!
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:
- Changed all of our codebase so all of the imports are no longer relative but package-based.
- Fixed all of our development tooling to match our new infrastructure.
- Published the correct versions of each package both in CI and upon request.
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.