Monorepo Semantic Releases

Bogdan Kolesnyk
Valtech Switzerland
6 min readMay 2, 2023

the opinionated way

Photo by Tom Wilson on Unsplash

Maybe the one of the reasons you reading this lines is that you already tried to setup semantic releases in the monorepo. Googled, crawled Stack Overflow, maybe asked ChatGPT but lots of questions remained unanswered. At least that’s what my experience was…

🤔 Having the following requirements

  • Monorepo — all libraries and applications are living in the same git repository
  • Meaningful CHANGELOG.md — we want to know why and what’s being released.
  • Every monorepo member is releasable — we create a release for every workspace (lib, config or an app) when its changed
  • Changes of up-stream dependency should cause a release down-stream dependents (domino effect)

🛠️ Tooling

👀 Monorepo schema

Monorepo schema. Arrows represent dependencies.

📚 Very minimalist setup

You can find a demo repository here — github.com/b12k/monorepo-semantic-releases

Root package.json

{
"name": "mono",
"version": "0.0.0",
"private": true,
"license": "UNLICENSED",
"workspaces": [
"apps/app-a",
"apps/app-b",
"libs/lib-a",
"libs/lib-b",
"libs/lib-c",
"configs/config-release-it"
],
"scripts": {
"start": "turbo start",
"prepare": "husky install",
"release": "turbo release --concurrency=1"
},
"devDependencies": {
"@commitlint/cli": "17.6.1",
"@commitlint/config-conventional": "17.6.1",
"husky": "8.0.3",
"turbo": "1.9.3"
},
"packageManager": "yarn@1.22.19"
}

workspaces:

Explicitly defined monorepo members. Yarn tend to initialize explicit references faster, and no “false-positive” workspaces (in case of nested package.json).

scrips:

  • start: used to run our apps
  • prepare: used to install git hooks
  • release: name speaks for itself

devDependencies:

  • @commitlint/cli + @commitlint/config-conventional — used to ensure conventional commits for further CHANGELOG.md generation.
  • husky — used to run commitlint on commit message hook.
  • turbo — used to manage the monorepo and execute npm scripts defined in members package.json files.

turbo.json

{
"$schema": "https://turborepo.org/schema.json",
"pipeline": {
"start": {
"cache": false
},
"release": {
"dependsOn": ["^release"],
"outputMode": "new-only"
}
}
}

Configuration of “apps” and “libs”

All of our monorepo members in apps and libs folders have very similar configuration.

member's package.json:

{
"name": "@mono/app-a",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"start": "node index.js",
"release": "release-it --ci"
},
"dependencies": {
"@mono/lib-a": "*"
},
"devDependencies": {
"@mono/config-release-it": "*"
}
}

The only lines differ from member to member is:

  • name: in the example repo all of the members package names are prefixed with @mono for convenience.
  • release: the script to be executed using turbo.
  • dependencies and devDependencies: they vary, but all of them depend on the same shared release-it configuration.

member’s .release-it.js

module.exports = require('@mono/config-release-it');

In order to be “released” every monorepo member should have this configuration file, and the only thing it does — re-exports the shared config.

⚙️ Release-it configuration

The tool used for release process is “release-it”. It was selected due to simplicity and at the same time flexibility of release process setup.

shared configuration — .release-it.js

const version = '${version}';
const packageName = process.env.npm_package_name;
const scope = packageName.split('/')[1];

