Monorepo Semantic Releases
the opinionated way
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
- Yarn — package manager
- Turborepo — monorepo manager
- Release-it — a tool to actually do releases
- MVM — a tool to manage our internal monorepo deps
👀 Monorepo schema
📚 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 furtherCHANGELOG.md
generation.husky
— used to run commitlint on commit message hook.turbo
— used to manage the monorepo and execute npm scripts defined in memberspackage.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 usingturbo
.dependencies
anddevDependencies
: they vary, but all of them depend on the same sharedrelease-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 workspacepackage.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. ⚠️ Importantpath
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 inGitHub Actions
execution, to force usingssh
instead ofhttps
in order to push backpackage.json
version bumps and updatedmvm.lock
andCHANGELOG.md
files.git.commitsPath
: same purpose as inplugins
.git.commitMessage
: besides providingscope
for explicitness[no ci]
is added as it disablesGithub Actions
execution not to get into infinite loop statecommit to master => run release action => commit back to master ...
.git.requireCommits
:release-it
will execute release process only if there commits available (rel. togit.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 terminateturbo
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 respectivepackage.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.
> 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.
A bunch of new files were generated and updated:
CHANGELOG.md
: every monorepo member now has a source of changes filepackage.json
: version was updatedmvm.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.
🁠 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…
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.