I'm migrating my project consisting of many dependent on each other packages to monorepo with Lerna. We follow something like Gitflow workflow during development. The main concept is to make all source code changes in develop
branch and all other branches (feature, bugfix, etc.) created from and merged back to develop
. As long as new version of a package is ready we publish it by npm publish
or yarn publish
and then merge it to master
branch and tag it there manually the following way:
$ git checkout develop
Make some changes in source code including version bumping...
$ git add -A
$ git commit -m "Make some changes and version bump."
$ git checkout master
$ git merge --no-ff develop -m "Version 0.14.1."
$ git tag -a 0.14.1 -m "Version 0.14.1."
Now I want to achieve the same thing managing all the packages with Lerna. Looking at the docs I stated that publish command relies on version command that, in turn, uses changed command behind the scenes to detect changes made in packages since latest release:
List local packages that have changed since the last tagged release
Consider that some change is made in develop
branch in one package (say, @geoapps/layout
)
$ lerna changed
says that all packages are changed (that is not that I expect):
info cli using local version of lerna
lerna notice cli v3.13.1
lerna info Assuming all packages changed
@geoapps/angle
@geoapps/camera-scene-mode-switcher
...
@geoapps/tracer
@geoapps/vector
lerna success found 39 packages ready to publish
I guess that it happens due to Lerna looks for tagged commits in develop
branch to compare with but nothing is found there. If I commit source code changes to master
branch
then Lerna detects them in single @geoapps/layout
package properly:
$ git checkout master
$ lerna changed
info cli using local version of lerna
lerna notice cli v3.13.1
lerna info Looking for changed packages since 0.14.1
@geoapps/layout
lerna success found 1 package ready to publish
But making changes in master
branch is also not what I want to do. include-merged-tags was another option I tried to use but seems it works only when tagged commit is also a part of the history of develop
branch:
$ git checkout develop
$ git merge --no-ff master -m "Sync with master."
$ lerna changed --include-merged-tags
info cli using local version of lerna
lerna notice cli v3.13.1
lerna info Looking for changed packages since 0.14.1
@geoapps/layout
lerna success found 1 package ready to publish
Since all source code changes tagged in master
branch are present in develop
branch I wonder whether it is possible to force Lerna to compare changes made in develop
branch not with tagged commits from master
but with their parent commits (0.14.1^2
) also belonging to develop
. Is it possible?
Environment:
$ node --version
v10.15.0
$ npm --version
6.9.0
$ yarn --version
1.15.2
$ lerna --version
3.13.1
Lerna's core developer says that Lerna doesn't suit well to work with Gitflow workflow. To say more, it's prohibited to publish packages detecting their changes from specific commit (tagged commit in another branch). Latest tagged release should belong to the same branch where changes were made.
Keeping it and our wish to stay with Gitflow in mind I decided to patch Lerna to achieve the desired behavior. Just created git patch and placed it in the root directory of my project using Lerna.
lerna-version-since.patch
diff --git a/commands/version/command.js b/commands/version/command.js
index da9b1c00..3c5e19e2 100644
--- a/commands/version/command.js
+++ b/commands/version/command.js
@@ -104,6 +104,11 @@ exports.builder = (yargs, composed) => {
requiresArg: true,
defaultDescription: "alpha",
},
+ since: {
+ describe: "Look for changes since specified commit instead of last tagged release",
+ type: "string",
+ requiresArg: true,
+ },
"sign-git-commit": {
describe: "Pass the `--gpg-sign` flag to `git commit`.",
type: "boolean",
If something changes in commands/version/command.js
then we will probably update the patch. In order to apply the patch one should run this command:
$ git apply -p3 --directory node_modules/@lerna/version lerna-version-since.patch
Having patched Lerna it's now possible to bump and publish in develop
branch and tag a release in master
. In order to make things simplier I wrote a script called lerna-gitflow.js
that makes everything automatically. Here is a script section of package.json
:
"scripts": {
"publish:major": "./lerna-gitflow.js publish major",
"publish:minor": "./lerna-gitflow.js publish minor",
"publish:patch": "./lerna-gitflow.js publish patch",
"changes": "./lerna-gitflow.js changes",
"postinstall": "./lerna-gitflow.js patch"
}
All these publish:*
and changes
commands should be run from development branch (develop
by default).
changes
command just shows changed packages in development branch (develop
) since latest release tag in release branch (master
by default).
publish
command does two things:
package.json
files of changed packages, in root package.json
and lerna.json
and commits them to develop
branch locally (it can be done separately by running, for example, ./lerna-gitflow.js version patch
);develop
branch, then merges changes to master
branch without fast-forward and tag a new release there (it also can be done separately by running ./lerna-gitflow.js publish --skip-version
).postinstall
script tries to patch Lerna on any npm install
or yarn install
call otherwise required changes to make everything working will be lost.
lerna-gitflow.js
#!/usr/bin/env node
const path = require('path');
const yargs = require('yargs');
const execa = require('execa');
const jsonfile = require('jsonfile');
const noop = () => {};
async function lernaCommand(command, options) {
const { devBranch } = options;
const branch = await getCurrentBranch();
if (branch !== devBranch) {
return Promise.reject(
`You should be in "${devBranch}" branch to detect changes but current branch is "${branch}".`
);
}
const latestVersion = await getLatestVersion();
const bumpVersion = async bump => {
await lernaVersion(latestVersion, bump);
const version = await getLernaVersion();
const packageJsonPath = path.resolve(__dirname, 'package.json');
const packageJson = await jsonfile.readFile(packageJsonPath);
packageJson.version = version;
await jsonfile.writeFile(packageJsonPath, packageJson, { spaces: 2 });
await exec('git', ['add', '-A']);
await exec('git', ['commit', '-m', 'Version bump.']);
return version;
};
const reject = e => {
if (typeof e === 'string') {
return Promise.reject(e);
}
return Promise.reject('Unable to detect any changes in packages, probably nothing has changed.');
};
switch (command) {
case 'publish': {
const { bump, skipVersion, releaseBranch } = options;
if (releaseBranch === devBranch) {
return Promise.reject('Release and development branches can\'t be the same.');
}
try {
const version = skipVersion ? await getLernaVersion() : await bumpVersion(bump);
await lernaPublish(latestVersion, version);
await exec('git', ['checkout', releaseBranch]);
await exec('git', ['merge', '--no-ff', devBranch, '-m', `Version ${version}.`]);
await exec('git', ['tag', '-a', version, '-m', `Version ${version}.`]);
await exec('git', ['checkout', devBranch]);
}
catch (e) {
return reject(e);
}
break;
}
case 'version': {
const { bump } = options;
try {
await bumpVersion(bump);
}
catch (e) {
return reject(e);
}
break;
}
case 'changed': {
try {
await lernaChanged(latestVersion);
}
catch (e) {
return reject(e);
}
break;
}
}
}
async function lernaPublish(since, version) {
if (since === version) {
return Promise.reject(`Unable to publish packages with same version ${version}.`);
}
return exec('lerna', ['publish', '--since', since, version, '--no-push', '--no-git-tag-version', '--yes']);
}
async function lernaVersion(since, bump) {
return exec('lerna', ['version', '--since', since, bump, '--no-push', '--no-git-tag-version', '--yes']);
}
async function lernaChanged(since) {
return exec('lerna', ['changed', '--since', since]);
}
async function patch() {
try {
await exec('git', ['apply', '-p3', '--directory', 'node_modules/@lerna/version', 'lerna-version-since.patch']);
}
catch (e) {
return Promise.reject('Lerna Gitflow patch is not applied (probably, it\'s already applied before).');
}
}
async function getCurrentBranch() {
const { stdout } = await exec('git', ['branch']);
const match = stdout.match(/\* ([\S]+)/);
if (match === null) {
return Promise.reject('Unable to detect current git branch.');
}
return match[1];
}
async function getLatestTaggedCommit() {
const { stdout } = await exec('git', ['rev-list', '--tags', '--max-count', 1]);
if (!stdout) {
return Promise.reject('Unable to find any tagged commit.');
}
return stdout;
}
async function getLatestVersion() {
const commit = await getLatestTaggedCommit();
const { stdout } = await exec('git', ['describe', '--tags', commit]);
return stdout;
}
async function getLernaVersion() {
const lernaJson = await jsonfile.readFile(path.resolve(__dirname, 'lerna.json'));
return lernaJson.version;
}
function exec(cmd, args, opts) {
console.log(`$ ${cmd} ${args.join(' ')}`);
const promise = execa(cmd, args, opts);
promise.stdout.pipe(process.stdout);
promise.stderr.pipe(process.stderr);
return promise;
}
yargs
.wrap(null)
.strict(true)
.help(true, 'Show help')
.version(false)
.fail((msg, error) => {
console.error(error);
if (msg) {
console.error(msg);
}
})
.demandCommand()
.command(
'publish <bump>',
'Bump and commit packages\' in development branch, then publish, merge into and tag in release branch',
yargs => yargs
.positional('bump', {
describe: 'Type of version update',
type: 'string'
})
.option('skip-version', {
describe: 'Skip version bumping and commiting in development branch',
type: 'boolean',
default: false
}),
opts => lernaCommand('publish', opts)
)
.command(
'version <bump>',
'Bump and commit packages\' version in development branch',
yargs => yargs
.positional('bump', {
describe: 'Type of version update',
type: 'string'
}),
opts => lernaCommand('version', opts)
)
.command(
'changes',
'Detect packages changes since latest release',
noop,
opts => lernaCommand('changed', opts)
)
.command('patch', 'Patch Lerna to use with Gitflow', noop, () => patch())
.options({
'dev-branch': {
describe: 'Name of git development branch',
type: 'string',
demandOption: true,
default: 'develop'
},
'release-branch': {
describe: 'Name of git release branch',
type: 'string',
demandOption: true,
default: 'master'
}
})
.parse();
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With