Prerequisites
Some Javascript and Git knowledge and a Github account. Also, NodeJS has to be installed on your computer. If you don’t have it installed already I recommend doing it using a version manager such as nvm.
Context
Monorepo
A monorepo (mono = single, repo = repository) is an approach for managing multiple software projects inside the same repository, often called packages.
Lerna
Lerna is a tool for managing JavaScript projects with multiple packages.
Conventional commits
Conventional commits are a convention built on top of commits that consist of a set of rules to follow when writing commit messages. To specify the nature of the changed code, a set of instructions that conform to the SemVer (Semantic Versioning) specification must be followed.
Github packages
Github packages is the package registry of Github. It allows developers to store software packages for some of the most used package registries (Npm, Docker, Maven…). In our case, we'll use the npm one.
What are we going to build?
We will create a monorepo that will contain two projects (packages). After making changes to any of the projects we will commit them following the conventional commits specification.
After finishing making changes, we'll use Lerna in conjunction with conventional commits for analyzing the commit history and detecting which packages have changed, the level of affectation of these changes, and determining the versions that have to be bumped and published to the registry.
Hands-on
Setting up the monorepo
The very first thing to do is to create a new Github repository. I will call it monorepo
.
Clone the repository, navigate to the root folder, and execute the following command to initialize the npm project.
$ npm init
After that, install Lerna as a dependency and execute the command to initialize the Lerna project:
$ npm install --save lerna
$ lerna init --independent
The following lerna.json file will be generated. This file is used to configure the different options supported by Lerna. The --independent flag is important because we want that each package in the repo is versioned independently instead of having a single version for all the packages.
{
"packages": [
"packages/*" <-- folder where the packages will be located
],
"version": "independent" <-- versioning strategy
}
In order to avoid publishing the node_modules
folder to the repository, create a .gitignore
file with the following content:
node_modules
Our project structure should look like this:
/
.gitignore <-- avoid publish certain files to the repository
package.json <-- Lerna installed in the root dependencies
lerna.json <-- Lerna configuration file
packages/ <-- folder where the packages will be located
Now, let's publish these initial changes to the repository following the conventional commits specification (notice that we're using feat
as the commit type and root
as the scope). Later, in the scope
section of the commit, we'll set the name of the affected package but since the current changes are global we'll just pick a name like root
or any other one that you prefer:
$ git add .
$ git commit -m "feat(root): adds npm, lerna and packages"
$ git push
Creating the packages
We will create the following two packages:
-
date-logic
: It will export a function that returns the current date. -
date-renderer
: It will use thedate-logic
to print the current date to the console.
Package 1 (date-logic)
Create a new folder named date-logic
inside the packages
folder, navigate to it, and execute npm i
to generate its own package.json
file. After that, apply the following changes:
- Add an npm scope to the
name
attribute to indicate who's the owner of the package. In my case,@xcanchal
. - Add the
repository
attribute, with the URL to the Github repository. - Add the
publishConfig.registry
attribute pointing to the Github Packages registry. This specifies the npm registry where the packages will be published.
The package.json
should look like the following:
{
"name": "@xcanchal/date-logic", <-- @{scope}/{package-name}
"version": "1.0.0",
"description": "A package that returns the current date",
"main": "index.js",
"repository": "https://github.com/xcanchal/monorepo", <-- repo
"publishConfig": { <-- publish config
"@xcanchal:registry": "https://npm.pkg.github.com/xcanchal"
}
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Xavier Canchal",
"license": "ISC"
}
Now, we'll implement a very simple script for the date-logic
package. Create a new index.js
file with the following content:
module.exports = function getDate() {
return new Date();
};
Let's push the changes to the repo (remember that we have to follow the conventional commits specification). Because the changes are about adding a new feature to the date-logic
package, we will use the feat
type of commit and the date-logic
scope:
$ git add .
$ git commit -m "feat(date-logic): creates package"
$ git push
We will now publish the very first version of the package to the Github Packages npm registry, so we can install it from the second package that we'll implement later (the date-renderer
).
Authentication in Github Packages and npm
Before being able to publish packages we have to set up a Github Personal Access Token and modify the .npmrc
config file to be able to authenticate when executing publish
or install
commands.
- Go to your "Github > Settings > Developer settings > Personal access tokens" and click "Generate new token". Once in the form, set a descriptive name and check the write:packages,(read:packages implicit) and delete:packages permissions:
You can learn more about Github packages authentication in the docs.
- Add the following lines to the
.npmrc
file, which is an configuration file for npm:
@xcanchal:registry=https://npm.pkg.github.com/xcanchal
always-auth=true
//npm.pkg.github.com/:_authToken={YOUR_GITHUB_TOKEN}
Finally, we can publish our date-logic
package. To do so, execute the following command from the package folder:
$ npm publish
We’ll see the following output (notice that the version 1.0.0
has been published):
npm notice
npm notice 📦 @xcanchal/date-logic@1.0.0
npm notice === Tarball Contents ===
npm notice 61B index.js
npm notice 400B package.json
npm notice === Tarball Details ===
npm notice name: @xcanchal/date-logic
npm notice version: 1.0.0
npm notice filename: @xcanchal/date-logic-1.0.0.tgz
npm notice package size: 397 B
npm notice unpacked size: 461 B
npm notice shasum: 4e48d9d684539e0125bf41a44ae90d6c6fc4b7df
npm notice integrity: sha512-DowuECiLPHd55[...]/LV5T/2pFqucQ==
npm notice total files: 2
npm notice
+ @xcanchal/date-logic@1.0.0
Let’s check how this looks in Github. Open a browser and navigate to your Github repository. There, you can see published packages on the bottom-right of the page:
By clicking the package name you will be redirected to the details page. There, some information such as the installation instructions, the versions published, or the download activity is available.
Package 2 (date-renderer)
Now, let’s implement our second package: the date-renderer
. Create a new date-renderer
folder under packages
and repeat the same steps that we did for the date-logic
package.
Then, install the date-logic
package as a dependency (remember, the date-renderer
will use the date-logic to print the value to the console).
$ npm install --save @xcanchal/date-logic
Great, we have installed a package of our Github packages registry! After that, we will create a new index.js
file and add the following code, which is a simple script that imports the date-logic
package and executes the function exported there to print the date to the console.
const getDate = require('@xcanchal/date-logic');
(() => {
console.log(`Date: ${getDate()}`);
})();
We can test it to check that it works correctly:
$ node index.js
// -> Date: Wed Sep 22 2021 22:50:51 GMT+0200 (Central European Summer Time)
Our project structure now should look like this (this is how a typical Lerna project looks like):
/
package.json
lerna.json
packages/
date-logic/
index.js
package.json
date-renderer/
index.js
package.json <-- date-logic installed as a dependency
Let’s publish the date-renderer
package to the Github Packages registry too by running npm publish
from the package folder.
Modifying packages
Let’s make some changes to our packages. Modify the code in the index.js
file of the date-logic
package to render the date formatted according to a given a locale and some options:
module.exports = function getDate(
locale = 'en-US',
options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
) {
return new Date().toLocaleDateString(locale, options);
};
Before pushing these changes we have to determine the commit type since it will impact the consumers that use our package. Since we have changed the return type of the function from a Date
object to a String
we may consider this as a breaking change. In order to specify it using conventional commits, the body of the footer has to be multi-line and the footer line must start with “BREAKING CHANGE:”
$ git add .
$ git commit -m "feat(date-logic): returns localized date string
BREAKING CHANGE: changes the return type of the getDate function"
$ git push
Leveraging the power of Lerna
Execute git log
to see the three different commits that we have made up until now (from newest to oldest):
commit 7decbab3aab121c2235e3fa8fd79fe30ad4350c4 (HEAD -> main, origin/main, origin/HEAD)
Author: Xavier Canchal <xaviercanchal@userzoom.com>
Date: Thu Sep 23 13:45:02 2021 +0200
feat(date-logic): returns localized date string
BREAKING CHANGE: changes the return type of the getDate function
commit d2497bbb357d41b0f4ed81e9a5f1af45b38e5fce
Author: Xavier Canchal <xaviercanchal@userzoom.com>
Date: Thu Sep 23 12:48:59 2021 +0200
feat(date-renderer): creates package
commit 857efc7057941c254f97d7cf2d49b4f8eae3b196
Author: Xavier Canchal <xaviercanchal@userzoom.com>
Date: Thu Sep 23 09:48:02 2021 +0200
feat(date-logic): creates package
Now, we will use Lerna to analyze the conventional commits history to detect which packages have changed and the level of affectation of those changes to determine the appropriate version to be bumped.
Execute the following command from the root folder of the monorepo (notice the --conventional-commits
flag).
$ lerna version --conventional-commits
Some logs will appear and Lerna will list the packages that will be versioned and will ask for confirmation:
[...]
Changes:
- @xcanchal/date-logic: 1.0.0 => 2.0.0
? Are you sure you want to create these versions? (ynH)
If we confirm by pressing the y
key, Lerna will update the version
attribute in the date-logic
‘s package.json
and will push a tag to Github. See the output:
lerna info execute Skipping releases
lerna info git Pushing tags...
lerna success version finished
If we visit the tags page of our Github repo, we can see the created tag:
But there's more! Lerna also generated a particular CHANGELOG.md
for the date-logic
package with all the changes history. Pretty neat, right?
We still haven’t published this new version 2.0.0
. To do it we’ll use another Lerna command: lerna publish
with the from-git
argument. This argument tells Lerna to decide which versions have to be published by looking at the Git tags, which are used as the source of truth.
But first, we have to extend the Lerna configuration by adding the registry URL under the commands.publish.registry
attribute in our lerna.json
file, which now looks like this:
{
"packages": [
"packages/*"
],
"version": "independent",
"command": {
"publish": {
"registry": "https://npm.pkg.github.com/xcanchal"
}
}
}
Commit and publish the Lerna configuration change:
$ git add .
$ git commit -m "feat(root): adds publish registry to lerna config"
$ git push
And execute the Lerna publish command:
$ lerna publish from-git
Which will ask for confirmation too, like in the version
stage (add a --yes
flag if you want to autoconfirm):
[...]
Found 1 package to publish:
- @xcanchal/date-logic => 2.0.0
? Are you sure you want to publish these packages? (ynH)
We confirm and we get the following output:
[...]
Successfully published:
- @xcanchal/date-logic@2.0.0
lerna success published 1 package
Let’s visit our repository packages page and see how our package now has two different versions published:
Now we can use the new version of the date-logic
package in the date-renderer
. Update the date-renderer
's package.json
to target from the version 2.0.0
and up and execute npm install
.
{
...
"dependencies": {
"@xcanchal/date-logic": "^2.0.0"
}
...
}
Navigate to the date-renderer
package folder and execute node index.js
to see the updated result:
$ node index.js
// -> Date: Thursday, September 23, 2021
And that’s it!
Conclusion
What have we covered in this article?
- Conventional commits specification.
- Using Github packages as an npm registry.
- Configuring authentication in Github packages and npm.
- Using Lerna in conjunction with conventional commits to version and publish packages, and get a nice CHANGELOG.md file as a bonus.
Next steps
- Setting up a commit syntax checker (e.g. commitlint) to avoid human mistakes that could impact the versioning due to wrong commit history.
- Automate the package versioning and publication workflow when pushing new code to the repository using Github actions.
- Publish different types of versions: beta versions when pushing to development and final versions when pushing to master as part of the previous Github action. See Lerna’s --conventional-prerelease and --conventional-graduate flags.
The last two steps are covered in this following article.
Have you ever used a monorepo for managing packages? Did you use Lerna or any other tool? Don’t hesitate to leave some feedback!
Follow me on Twitter for more content @xcanchal
Buy me a coffee:
Top comments (10)
Great article. I have one issue which i'm struggling with. When i try to publish the individual package from my own Monorepo, it says I need to authorise with NPM. I can run npm publish from he root of the repo but this publishes the entire monorepo which i don't want. Any ideas? Thanks!
Hi andian101,
The first thing that comes to my mind is to check if you have completed the following steps:
1 - Adding the personal access token to the
.npmrc
file (previously created in in Github)2 - Adding the repository and the publish config to the package's package.json file:
I hope this helps
Thanks Xavier! I had done all that but still no joy.
However instead of running
npm publish
from the package directory, Ii did it from the root of the monorepo likenpm publish ./packages/myPackage
and it worked perfectly!Awesome!
Interestingly when I attempt to do this I get an error, has things changed in Feb '22?
This is what my Lerna config looks like:
Excellent article, one question, the
.npmrc
is per package?Hi Alvaro, nope. The
.npmrc
is the global npm configuration file. Does this answer your question? Thanks for reading!Yes thanks a lot
5/5
Thank you, Hector!