< Back

Lerna: off the beaten track

This post talks about and explains the build pipeline being used to make the https://threemammals.com website.

Monorepo

  • I wanted to use a Monorepo so I could make changes across various services & packages and run tests against them and anything that used that service.
  • Stop me from having to manually bring in a new package and bump its version all of the time. For example if my components were in a different repository.

Build pipeline

  • Decouple building and producing artefacts from deployment so I could use more advanced deployment techniques provided by a different tool to my CI tool.
  • No build logic in the CI server. This enables me to run my builds locally to test and debug. In addition it makes it easy for me to move CI tool.

Developer Experience

  • Find a way to get hot loading working across multiple packages in a nice way.

What I did

Monorepo

  • I chose Lerna as the tool to manage my Monorepo.

Build pipeline

  • I created two builds, one to build master and one to build PRs. These just built the software in the Monorepo and pushed it to a repository.
  • After the software was pushed I would trigger the deployment step, creating a decoupled pipeline. In theory I could trigger anything to deploy, Spinakker, Octopus, Harness etc.

Developer Experience

  • Hot loading works across the Monorepo.
  • I can run the same scripts to debug builds locally that run in CI.

What is Lerna

A tool for managing JavaScript projects with multiple packages.

This is where I start to go off the beaten track. Lerna is not designed to push Docker images. It is only meant for managing JavaScript packages and pushing them to NPM. With a bit of effort we can make it support any artefact.

Lerna provides you with a bunch of commands that do stuff and the ability to run them against the packages in your Monorepo.

My Monorepo structure is as follows, note your's doesn't have to look like this :)

- node_modules
- packages
  - bar-service
    - node_modules
    - src
    - Dockerfile
    - package.json
  - foo-library
    - node_modules
    - src
    - package.json
    - .npmrc
  - foobar-service
    - node_modules
    - src
    - Dockerfile
    - package.json
  - bar-library
    - node_modules
    - src
    - package.json
    - .npmrc
  - scripts
    - node_modules
    - build.sh
    - package.json
- lerna.json
- package.json
- README.md

The folders in the packages folder are software, that is services or libraries. For the sake of this post services are things that can be deployed and run somewhere e.g. web server, lambda etc.

There is a lot to note down here so let's start at the beginning. The first thing you really don't want to do is install Lerna globally. Install it in your package.json and reference it from NPM scripts.

My root package.json looks something like

{
  "name": "root",
  "private": true,
  "scripts": {
    "build": "./packages/scripts/docker-build.sh",
    "build-pr": "./packages/scripts/docker-build-pr.sh",
    "lerna": "lerna run",
    "bootstrap": "lerna bootstrap",
  },
  "devDependencies": {
    "lerna": "^3.4.0"
  }
}

Let's talk about the scripts in here...build and build-pr call my scripts that build the Monorepo which I will go over later. The lerna script is a helper that calls Lerna's run command and you can pass any arguments you want into this using -- syntax.

The run command allows you to run scripts for packages in the Monorepo. For example if I have a test command in the bar-library package and I want to run the tests I can do npm run lerna -- --scope=@namespace/bar-library test --stream. The scope argument tells Lerna which packages it should call and --stream tells it to steam to stdout rather than flush a buffer at the end. I advise always using a namespace for your packages such as @my-company.

The bootstrap script tells Lerna to install the dependencies and links any cross-dependencies for all packages in the Monorepo. This is one of Lerna's useful features. All of the local packages are symlinked without having to mess around with npm link. If you have a package bar-service and it depends on bar-library then lerna will symlink bar-library into bar-service.

bar-service package.json:

{
  "name": "@my-company/bar-service",
  "version": "2.1.8",
  "dependencies": {
    "@my-company/bar-library": "^1.0.7",
  }
}

bar-library package.json:

{
  "name": "@my-company/bar-library",
  "version": "1.0.7",
}

This means that when you change bar-library those changes are immediately reflected in bar-service. This means I can easily get hot loading working across my packages which improves the developer experience.

And now I have the following developer experience:

  • Update bar-library this is automatically pulled into bar-service. Create a PR, wait a while for the build in CI.
  • If merge conflicts fix and then merge.
  • Deploy to production.
  • Pray testing caught all the problems and you got the requirements right.
  • Repeat this all again for the next story.

Building the Monorepo

Let's talk in more detail about how I get the Monorepo build working.

The first thing to note is every Lerna Monorepo should have at least one package with your build scripts. Do not have these outside the lerna convention the reason for this is that after you change build scripts you are going to want to rebuild everything that depends on them.

This brings me onto another nice feature of Lerna. It will work out what needs to be rebuilt if a dependency has changed. For example if I change bar-libary Lerna knows to rebuild bar-service as it is, a dependency. This means that the Monorepo is always in a correct state given the quality of my tests :)

So let's get to the dirty bit. Earlier I touched on how Lerna is meant for managing JavaScript packages only. It's important to call this out because I are about to start hacking. If you are using Lerna properly you would probably just use two Lerna commands bootstrap and publish. Publish just publishes your NPM packages. However because I also have Docker images as artefacts from my packages this doesn't work.

This script shows what I do to build master.

npm install

changed_packages=$(echo "{$(lerna changed --json --loglevel=silent | jq -c -r 'map(.name) | join(",")'),}")