module.exports = {
plugins: {
'@release-it/conventional-changelog': {
path: '.',
infile: 'CHANGELOG.md',
preset: 'conventionalcommits',
gitRawCommitsOpts: {
path: '.',
},
},
},
git: {
push: true,
tagName: `${packageName}-v${version}`,
pushRepo: 'git@github.com:b12k/monorepo-semantic-release.git',
commitsPath: '.',
commitMessage: `feat(${scope}): released version v${version} [no ci]`,
requireCommits: true,
requireCommitsFail: false,
},
npm: {
publish: false,
versionArgs: ['--workspaces false'],
},
github: {
release: true,
releaseName: `${packageName}-v${version}`,
},
hooks: {
'before:git:release': [
'mvm-update',
'git add --all',
],
}
};
  • version: release-it has internal string interpolation mechanism to replace provided placeholder with calculated version.
  • packageName: the same name property present in the workspace package.json which properties nodejs injects into process environment.
  • scope: this is optional. Used to make release commit message more explicit. Based on workspace naming convention @mono/workspace-name only second part is an actual application/library name.
  • plugins: responsible for semantic version calculation based on conventional commits. ⚠️ Important path property which tells plugins to only consider commits which affect current . directory (workspace).
  • git.tagName: contains configuration for tag name — @mono/workspace-name-v0.0.0.
  • git.pushRepo mostly needed for in GitHub Actions execution, to force using ssh instead of https in order to push back package.json version bumps and updated mvm.lock and CHANGELOG.md files.
  • git.commitsPath: same purpose as in plugins.
  • git.commitMessage: besides providing scope for explicitness [no ci] is added as it disables Github Actions execution not to get into infinite loop state commit to master => run release action => commit back to master ... .
  • git.requireCommits: release-it will execute release process only if there commits available (rel. to git.commitsPath).
  • git.requireCommitFail: disables non zero process termination (erroring) if there no commits for workspace. That’s a standard situation in monorepo, and we do not want to generate errors and terminate turbo release process.
  • npm.publish: this article do not cover publishing of monorepo packages. Considering that it may contain applications and libraries written in other then JS/TS languages publishing can be handled using respective GitHub action triggered by “on release” event.
  • npm.versionArgs: by default while bumping members version in respective package.json NPM will try to update version in dependents as well. This will break monorepo internal dependency linking. --worspaces false disable this functionality.
  • github.releaseName: making release name to match tag name pattern defined previously.
  • hooks: ⚠️ important part of current setup. Will be further explained in details…

🚀 Releasing

Now when monorepo is configured we can try releasing it.

Configured monorepo initial state

> git add . && git commit -am “feat(repo): initial”

All versions of monorepo members are initially 0.0.0 so after release process we may expect them to be 0.1.0 because of semantic versioning and conventional commits

> yarn release

Turborepo will:

  • find “touched” monorepo members (initially all of them).
  • execute release scripts, according to dependency tree — upstream first.
  • cache the outputs of every release action.
Expected output of release process
Post-release state of monorepo
Tags created

A bunch of new files were generated and updated:

  • CHANGELOG.md: every monorepo member now has a source of changes file
  • package.json: version was updated
  • mvm.lock: file with contains actual versions of member dependencies which are present in monorepo. * should be kept as dependency version to keep monorepo working, for that reason actual versions are stored in a different file.

Cascading releases is also a desired feature (aka domino effect). You may have noticed that change log on the image contains more commits then we did initially. MVM library did that. In order to invalidate turbo cache for release action monorepo member should be “touched” (contain changes). Every time something is released there is hooks section in shared release-it config which executes mvm-update. MVM searches for all of package.json files present and gerenerates/updates mvm.lock with latest version of the currently released dependency.

⚠️Turbo cache issue and a workaround

Because of how turbo cache works after initial release cache is invalidated because all of the members received changes. turbo generates cache key before the task executed. Meaning that the second execution of > yarn release will run the whole process again.

release-it got us covered.

requireCommits and requireCommitFail will just skip the whole process (successfully fail 😂) and results will be now properly cached.

Second run with no changes
Third run with successful cache hit

🁠 Domino effect

As soon as something within monorepo will be “touched” (changed)

  • it will be commited
  • turbo cache will be invalidated
  • release action executed
  • mvm will update dependents mvm.lock — invalidating turbo cache and producing a reason for release
  • entire process will be repeated…
Expected console output
lib-b & app-b updated CHANGELOG.md

🏁 That’s it

Everything is working according to requirements. More details can be found in the example repository, with a bonus 😎 — example GitHub Action which does a release on every merge to master.

--

--