When coupled together, Lerna and Yarn Workspaces can ease and optimize the management of working with multi-package repositories.
Lerna makes versioning and publishing packages to an NPM Org a painless experience by providing helpful utility commands for handling the execution of tasks across multiple packages.
Yarn Workspaces manages our dependencies. Rather than having multiple node_modules directories, it intelligently optimizes the installing of dependencies together and allows for the cross-linking of dependencies in a monorepo. Yarn Workspaces provide tools, like Lerna, the low-level primitives it needs to manage multi-package repositories.
In order to begin, let’s enable Yarn Workspaces
yarn config set workspaces-experimental true
Now we are able to illustrate these concepts by creating a dummy project
mkdir my-design-system && cd my-design-system
that we initialize
yarn init
and add Lerna as a dev dependency
yarn add lerna --dev
you’ll then want to initialize Lerna, which will create a lerna.json and a packages directory
lerna init
In order to set up Lerna with Yarn workspaces, we need to configure the lerna.json
Let’s add yarn as our npmClient and specify that we’re using yarn workspaces. For this tutorial we’ll be versioning our packages independently.
// lerna.json
{
"packages": ["packages/*"],
"version": "independent",
"npmClient": "yarn",
"useWorkspaces": true
}
At this point we should only have a root package.json. In this root package.json we need to add workspaces and private to true. Setting private to true will prevent the root project from being published to NPM.
// package.json
{
"name": "my-design-system",
"private": true,
"workspaces": [
"packages/*"
]
}
Flow for Creating a New Package
New packages need to be created under the packages directory. Let’s create a dummy form package
cd packages
Once we’re in the correct directory, we can create and cd into our new package
mkdir my-design-system-form && cd my-design-system-form
then we create a new package.json by running yarn init:
yarn init
The new name should follow our NPM Org scope ex. @my-scope-name
It’s also important to have the new package start at a version like 0.0.0 because once we do our first publish using Lerna, it’ll be published at 0.1.0 or 1.0.0.
// package.json
{
"name": "@my-scope-name/my-design-system-form",
"version" : "0.0.0",
"main" : "index.js"
}
If you have an NPM Org Account which supports private packages, you can add the following to your module’s individual package.json
"publishConfig": {
"access": "restricted"
}
Adding a Local Sibling Dependency to a Specific Package
Now that we know the flow for creating new packages, let’s say we ended up with a structure like:
my-design-system/
packages/
my-design-system-core/
my-design-system-form/
my-design-system-button/
If we wanted to add the my-design-system-button as a dependency to our my-design-system-form and have Lerna symlink them, we can do so by cd into that package
cd my-design-system-form
and then running the following:
lerna add @my-scope-name/design-system-button --scope=@my-scope-name/my-design-system-form
This will update the package.json of @my-scope-name/my-design-system-form.
Our package.json should look like:
// package.json
{
"name": "@my-scope-name/my-design-system-form",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"@my-scope-name/my-design-system-button": "^1.0.0"
}
}
Now you can reference this local dependency in index.js like
import Button from '@my-scope-name/my-design-system-button';
Adding a “common” dependency to ALL packages
Doing this is similar to the previous command. This would be for /packages/*. It doesn’t matter if they’re local sibling dependencies or from NPM
lerna add the-dep-name
If you have common dev dependencies, it’s better to specify them in the workspace root package.json. For instance, this can be dependencies like Jest, Husky, Storybook, Eslint, Prettier, etc.
yarn add husky --dev -W
*Adding the -W flag makes it explicit that we’re adding the dependency to the workspace root.
Removing Dependencies
If there’s a dependency that all packages use but that you want to remove, Lerna has the exec command that runs an arbitrary command in each package. With this knowledge, we can use exec to remove a dependency on all packages.
lerna exec -- yarn remove dep-name
Running Tests
Lerna provides the run command which will run an npm script in each package that contains that script.
For instance, say all of our packages follow the structure of my-design-system-form:
my-design-system-form/
__tests__/
Form.test.js
and in each package.json we have the test npm script
"name": "@my-scope-name/my-design-system-form",
"scripts": {
"test": "jest"
}
Then Lerna can execute each test script by running:
lerna run test --stream
*The — stream flag just provides output from the child processes
Publishing to NPM
Manual
First, you need to make sure you’re logged in. You can verify that you’re logged in by doing
npm whoami // myusername
If you’re not logged in, run the following and follow the prompts:
npm login
Once logged in you can have Lerna publish by running:
lerna publish
Lerna will prompt you to update the version numbers.
Automatic
Lerna supports the use of the Conventional Commits Standard to automate Semantic Versioning in a CI environment.
This gives developers the ability to commit messages like
git commit -m "fix: JIRA-1234 Fixed minor bug in foo"
Then in a CI environment, the packages version’s can be updated and published to NPM based on commits like the one above. This is done by configuring your CI environment to run:
lerna publish --conventional-commits --yes
If you don’t want to pass flags, add the following to your lerna.json file
"command": {
"publish": {
"conventionalCommits": true,
"yes": true
}
}
Enforcing Conventional Commits
If you want to enforce the Conventional Commits Standard, I recommend adding commitlint to the ROOT of the project
yarn add @commitlint/cli @commitlint/config-conventional husky cross-env --dev
Then create a release script in the root package.json
"scripts": {
"release": "cross-env HUSKY_BYPASS=true lerna publish"
}
This release script will be run in a CI Environment. Note, that we configured the conventional commits and “yes” flag in the lerna.json file. Since, this CI environment will be committing the Version changes, we don’t want to trigger the linting of the commit message. We do this by adding an Environment Variable named HUSKY_BYPASS which we’ll set to true by using cross-env.
We still need to add further configuration in the root package.json
"husky": {
"hooks": {
"commit-msg": "[[ -n $HUSKY_BYPASS ]] || commitlint -E HUSKY_GIT_PARAMS"
}
},
"commitlint": {
"extends": ["@commitlint/config-conventional"]
}
For husky, we add a commit-msg hook that will check the HUSKY_BYPASS Environment Variable we added above, if this is falsy then we lint the commit message by using @commitlint/config-conventional
Split Versioning and Publishing
If for whatever reason you want full control of the versioning, Lerna has the ability to split versioning and publishing into two commands.
You can manually run:
lerna version
Then follow the prompts to update the individual version numbers.
Then you can have a step that will read the latest tag (that was manually updated) to publish to NPM:
lerna publish from-git --yes
Local Development with Multiple Contributors
Anytime a new contributor does a git clone of your project or you need to pull your team’s latest changes you have to run the yarn command:
yarn
In most Lerna tutorials, it is advocated to use the lerna bootstrap command, however when yarn workspaces is enabled this is unecessary and redundant.
lerna bootstrap
when you're using Yarn workspaces is literally redundant? Alllerna bootstrap --npm-client yarn --use-workspaces
(CLI equivalent of your lerna.json config) does is callyarn install
in the root. — Issue 1308
See https://github.com/lerna/lerna/issues/1308 for more info.
Cross Project Local Development
In our example, we are building a multi-package design system. If a developer wanted to create a new component in the design system but also test it out in a local client application before it’s published, they can do so by using yarn’s link command.
To symlink a local dependency
Say we want to use our local my-design-system-core in my-client-app
We first CD into the package we want to use in another project
cd ~/path/to/my-design-system/my-design-system-core
Then we create a symlink
yarn link
You should see output like:
success Registered "@my-scope-name/my-design-system-core".
info You can now run `yarn link "@my-scope/my-design-system-core"` in the projects where you want to use this module and it will be used instead.
Now that our package is symlinked, we can go into my-client-app to use:
cd ~/path/to/my-client-app
yarn link @my-scope-name/my-design-system-core
Any changes in /packages/my-design-system-core will be reflected in my-client-app. Now a developer can easily do local development on both projects and see it reflected.
To unlink a local dependency
When the developer is finished and no longer wants to use the local package we need to unlink.
CD into the package we want to unlink
cd ~/path/to/my-design-system/my-design-system-core
Run unlink to remove the local symlink
yarn unlink
You’ll see output like:
success Unregistered "@my-scope-name/my-design-system-core".
info You can now run `yarn unlink "@my-scope-name/my-design-system-core"` in the projects where you no longer want to use this module.
Now we can cd into my-client-app to unlink
cd ~/path/to/my-client-app
yarn unlink @my-scope-name/my-design-system-core
Overall
Lerna coupled with yarn workspaces is a great combination. Lerna adds utility functionality on top of Yarn Workspaces for working with multiple packages. Yarn workspaces make it so that all dependencies can be installed together, making caching and installing faster. It allows us to easily release dependencies on NPM with a single command, automatically updates the package.json of sibling dependencies when a dependency version changes, and generally makes installing, versioning, and publishing a painless experience.