@release-it/conventional-changelog
Version:
Conventional changelog plugin for release-it
690 lines (584 loc) • 22.2 kB
JavaScript
import { mock, test } from 'node:test';
import { strict as assert } from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import { EOL } from 'node:os';
import sh from 'shelljs';
import tmp from 'tmp';
import semver from 'semver';
import runTasks from 'release-it';
sh.config.silent = true;
try {
fs.unlinkSync('CHANGES.md');
} catch (error) {}
const noop = () => {};
const log = {
log: noop,
error: noop,
verbose: noop,
info: noop,
obtrusive: noop,
exec: noop,
warn: noop,
preview: noop
};
const namespace = 'conventional-changelog';
const { pathname } = new URL('./index.js', import.meta.url);
const preset = { name: 'angular' };
const getOptions = (options, git = { commit: false, tag: false }) => [
{
ci: true,
git: { commit: git.commit, tag: git.tag, push: false, requireUpstream: false },
plugins: { [pathname]: [namespace, options] }
},
{ log }
];
const mkTmpDir = () => {
const dir = tmp.dirSync({ prefix: namespace });
return dir.name;
};
const add = (type, file, opts = { breaking: false }) => {
sh.ShellString(file).toEnd(file);
sh.exec(`git add ${file}`);
sh.exec(`git commit -m "${type}(${file})${opts.breaking ? '!' : ''}: ${type} ${file}"`);
};
const setup = () => {
const dir = mkTmpDir();
sh.pushd(dir);
sh.exec(`git init .`);
// Disable GPG signing for tags in test repo (may be enabled globally)
sh.exec(`git config tag.gpgSign false`);
add('fix', 'foo');
sh.ShellString('{ "hooks": {} }').toEnd('.release-it.json');
return { dir };
};
const setupUnmergedReleaseTag = initialTag => {
setup();
sh.exec(`git tag ${initialTag}`);
add('feat', 'pre');
sh.exec(`git branch develop`);
add('chore', 'release');
sh.exec(`git tag 1.1.0`);
sh.exec(`git switch develop`);
add('fix', 'post');
};
const date = /\([0-9]{4}-[0-9]{2}-[0-9]{2}\)/.source;
const sha = /[0-9a-f]{7}/.source;
const shaLong = /[0-9a-f]{40}/.source;
const level = (from, to) => `${/patch/.test(semver.diff(from, to)) ? '##' : '#'}`;
const header = (from, to, suffix = '', url = '') => {
const compareUrl = url ? `${url}/compare` : 'compare';
return `${level(from, to)} \\[${to}\\]\\(${compareUrl}/${from}${suffix}...${to}${suffix}\\) ${date}`;
};
const features = EOL + EOL + '### Features' + EOL;
const fixes = EOL + EOL + '### Bug Fixes' + EOL;
const commit = (type, name, url = '') => EOL + `\\* \\*\\*${name}:\\*\\* ${type} ${name} ${url ? `\\(\\[${sha}\\]\\(${url}/commit/${shaLong}\\)\\)` : sha}`;
const nl = value => value.split(/\r\n|\r|\n/g).join(EOL);
test('should generate changelog using recommended bump (minor)', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('fix', 'bar');
add('feat', 'baz');
const options = getOptions({ preset });
const { changelog } = await runTasks(...options);
const title = header('1.0.0', '1.1.0');
const bar = commit('fix', 'bar');
const baz = commit('feat', 'baz');
assert.match(nl(changelog), new RegExp('^' + title + fixes + bar + features + baz + '$'));
});
test('should generate changelog using recommended bump (patch)', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('fix', 'bar');
add('fix', 'baz');
const options = getOptions({ preset });
const { changelog } = await runTasks(...options);
const title = header('1.0.0', '1.0.1');
const bar = commit('fix', 'bar');
const baz = commit('fix', 'baz');
assert.match(nl(changelog), new RegExp('^' + title + fixes + bar + baz + '$'));
});
test('should support tag suffix', async () => {
setup();
const latestVersion = '2.0.0';
const suffix = '-next';
const latestTag = latestVersion + suffix;
sh.exec(`git tag ${latestTag}`);
add('fix', 'bar');
const [config, container] = getOptions({ preset });
config.git.tagName = `\${version}${suffix}`;
const { changelog, version } = await runTasks(config, container);
assert.match(
nl(changelog),
// release-it supports tag suffix/template, but conventional-changelog does not so the title will not contain it:
/^## \[2\.0\.1\]\(compare\/2\.0\.0-next\.\.\.2\.0\.1-next\) \([0-9]{4}-[0-9]{2}-[0-9]{2}\)\s*### Bug Fixes\s*\* \*\*bar:\*\* fix bar [0-9a-f]{7}$/
);
const title = header(latestVersion, version, suffix);
const bar = commit('fix', 'bar');
assert.match(nl(changelog), new RegExp('^' + title + fixes + bar + '$'));
});
test('should respect --no-increment and return previous, identical changelog', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('feat', 'bar');
add('fix', 'baz');
sh.exec(`git tag 1.1.0`);
add('fix', 'bar');
add('feat', 'baz');
sh.exec(`git tag 1.2.0`);
const [config, container] = getOptions({ preset });
config.increment = false;
const { changelog } = await runTasks(config, container);
const title = header('1.1.0', '1.2.0');
const bar = commit('fix', 'bar');
const baz = commit('feat', 'baz');
assert.match(nl(changelog), new RegExp('^' + title + fixes + bar + features + baz + '$'));
});
test('should ignore recommended bump (option)', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('feat', 'baz');
const options = getOptions({ preset, ignoreRecommendedBump: true });
const { version } = await runTasks(...options);
assert.equal(version, '1.0.1');
});
test('should use provided pre-release id', async t => {
setup();
sh.exec(`git tag 1.0.0`);
add('feat', 'baz');
const [config, container] = getOptions({ preset });
config.preRelease = 'alpha';
const { version } = await runTasks(config, container);
assert.equal(version, '1.1.0-alpha.0');
});
test('should use provided pre-release base', async t => {
setup();
sh.exec(`git tag 1.0.0`);
add('feat', 'baz');
const [config, container] = getOptions({ preset });
config.preRelease = 'alpha';
config.preReleaseBase = '1';
const { version } = await runTasks(config, container);
assert.equal(version, '1.1.0-alpha.1');
});
test('should follow conventional commit strategy with prereleaase', async t => {
setup();
sh.exec(`git tag 1.2.1`);
add('feat', 'baz');
const [config, container] = getOptions({ preset: { name: 'conventionalcommits' } }, { commit: true, tag: true });
config.preRelease = 'alpha';
const { version: version1 } = await runTasks(config, container);
assert.equal(version1, '1.3.0-alpha.0');
add('fix', 'buz');
const { version: version2 } = await runTasks(config, container);
assert.equal(version2, '1.3.0-alpha.1');
add('feat', 'biz', { breaking: true });
const { version: version3 } = await runTasks(config, container);
assert.equal(version3, '2.0.0-alpha.0');
add('fix', 'boz');
const { version: version4 } = await runTasks(config, container);
assert.equal(version4, '2.0.0-alpha.1');
});
test('should use provided pre-release id (pre-release continuation)', async t => {
setup();
sh.exec(`git tag 1.0.1-alpha.0`);
add('feat', 'baz');
const [config, container] = getOptions({ preset });
config.preRelease = 'alpha';
const { version } = await runTasks(config, container);
assert.equal(version, '1.0.1-alpha.1');
});
test('should use provided pre-release id (next pre-release)', async t => {
setup();
sh.exec(`git tag 1.1.0-alpha.1`);
const [config, container] = getOptions({ preset });
config.preRelease = 'beta';
const { version } = await runTasks(config, container);
assert.equal(version, '1.1.0-beta.0');
});
test('should use recommended bump (after pre-rerelease)', async t => {
setup();
sh.exec(`git tag 1.0.1-beta.0`);
add('feat', 'baz');
const options = getOptions({ preset });
const { version } = await runTasks(...options);
assert.equal(version, '1.1.0');
});
test('should follow strict semver (pre-release continuation)', async t => {
setup();
sh.exec(`git tag 1.1.0-alpha.0`);
add('feat', 'baz');
const [config, container] = getOptions({ preset, strictSemVer: true });
config.preRelease = 'alpha';
const { version } = await runTasks(config, container);
assert.equal(version, '1.2.0-alpha.0');
});
test('should follow strict semver (pre-release continuation, conventionalcommits)', async t => {
setup();
sh.exec(`git tag 2.0.1-alpha.0`);
sh.ShellString('file').toEnd('file');
sh.exec(`git add file`);
sh.exec(`git commit -m "feat: new feature"`);
const [config, container] = getOptions({
preset: { name: 'conventionalcommits' },
strictSemVer: true,
writerOpts: {},
parserOpts: {}
});
config.preRelease = 'alpha';
const { version } = await runTasks(config, container);
assert.equal(version, '2.1.0-alpha.0');
});
test('should use provided increment', async () => {
setup();
sh.exec(`git tag 1.0.0`);
const [config, container] = getOptions({ preset });
config.increment = 'major';
const { version } = await runTasks(config, container);
assert.equal(version, '2.0.0');
});
test('should use provided version (ignore recommended bump)', async () => {
setup();
const [config, container] = getOptions({ preset });
config.increment = '1.2.3';
const { version } = await runTasks(config, container);
assert.equal(version, '1.2.3');
});
test('should not throw with Git plugin disabled', async () => {
setup();
const [config, container] = getOptions({ preset });
config.git = false;
const { version, changelog } = await runTasks(config, container);
assert.equal(version, '0.0.1');
const title = `## 0.0.1 ${date}`;
const fix = commit('fix', 'foo');
assert.match(nl(changelog), new RegExp(title + fixes + fix));
});
test(`should write and update infile`, async () => {
const { dir } = setup();
sh.exec(`git tag 1.0.0`);
add('fix', 'foo');
add('feat', 'bar');
const h = 'The header' + EOL + EOL + 'The subheader';
const infile = path.join(dir, 'CHANGES.md');
const [config, container] = getOptions({ preset, infile, header: h });
config.git.tag = true;
await runTasks(config, container);
const changelog = fs.readFileSync(infile).toString();
const title = header('1.0.0', '1.1.0');
const fix1 = commit('fix', 'foo');
const feat1 = commit('feat', 'bar');
const first = title + fixes + fix1 + features + feat1;
assert.match(nl(changelog), new RegExp('^' + h + EOL + EOL + first + EOL + '$'));
{
add('fix', 'bar');
add('fix', 'baz');
const options = getOptions({ preset, infile, header: h });
await runTasks(...options);
const changelog = fs.readFileSync(infile).toString();
const title2 = header('1.1.0', '1.1.1');
const fix2 = commit('fix', 'bar');
const fix3 = commit('fix', 'baz');
const second = title2 + fixes + fix2 + fix3;
assert.match(nl(changelog), new RegExp('^' + h + EOL + EOL + second + EOL + EOL + first + EOL + '$'));
}
});
test('should promote a pre-release using stable tag and writer policies', async () => {
const { dir } = setup();
sh.exec(`git tag 1.1.0`);
add('feat', 'bar');
const infile = path.join(dir, 'CHANGES.md');
const pluginOptions = {
preset: {
name: 'conventionalcommits',
types: [
{ type: 'feat', section: 'Features' },
{ type: 'chore', section: 'Chores', releaseAs: 'patch' }
]
},
infile,
tagOpts: { skipUnstable: true },
writerOpts: { generateOn: null }
};
const [preReleaseConfig, container] = getOptions(pluginOptions, { commit: true, tag: true });
preReleaseConfig.git.commitMessage = 'chore(release): ${version}';
preReleaseConfig.preRelease = 'rc';
const { version: preReleaseVersion } = await runTasks(preReleaseConfig, container);
assert.equal(preReleaseVersion, '1.2.0-rc.0');
const [stableConfig] = getOptions(pluginOptions);
stableConfig.git.tagExclude = '*.*.*-*';
const { changelog, version } = await runTasks(stableConfig, container);
assert.equal(version, '1.2.0');
assert.match(changelog, /^##? \[1\.2\.0\]/m);
assert.doesNotMatch(changelog, /^##? \[1\.2\.0-rc\.0\]/m);
assert.match(changelog, /\*\*bar:\*\* feat bar/);
const persistedChangelog = fs.readFileSync(infile, 'utf8');
assert.equal((persistedChangelog.match(/^##? \[1\.2\.0\]/gm) || []).length, 1);
assert.equal((persistedChangelog.match(/^##? \[1\.2\.0-rc\.0\]/gm) || []).length, 1);
});
test('should stage a new infile without shell interpolation', async () => {
const { dir } = setup();
const infile = path.join(dir, 'CHANGES.md');
const exec = mock.fn();
const [config, container] = getOptions({ preset, infile });
container.log = { ...log, exec };
await runTasks(config, container);
const stageCommand = exec.mock.calls
.map(call => call.arguments[0])
.find(command =>
Array.isArray(command)
? command[0] === 'git' && command[1] === 'add' && command.includes(infile)
: command.startsWith('git add ') && command.includes(infile)
);
assert.deepEqual(stageCommand, ['git', 'add', '--', infile]);
});
test('should reject if conventional bump passes error', async () => {
setup();
const options = getOptions({ preset: 'what?' });
await assert.rejects(
runTasks(...options),
'Error: Unable to load the "what?" preset package. Please make sure it\'s installed.'
);
});
test('should reject if conventional changelog has error', async () => {
setup();
const options = getOptions({ preset: () => {} });
await assert.rejects(runTasks(...options), /preset must be string or object with property `name`/i);
});
test('should not write infile in dry run', async () => {
const { dir } = setup();
const infile = path.join(dir, 'DRYRUN.md');
const [config, container] = getOptions({ preset, infile });
config['dry-run'] = true;
await runTasks(config, container);
assert.throws(() => fs.readFileSync(infile), /no such file/);
});
test('should not write infile if set to false', async () => {
const { dir } = setup();
const infile = path.join(dir, 'DRYRUN.md');
const options = getOptions({ preset, infile: false });
const { version } = await runTasks(...options);
assert.throws(() => fs.readFileSync(infile), /no such file/);
assert.equal(version, '0.0.1');
});
test('should not bump when recommended bump returns null', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('fix', 'bar');
add('feat', 'baz');
{
const options = getOptions({ preset: 'angular' });
const { version } = await runTasks(...options);
assert.equal(version, '1.1.0');
}
add('blorp', 'faz');
add('faz', 'blorp');
{
const options = getOptions({ preset: 'angular' });
const { version } = await runTasks(...options);
assert.equal(version, '1.1.0'); // Incorrect result from conventional-recommended-bump
}
{
const whatBump = commits => ({ level: null, reason: 'Parsed commits do not warrant a version bump.' });
const options = getOptions({ whatBump });
const { version } = await runTasks(...options);
assert.equal(version, undefined);
}
});
test('should not bump a pre-release when recommended bump returns null', async () => {
setup();
sh.exec(`git tag 1.0.0-alpha.0`);
add('chore', 'bar');
const whatBump = () => ({ level: null, reason: 'Parsed commits do not warrant a version bump.' });
for (const strictSemVer of [false, true]) {
const [config, container] = getOptions({ whatBump, strictSemVer });
config.preRelease = 'alpha';
const { version } = await runTasks(config, container);
assert.equal(version, undefined);
}
});
test('should not bump when whatBump === false', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('fix', 'bar');
add('feat', 'baz');
{
const options = getOptions({ whatBump: false });
const { version } = await runTasks(...options);
assert.equal(version, undefined);
}
});
test('should not throw when no preset is configured', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('fix', 'bar');
add('feat', 'baz');
{
const options = getOptions({});
const { version } = await runTasks(...options);
assert.equal(version, undefined);
}
});
test('should use given whatBump when provided', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('fix', 'bar');
const whatBump = mock.fn();
{
const options = getOptions({ whatBump });
await runTasks(...options);
assert.ok(whatBump.mock.callCount() > 1);
const commitHeaders = whatBump.mock.calls[0].arguments[0]?.map(commit => commit.header);
assert.strictEqual(commitHeaders.length, 1);
assert.match(commitHeaders[0], /^fix\(bar\):/);
}
});
test('should pass parserOpts and writerOpts', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('fix', 'bar');
add('feat', 'baz');
const parserOpts = {
mergePattern: /^Merge pull request #(\d+) from (.*)$/,
mergeCorrespondence: ['id', 'source']
};
const writerOpts = {
groupBy: 'type'
};
const options = getOptions({ preset, parserOpts, writerOpts });
const { changelog, version } = await runTasks(...options);
assert.equal(version, '1.1.0');
assert.ok(changelog);
assert.match(changelog, /fix/);
assert.match(changelog, /feat/);
});
test('should support render-function writer partials', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('fix', 'bar');
const writerOpts = {
commitPartial: (_context, commit) => `CUSTOM: ${commit.header}`
};
const options = getOptions({ preset, writerOpts });
const { changelog } = await runTasks(...options);
assert.match(changelog, /CUSTOM: fix\(bar\): fix bar/);
});
test('should generate changelog with origin urls', async () => {
setup();
const url = 'https://github.com/release-it/conventional-changelog';
sh.exec(`git tag 1.0.0`);
sh.exec(`git remote add origin ${url}`)
add('fix', 'bar');
add('feat', 'baz');
const options = getOptions({ preset });
const { changelog } = await runTasks(...options);
const title = header('1.0.0', '1.1.0', '', url);
const bar = commit('fix', 'bar', url);
const baz = commit('feat', 'baz', url);
assert.match(nl(changelog), new RegExp('^' + title + fixes + bar + features + baz + '$'));
});
test('should apply custom issuePrefixes from parserOpts', async () => {
setup();
sh.exec(`git tag 1.0.0`);
// Create a commit with a custom issue reference in the body
sh.ShellString('bar').toEnd('bar');
sh.exec(`git add bar`);
// Use a commit with a custom issue prefix in the footer
sh.exec(`git commit -m "fix(bar): fix bar" -m "Refs: XYZ-123"`);
// Test 1: WITHOUT custom issuePrefixes, the parser won't recognize XYZ-123
// and the changelog won't show any issue reference
{
const options = getOptions({ preset });
const { changelog } = await runTasks(...options);
// Without issuePrefixes: ['XYZ-'], the parser won't detect XYZ-123 as an issue
// so no "closes" reference should appear
assert.doesNotMatch(changelog, /closes/);
}
// Reset and create the same commit again
sh.exec(`git tag 1.0.1`);
sh.ShellString('baz').toEnd('baz');
sh.exec(`git add baz`);
sh.exec(`git commit -m "fix(baz): fix baz" -m "Refs: XYZ-456"`);
// Test 2: WITH custom issuePrefixes, the parser recognizes XYZ-456 as an issue
{
const parserOpts = {
issuePrefixes: ['XYZ-']
};
const options = getOptions({ preset, parserOpts });
const { changelog } = await runTasks(...options);
assert.match(changelog, /closes.*XYZ-456/);
}
});
test('should respect a custom tag prefix when computing the recommended bump (#80)', async () => {
setup(); // adds fix(foo)
add('feat', 'pre'); // a feature BEFORE the release tag
sh.exec(`git tag pkg-v1.0.0`);
add('fix', 'post'); // only a fix AFTER the release tag
const [config, container] = getOptions({ preset });
config.git.tagName = 'pkg-v${version}';
const { version } = await runTasks(config, container);
// Only a fix since pkg-v1.0.0 -> patch (1.0.1). Without honoring the prefix,
// the bump is computed over all history (incl. feat 'pre') -> wrong 1.1.0.
assert.equal(version, '1.0.1');
});
test('should use the latest tag from all refs as the recommended bump boundary (#99)', async () => {
setupUnmergedReleaseTag('1.0.0');
const [config, container] = getOptions({ preset });
config.git.getLatestTagFromAllRefs = true;
const { version } = await runTasks(config, container);
assert.equal(version, '1.1.1');
});
test('should preserve an explicit recommended bump tag policy', async () => {
setupUnmergedReleaseTag('legacy-1.0.0');
const [config, container] = getOptions({ preset, tagOpts: { prefix: 'legacy-' } });
config.git.getLatestTagFromAllRefs = true;
config.git.tagMatch = '1.*';
const { version } = await runTasks(config, container);
assert.equal(version, '1.2.0');
});
test('should preserve an explicit changelog boundary', async () => {
setupUnmergedReleaseTag('1.0.0');
const [config, container] = getOptions({ preset, gitRawCommitsOpts: { from: '1.0.0' } });
config.git.getLatestTagFromAllRefs = true;
const { changelog, version } = await runTasks(config, container);
assert.equal(version, '1.1.1');
assert.match(changelog, /feat pre/);
assert.match(changelog, /fix post/);
});
test('should apply custom preset type effects (#78)', async () => {
setup(); // fix(foo)
sh.exec(`git tag 1.0.0`);
add('feat', 'bar');
add('chore', 'baz');
add('docs', 'qux');
const options = getOptions({
preset: {
name: 'conventionalcommits',
types: [
{ type: 'feat', section: '🚀 Features' },
{ type: 'chore', effect: 'hidden' },
{ type: 'docs', section: 'Documentation', effect: 'changelog' }
]
}
});
const { changelog, version } = await runTasks(...options);
assert.equal(version, '1.1.0');
assert.match(changelog, /🚀 Features/);
assert.match(changelog, /bar/);
assert.doesNotMatch(changelog, /baz/);
assert.match(changelog, /Documentation/);
assert.match(changelog, /qux/);
});
test('should not bump for a changelog-only type effect', async () => {
setup();
sh.exec(`git tag 1.0.0`);
add('docs', 'bar');
const options = getOptions({
preset: {
name: 'conventionalcommits',
types: [{ type: 'docs', section: 'Documentation', effect: 'changelog' }]
}
});
const { version } = await runTasks(...options);
assert.equal(version, undefined);
});