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…
CHANGELOG.md
— we want to know why and what’s being released.You can find a demo repository here —github.com/b12k/monorepo-semantic-releases
{
"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 nestedpackage.json
).
scrips:
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.{
"$schema": "https://turborepo.org/schema.json",
"pipeline": {
"start": {
"cache": false
},
"release": {
"dependsOn": ["^release"],
"outputMode": "new-only"
}
}
}
apps
” and “libs”All of our monorepo members inapps
andlibs
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.
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 samenameproperty 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…Now when monorepo is configured we can try releasing it.
> git add . && git commit -am “feat(repo): initial”
All versions of monorepo members are initially0.0.0
so after release process we may expect them to be0.1.0
because of semantic versioning and conventional commits
> yarn release
Turborepo will:
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 invalidateturbo
cache for release action monorepo member should be “touched” (contain changes). Every time something is released there ishooks
section in sharedrelease-it
config which executesmvm-update
.MVM
searches for all ofpackage.json
files present and gerenerates/updatesmvm.lock
with latest version of the currently released dependency.
Because of howturbo
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
andrequireCommitFail
will just skip the whole process (successfully fail 😂) and results will be now properly cached.
As soon as something within monorepo will be “touched” (changed)
lib-b & app-b
updated CHANGELOG.mdEverything 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.
We are Valtech, a global digital agency focused on business transformation.