if [ ${changed_packages} = "{,}" ] || [ ${changed_packages} = "{}" ] || [ ${changed_packages} = {} ]
then
  echo "No packages were changed, nothing to build....if there was something to build put it in a package!!"
  exit 0
fi

lerna version --conventional-commits --changelog-preset angular --yes

lerna bootstrap --no-ci --scope=${changed_packages}

lerna run --scope=${changed_packages} --stream --concurrency=1 test

lerna run --scope=${changed_packages} --stream --concurrency=1 build-ci

lerna run --scope=${changed_packages} --stream --concurrency=1 publish
  • I install the top level dependencies so that I have Lerna available with npm install
  • I work out what packages have changed since the last git tag (the point where a given branch would start) and store the changed packages in memory. If nothing has changed I bail out
  • I version the repository using lerna version --conventional-commits --changelog-preset angular --yes. The version command looks in package.json for any packages that have changed, bumps the package version, bumps any dependent package references, bumps dependent package versions, and tags the monorepo with the versions at the current commit. It also creates a change log then pushes all of this back to the remote.
  • I then call bootstrap on all the packages that have changed to install their dependencies ready for the rest of my build script.
  • Next I call the test command for all the packages that have changed. Lerna executes the command called test in the specific projects package.json. This runs any unit tests.
  • Next I call the build-ci command for all the packages that have changed. Lerna executes the command in the specific projects package.json. This command is a bit of a hack to support JavaScript packages and Docker images.
  • If the package is a JavaScript module it will build then publishes the package to NPM / private NPM. The reason I do this is so that the JavaScript package is available for anything that has a dependency on it in the build-ci stage.
  • If you want your packages to be private you will need to push them to a private NPM. I use private NPM so I use the package scope to tell .npmrc where to send the packages.
    registry=https://registry.npmjs.org/
    @my-company:registry=xxxxxx
  • I can't store this in source control as it contains secrets so I just have a script that pushes this in at build time on the CI server and then deletes it. Slightly better than storing in source control!!
  • If the package is a Docker image I just build it.
  • This part is important to understand. Lerna Symlinks dependencies into each package. When you copy files and folders into the Docker build process it does not copy Symlinks. Therefore when you try and do npm install in the container NPM won't find the dependency. This means they need to be available on your NPM / private repo. An alternative approach to this is tarball your dependencies and pass them into the container. Don't do this it is pretty terrible IMO.
  • Next I call the publish command for all the packages that have changed. Lerna executes the command in the specific projects package.json. As I already published the JavaScript packages in my hack for step 6 this only pushes my Docker images to my registry. If your Docker images are private again you are going to push them to a private registry you can use a script like this.
  PACKAGE_VERSION=$(cat package.json \
  | grep version \
  | head -1 \
  | awk -F: '{ print $2 }' \
  | sed 's/[",\t ]//g')

  PACKAGE_NAME=$(cat package.json \
    | grep name \
    | head -1 \
    | awk -F: '{ print $2 }' \
    | sed 's/["@,\ ]//g')

  DOCKER_TAG=$PACKAGE_NAME:$PACKAGE_VERSION
  DOCKER_LATEST_TAG=$PACKAGE_NAME:latest

  docker login $GEORGE_ACR_URL -u $GEORGE_ACR_USERNAME -p $GEORGE_ACR_PASSWORD

  docker tag $PACKAGE_NAME $GEORGE_ACR_URL/$DOCKER_LATEST_TAG
  docker tag $PACKAGE_NAME $GEORGE_ACR_URL/$DOCKER_TAG

  docker push $GEORGE_ACR_URL/$DOCKER_TAG
  docker push $GEORGE_ACR_URL/$DOCKER_LATEST_TAG

This script gets the version of the package (Docker image) being published from package.json (Lerna has managed all of this) and then tags the Image with it and latest before I push to the Docker registry.

An example of the scripts from package.json for a package that pushes a docker image would be...

"scripts": {
    "test": "jest --coverage --verbose --detectOpenHandles",
    "build": "./node_modules/@my-company/scripts/setup-npmrc.sh && docker build -t george/api . && ./node_modules/@my-company/scripts/teardown-npmrc.sh",
    "build-ci": "npm run build",
    "publish": "./node_modules/@my-company/scripts/publish.sh"
  },

An example of the scripts from package.json for a package that pushes a JavaScript package would be...

  "scripts": {
    "test": "jest --coverage --verbose --detectOpenHandles",
    "build": "webpack  --mode=production",
    "build-ci": "npm run build && ./node_modules/@my-company/scripts/setup-npmrc.sh && npm publish && ./node_modules/@my-company/scripts/teardown-npmrc.sh"
  },

And all this explains how I can build what has changed in the Monorepo and push artefacts for deployment.

There are some slight differences for PRs:

  • You cannot version and push back to the remote
  • You have to have a unique version so that you can always push the artefacts to NPM / private NPM / docker / private docker
  • Solve the two items above by adding the git commit SHA to you versions but don't commit, tag and push back to remote like below.
COMMIT=$(git rev-parse HEAD)
lerna version prepatch --preid $COMMIT --conventional-commits --no-changelog --no-git-tag-version --yes

Examples

Please check out george to see an example of a project using this Monorepo approach with Lerna. Everything you need to get going is there.