DEV Community

Cover image for Monorepo using Lerna, Conventional commits, and Github packages
Xavier Canchal
Xavier Canchal

Posted on • Updated on

Monorepo using Lerna, Conventional commits, and Github packages

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.

Github repository creation

Clone the repository, navigate to the root folder, and execute the following command to initialize the npm project.

$ npm init
Enter fullscreen mode Exit fullscreen mode

After that, install Lerna as a dependency and execute the command to initialize the Lerna project:

$ npm install --save lerna

$ lerna init --independent
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

In order to avoid publishing the node_modules folder to the repository, create a .gitignore file with the following content:

node_modules
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 the date-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:

  1. Add an npm scope to the name attribute to indicate who's the owner of the package. In my case, @xcanchal.
  2. Add the repository attribute, with the URL to the Github repository.
  3. 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"
}
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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-logicpackage, 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
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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:

Personal access token form

You can learn more about Github packages authentication in the docs.

  1. 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}
Enter fullscreen mode Exit fullscreen mode

Finally, we can publish our date-logic package. To do so, execute the following command from the package folder:

$ npm publish
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Github repository 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.

Github package page

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
Enter fullscreen mode Exit fullscreen mode

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()}`);
})();
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If we visit the tags page of our Github repo, we can see the created tag:

Github repository tags

But there's more! Lerna also generated a particular CHANGELOG.md for the date-logic package with all the changes history. Pretty neat, right?

Package changelog

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Commit and publish the Lerna configuration change:

$ git add .
$ git commit -m "feat(root): adds publish registry to lerna config"
$ git push
Enter fullscreen mode Exit fullscreen mode

And execute the Lerna publish command:

$ lerna publish from-git
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

We confirm and we get the following output:

[...]

Successfully published:
 - @xcanchal/date-logic@2.0.0
lerna success published 1 package
Enter fullscreen mode Exit fullscreen mode

Let’s visit our repository packages page and see how our package now has two different versions published:

Github package page

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"
  }
...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Image description

Top comments (10)

Collapse
 
andian101 profile image
andian101

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!

Collapse
 
xcanchal profile image
Xavier Canchal

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)

@xcanchal:registry=https://npm.pkg.github.com/xcanchal
always-auth=true
//npm.pkg.github.com/:_authToken={YOUR_GITHUB_TOKEN}
Enter fullscreen mode Exit fullscreen mode

2 - Adding the repository and the publish config to the package's package.json file:

"repository": "https://github.com/xcanchal/monorepo", <-- repo
  "publishConfig": { <-- publish config
     "@xcanchal:registry": "https://npm.pkg.github.com/xcanchal"
  }
Enter fullscreen mode Exit fullscreen mode

I hope this helps

Collapse
 
andian101 profile image
andian101

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 like npm publish ./packages/myPackage and it worked perfectly!

Thread Thread
 
xcanchal profile image
Xavier Canchal

Awesome!

Collapse
 
dextermb profile image
Dexter Marks-Barber • Edited

Interestingly when I attempt to do this I get an error, has things changed in Feb '22?

npx lerna version --conventional-commits
lerna notice cli v8.0.2
lerna info current version independant
lerna info Assuming all packages changed
lerna ERR! TypeError: Invalid Version: independant
lerna ERR!     at new SemVer (/Users/<user>/Documents/git/monorepo/node_modules/semver/classes/semver.js:38:13)
lerna ERR!     at compare (/Users/<user>/Documents/git/monorepo/node_modules/semver/functions/compare.js:3:32)
lerna ERR!     at Object.lt (/Users/<user>/Documents/git/monorepo/node_modules/semver/functions/lt.js:2:29)
lerna ERR!     at VersionCommand.setGlobalVersionFloor (/Users/<user>/Documents/git/monorepo/node_modules/lerna/dist/index.js:8270:38)
lerna ERR!     at VersionCommand.recommendVersions (/Users/<user>/Documents/git/monorepo/node_modules/lerna/dist/index.js:8240:16)
lerna ERR!     at VersionCommand.getVersionsForUpdates (/Users/<user>/Documents/git/monorepo/node_modules/lerna/dist/index.js:8193:23)
lerna ERR!     at VersionCommand.initialize (/Users/<user>/Documents/git/monorepo/node_modules/lerna/dist/index.js:8124:37)
lerna ERR!     at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
lerna ERR! lerna Invalid Version: independant
Enter fullscreen mode Exit fullscreen mode

This is what my Lerna config looks like:

{
  "$schema": "node_modules/lerna/schemas/lerna-schema.json",
  "version": "independant",
  "packages": [
    "packages/libraries/*",
    "packages/apps/*",
    "packages/pages/*",
    "packages/workers/*"
  ],
  "ignoreChanges": [
    "**/test/**",
    "**/tests/**",
    "**/*.md"
  ]
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
alvarosabu profile image
Alvaro Saburido

Excellent article, one question, the .npmrc is per package?

Collapse
 
xcanchal profile image
Xavier Canchal

Hi Alvaro, nope. The .npmrc is the global npm configuration file. Does this answer your question? Thanks for reading!

Collapse
 
alvarosabu profile image
Alvaro Saburido

Yes thanks a lot

Collapse
 
hvmlopez profile image
Hector Vinicio Lopez Molinares

5/5

Collapse
 
xcanchal profile image
Xavier Canchal • Edited

Thank you, Hector!