UNPKG

@capgo/standard-version

Version:

replacement for `npm version` with automatic CHANGELOG generation

830 lines (742 loc) 26.2 kB
/* global describe it afterEach */ 'use strict' const shell = require('shelljs') const fs = require('fs') const { resolve } = require('path') const { Readable } = require('stream') const mockFS = require('mock-fs') const mockery = require('mockery') const stdMocks = require('std-mocks') const cli = require('../command') const formatCommitMessage = require('../lib/format-commit-message') require('chai').should() // set by mock() let standardVersion function exec (opt = '', git) { if (typeof opt === 'string') { opt = cli.parse(`standard-version ${opt}`) } if (!git) opt.skip = Object.assign({}, opt.skip, { commit: true, tag: true }) return standardVersion(opt) } function getPackageVersion () { return JSON.parse(fs.readFileSync('package.json', 'utf-8')).version } /** * Mock external conventional-changelog modules * * Mocks should be unregistered in test cleanup by calling unmock() * * bump?: 'major' | 'minor' | 'patch' | Error | (opt, cb) => { cb(err) | cb(null, { releaseType }) } * changelog?: string | Error | Array<string | Error | (opt) => string | null> * execFile?: ({ dryRun, silent }, cmd, cmdArgs) => Promise<string> * fs?: { [string]: string | Buffer | any } * pkg?: { [string]: any } * tags?: string[] | Error */ function mock ({ bump, changelog, execFile, fs, pkg, tags } = {}) { mockery.enable({ warnOnUnregistered: false, useCleanCache: true }) mockery.registerMock('conventional-recommended-bump', function (opt, cb) { if (typeof bump === 'function') bump(opt, cb) else if (bump instanceof Error) cb(bump) else cb(null, bump ? { releaseType: bump } : {}) }) if (!Array.isArray(changelog)) changelog = [changelog] mockery.registerMock( 'conventional-changelog', (opt) => new Readable({ read (_size) { const next = changelog.shift() if (next instanceof Error) { this.destroy(next) } else if (typeof next === 'function') { this.push(next(opt)) } else { this.push(next ? Buffer.from(next, 'utf8') : null) } } }) ) mockery.registerMock('git-semver-tags', function (cb) { if (tags instanceof Error) cb(tags) else cb(null, tags | []) }) if (typeof execFile === 'function') { // called from commit & tag lifecycle methods mockery.registerMock('../run-execFile', execFile) } // needs to be set after mockery, but before mock-fs standardVersion = require('../index') fs = Object.assign({}, fs) if (pkg) { fs['package.json'] = JSON.stringify(pkg) } else if (pkg === undefined && !fs['package.json']) { fs['package.json'] = JSON.stringify({ version: '1.0.0' }) } mockFS(fs) stdMocks.use() return () => stdMocks.flush() } function unmock () { mockery.deregisterAll() mockery.disable() mockFS.restore() stdMocks.restore() standardVersion = null // push out prints from the Mocha reporter const { stdout } = stdMocks.flush() for (const str of stdout) { if (str.startsWith(' ')) process.stdout.write(str) } } describe('format-commit-message', function () { it('works for no {{currentTag}}', function () { formatCommitMessage('chore(release): 1.0.0', '1.0.0').should.equal( 'chore(release): 1.0.0' ) }) it('works for one {{currentTag}}', function () { formatCommitMessage('chore(release): {{currentTag}}', '1.0.0').should.equal( 'chore(release): 1.0.0' ) }) it('works for two {{currentTag}}', function () { formatCommitMessage( 'chore(release): {{currentTag}} \n\n* CHANGELOG: https://github.com/conventional-changelog/standard-version/blob/v{{currentTag}}/CHANGELOG.md', '1.0.0' ).should.equal( 'chore(release): 1.0.0 \n\n* CHANGELOG: https://github.com/conventional-changelog/standard-version/blob/v1.0.0/CHANGELOG.md' ) }) }) describe('cli', function () { afterEach(unmock) describe('CHANGELOG.md does not exist', function () { it('populates changelog with commits since last tag by default', async function () { mock({ bump: 'patch', changelog: 'patch release\n', tags: ['v1.0.0'] }) await exec() const content = fs.readFileSync('CHANGELOG.md', 'utf-8') content.should.match(/patch release/) }) it('includes all commits if --first-release is true', async function () { mock({ bump: 'minor', changelog: 'first commit\npatch release\n', pkg: { version: '1.0.1' } }) await exec('--first-release') const content = fs.readFileSync('CHANGELOG.md', 'utf-8') content.should.match(/patch release/) content.should.match(/first commit/) }) it('skipping changelog will not create a changelog file', async function () { mock({ bump: 'minor', changelog: 'foo\n' }) await exec('--skip.changelog true') getPackageVersion().should.equal('1.1.0') try { fs.readFileSync('CHANGELOG.md', 'utf-8') throw new Error('File should not exist') } catch (err) { err.code.should.equal('ENOENT') } }) }) describe('CHANGELOG.md exists', function () { it('appends the new release above the last release, removing the old header (legacy format)', async function () { mock({ bump: 'patch', changelog: 'release 1.0.1\n', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' }, tags: ['v1.0.0'] }) await exec() const content = fs.readFileSync('CHANGELOG.md', 'utf-8') content.should.match(/1\.0\.1/) content.should.not.match(/legacy header format/) }) it('appends the new release above the last release, removing the old header (new format)', async function () { const { header } = require('../defaults') const changelog1 = '### [1.0.1](/compare/v1.0.0...v1.0.1) (YYYY-MM-DD)\n\n\n### Bug Fixes\n\n* patch release ABCDEFXY\n' mock({ bump: 'patch', changelog: changelog1, tags: ['v1.0.0'] }) await exec() let content = fs.readFileSync('CHANGELOG.md', 'utf-8') content.should.equal(header + '\n' + changelog1) const changelog2 = '### [1.0.2](/compare/v1.0.1...v1.0.2) (YYYY-MM-DD)\n\n\n### Bug Fixes\n\n* another patch release ABCDEFXY\n' unmock() mock({ bump: 'patch', changelog: changelog2, fs: { 'CHANGELOG.md': content }, tags: ['v1.0.0', 'v1.0.1'] }) await exec() content = fs.readFileSync('CHANGELOG.md', 'utf-8') content.should.equal(header + '\n' + changelog2 + changelog1) }) it('[DEPRECATED] (--changelogHeader) allows for a custom changelog header', async function () { const header = '# Pork Chop Log' mock({ bump: 'minor', changelog: header + '\n', fs: { 'CHANGELOG.md': '' } }) await exec(`--changelogHeader="${header}"`) const content = fs.readFileSync('CHANGELOG.md', 'utf-8') content.should.match(new RegExp(header)) }) it('[DEPRECATED] (--changelogHeader) exits with error if changelog header matches last version search regex', async function () { mock({ bump: 'minor', fs: { 'CHANGELOG.md': '' } }) try { await exec('--changelogHeader="## 3.0.2"') throw new Error('That should not have worked') } catch (error) { error.message.should.match(/custom changelog header must not match/) } }) }) describe('lifecycle scripts', () => { describe('prerelease hook', function () { it('should run the prerelease hook when provided', async function () { const flush = mock({ bump: 'minor', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) await exec({ scripts: { prerelease: 'node -e "console.error(\'prerelease\' + \' ran\')"' } }) const { stderr } = flush() stderr.join('\n').should.match(/prerelease ran/) }) it('should abort if the hook returns a non-zero exit code', async function () { mock({ bump: 'minor', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) try { await exec({ scripts: { prerelease: 'node -e "throw new Error(\'prerelease\' + \' fail\')"' } }) /* istanbul ignore next */ throw new Error('Unexpected success') } catch (error) { error.message.should.match(/prerelease fail/) } }) }) describe('prebump hook', function () { it('should allow prebump hook to return an alternate version #', async function () { const flush = mock({ bump: 'minor', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) await exec({ scripts: { prebump: 'node -e "console.log(Array.of(9, 9, 9).join(\'.\'))"' } }) const { stdout } = flush() stdout.join('').should.match(/9\.9\.9/) }) }) describe('postbump hook', function () { it('should run the postbump hook when provided', async function () { const flush = mock({ bump: 'minor', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) await exec({ scripts: { postbump: 'node -e "console.error(\'postbump\' + \' ran\')"' } }) const { stderr } = flush() stderr.join('\n').should.match(/postbump ran/) }) it('should run the postbump and exit with error when postbump fails', async function () { mock({ bump: 'minor', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) try { await exec({ scripts: { postbump: 'node -e "throw new Error(\'postbump\' + \' fail\')"' } }) await exec('--patch') /* istanbul ignore next */ throw new Error('Unexpected success') } catch (error) { error.message.should.match(/postbump fail/) } }) }) }) describe('manual-release', function () { describe('release-types', function () { const regularTypes = ['major', 'minor', 'patch'] const nextVersion = { major: '2.0.0', minor: '1.1.0', patch: '1.0.1' } regularTypes.forEach(function (type) { it('creates a ' + type + ' release', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) await exec('--release-as ' + type) getPackageVersion().should.equal(nextVersion[type]) }) }) // this is for pre-releases regularTypes.forEach(function (type) { it('creates a pre' + type + ' release', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) await exec('--release-as ' + type + ' --prerelease ' + type) getPackageVersion().should.equal(`${nextVersion[type]}-${type}.0`) }) }) }) describe('release-as-exact', function () { it('releases as v100.0.0', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) await exec('--release-as v100.0.0') getPackageVersion().should.equal('100.0.0') }) it('releases as 200.0.0-amazing', async function () { mock({ bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) await exec('--release-as 200.0.0-amazing') getPackageVersion().should.equal('200.0.0-amazing') }) }) it('creates a prerelease with a new minor version after two prerelease patches', async function () { let releaseType = 'patch' const bump = (_, cb) => cb(null, { releaseType }) mock({ bump, fs: { 'CHANGELOG.md': 'legacy header format<a name="1.0.0">\n' } }) await exec('--release-as patch --prerelease dev') getPackageVersion().should.equal('1.0.1-dev.0') await exec('--prerelease dev') getPackageVersion().should.equal('1.0.1-dev.1') releaseType = 'minor' await exec('--release-as minor --prerelease dev') getPackageVersion().should.equal('1.1.0-dev.0') await exec('--release-as minor --prerelease dev') getPackageVersion().should.equal('1.1.0-dev.1') await exec('--prerelease dev') getPackageVersion().should.equal('1.1.0-dev.2') }) }) it('appends line feed at end of package.json', async function () { mock({ bump: 'patch' }) await exec() const pkgJson = fs.readFileSync('package.json', 'utf-8') pkgJson.should.equal('{\n "version": "1.0.1"\n}\n') }) it('preserves indentation of tabs in package.json', async function () { mock({ bump: 'patch', fs: { 'package.json': '{\n\t"version": "1.0.0"\n}\n' } }) await exec() const pkgJson = fs.readFileSync('package.json', 'utf-8') pkgJson.should.equal('{\n\t"version": "1.0.1"\n}\n') }) it('preserves indentation of spaces in package.json', async function () { mock({ bump: 'patch', fs: { 'package.json': '{\n "version": "1.0.0"\n}\n' } }) await exec() const pkgJson = fs.readFileSync('package.json', 'utf-8') pkgJson.should.equal('{\n "version": "1.0.1"\n}\n') }) it('preserves carriage return + line feed in package.json', async function () { mock({ bump: 'patch', fs: { 'package.json': '{\r\n "version": "1.0.0"\r\n}\r\n' } }) await exec() const pkgJson = fs.readFileSync('package.json', 'utf-8') pkgJson.should.equal('{\r\n "version": "1.0.1"\r\n}\r\n') }) it('does not print output when the --silent flag is passed', async function () { const flush = mock() await exec('--silent') flush().should.eql({ stdout: [], stderr: [] }) }) }) describe('standard-version', function () { afterEach(unmock) it('should exit on bump error', async function () { mock({ bump: new Error('bump err') }) try { await exec() /* istanbul ignore next */ throw new Error('Unexpected success') } catch (err) { err.message.should.match(/bump err/) } }) it('should exit on changelog error', async function () { mock({ bump: 'minor', changelog: new Error('changelog err') }) try { await exec() /* istanbul ignore next */ throw new Error('Unexpected success') } catch (err) { err.message.should.match(/changelog err/) } }) it('should exit with error without a package file to bump', async function () { mock({ bump: 'patch', pkg: false }) try { await exec({ gitTagFallback: false }) /* istanbul ignore next */ throw new Error('Unexpected success') } catch (err) { err.message.should.equal('no package file found') } }) it('bumps version # in bower.json', async function () { mock({ bump: 'minor', fs: { 'bower.json': JSON.stringify({ version: '1.0.0' }) }, tags: ['v1.0.0'] }) await exec() JSON.parse(fs.readFileSync('bower.json', 'utf-8')).version.should.equal( '1.1.0' ) getPackageVersion().should.equal('1.1.0') }) it('bumps version # in manifest.json', async function () { mock({ bump: 'minor', fs: { 'manifest.json': JSON.stringify({ version: '1.0.0' }) }, tags: ['v1.0.0'] }) await exec() JSON.parse(fs.readFileSync('manifest.json', 'utf-8')).version.should.equal( '1.1.0' ) getPackageVersion().should.equal('1.1.0') }) describe('custom `bumpFiles` support', function () { it('mix.exs + version.txt', async function () { const updater = 'custom-updater.js' const updaterModule = require('./mocks/updater/customer-updater') mock({ bump: 'minor', fs: { 'mix.exs': fs.readFileSync('./test/mocks/mix.exs'), 'version.txt': fs.readFileSync('./test/mocks/version.txt') }, tags: ['v1.0.0'] }) mockery.registerMock(resolve(process.cwd(), updater), updaterModule) await exec({ bumpFiles: [ 'version.txt', { filename: 'mix.exs', updater: 'custom-updater.js' } ] }) fs.readFileSync('mix.exs', 'utf-8').should.contain('version: "1.1.0"') fs.readFileSync('version.txt', 'utf-8').should.equal('1.1.0') }) it('bumps a custom `plain-text` file', async function () { mock({ bump: 'minor', fs: { 'VERSION_TRACKER.txt': fs.readFileSync( './test/mocks/VERSION-1.0.0.txt' ) } }) await exec({ bumpFiles: [{ filename: 'VERSION_TRACKER.txt', type: 'plain-text' }] }) fs.readFileSync('VERSION_TRACKER.txt', 'utf-8').should.equal('1.1.0') }) }) describe('custom `packageFiles` support', function () { it('reads and writes to a custom `plain-text` file', async function () { mock({ bump: 'minor', fs: { 'VERSION_TRACKER.txt': fs.readFileSync( './test/mocks/VERSION-6.3.1.txt' ) } }) await exec({ packageFiles: [{ filename: 'VERSION_TRACKER.txt', type: 'plain-text' }], bumpFiles: [{ filename: 'VERSION_TRACKER.txt', type: 'plain-text' }] }) fs.readFileSync('VERSION_TRACKER.txt', 'utf-8').should.equal('6.4.0') }) it('allows same object to be used in packageFiles and bumpFiles', async function () { mock({ bump: 'minor', fs: { 'VERSION_TRACKER.txt': fs.readFileSync( './test/mocks/VERSION-6.3.1.txt' ) } }) const origWarn = console.warn console.warn = () => { throw new Error('console.warn should not be called') } const filedesc = { filename: 'VERSION_TRACKER.txt', type: 'plain-text' } try { await exec({ packageFiles: [filedesc], bumpFiles: [filedesc] }) fs.readFileSync('VERSION_TRACKER.txt', 'utf-8').should.equal('6.4.0') } finally { console.warn = origWarn } }) }) it('`packageFiles` are bumped along with `bumpFiles` defaults [standard-version#533]', async function () { mock({ bump: 'minor', fs: { '.gitignore': '', 'package-lock.json': JSON.stringify({ version: '1.0.0' }), 'manifest.json': fs.readFileSync('./test/mocks/manifest-6.3.1.json') }, tags: ['v1.0.0'] }) await exec({ silent: true, packageFiles: [ { filename: 'manifest.json', type: 'json' } ] }) JSON.parse(fs.readFileSync('manifest.json', 'utf-8')).version.should.equal('6.4.0') JSON.parse(fs.readFileSync('package.json', 'utf-8')).version.should.equal('6.4.0') JSON.parse(fs.readFileSync('package-lock.json', 'utf-8')).version.should.equal('6.4.0') }) it('bumps version # in npm-shrinkwrap.json', async function () { mock({ bump: 'minor', fs: { 'npm-shrinkwrap.json': JSON.stringify({ version: '1.0.0' }) }, tags: ['v1.0.0'] }) await exec() JSON.parse( fs.readFileSync('npm-shrinkwrap.json', 'utf-8') ).version.should.equal('1.1.0') getPackageVersion().should.equal('1.1.0') }) it('bumps version # in package-lock.json', async function () { mock({ bump: 'minor', fs: { '.gitignore': '', 'package-lock.json': JSON.stringify({ version: '1.0.0' }) }, tags: ['v1.0.0'] }) await exec() JSON.parse( fs.readFileSync('package-lock.json', 'utf-8') ).version.should.equal('1.1.0') getPackageVersion().should.equal('1.1.0') }) describe('skip', () => { it('allows bump and changelog generation to be skipped', async function () { const changelogContent = 'legacy header format<a name="1.0.0">\n' mock({ bump: 'minor', changelog: 'foo\n', fs: { 'CHANGELOG.md': changelogContent } }) await exec('--skip.bump true --skip.changelog true') getPackageVersion().should.equal('1.0.0') const content = fs.readFileSync('CHANGELOG.md', 'utf-8') content.should.equal(changelogContent) }) }) it('does not update files present in .gitignore', async () => { mock({ bump: 'minor', fs: { '.gitignore': 'package-lock.json\nbower.json', // test a defaults.packageFiles 'bower.json': JSON.stringify({ version: '1.0.0' }), // test a defaults.bumpFiles 'package-lock.json': JSON.stringify({ name: '@org/package', version: '1.0.0', lockfileVersion: 1 }) }, tags: ['v1.0.0'] }) await exec() JSON.parse(fs.readFileSync('package-lock.json', 'utf-8')).version.should.equal( '1.0.0' ) JSON.parse(fs.readFileSync('bower.json', 'utf-8')).version.should.equal( '1.0.0' ) getPackageVersion().should.equal('1.1.0') }) describe('configuration', () => { it('--header', async function () { mock({ bump: 'minor', fs: { 'CHANGELOG.md': '' } }) await exec('--header="# Welcome to our CHANGELOG.md"') const content = fs.readFileSync('CHANGELOG.md', 'utf-8') content.should.match(/# Welcome to our CHANGELOG.md/) }) it('--issuePrefixes and --issueUrlFormat', async function () { const format = 'http://www.foo.com/{{prefix}}{{id}}' const prefix = 'ABC-' const changelog = ({ preset }) => preset.issueUrlFormat + ':' + preset.issuePrefixes mock({ bump: 'minor', changelog }) await exec(`--issuePrefixes="${prefix}" --issueUrlFormat="${format}"`) const content = fs.readFileSync('CHANGELOG.md', 'utf-8') content.should.include(`${format}:${prefix}`) }) }) describe('pre-major', () => { it('bumps the minor rather than major, if version < 1.0.0', async function () { mock({ bump: 'minor', pkg: { version: '0.5.0', repository: { url: 'https://github.com/yargs/yargs.git' } } }) await exec() getPackageVersion().should.equal('0.6.0') }) it('bumps major if --release-as=major specified, if version < 1.0.0', async function () { mock({ bump: 'major', pkg: { version: '0.5.0', repository: { url: 'https://github.com/yargs/yargs.git' } } }) await exec('-r major') getPackageVersion().should.equal('1.0.0') }) }) }) describe('GHSL-2020-111', function () { afterEach(unmock) it('does not allow command injection via basic configuration', async function () { mock({ bump: 'patch' }) await exec({ noVerify: true, infile: 'foo.txt', releaseCommitMessageFormat: 'bla `touch exploit`' }) const stat = shell.test('-f', './exploit') stat.should.equal(false) }) }) describe('with mocked git', function () { afterEach(unmock) it('--sign signs the commit and tag', async function () { const gitArgs = [ ['add', 'CHANGELOG.md', 'package.json'], ['commit', '-S', 'CHANGELOG.md', 'package.json', '-m', 'chore(release): 1.0.1'], ['tag', '-s', 'v1.0.1', '-m', 'chore(release): 1.0.1'], ['rev-parse', '--abbrev-ref', 'HEAD'] ] const execFile = (_args, cmd, cmdArgs) => { cmd.should.equal('git') const expected = gitArgs.shift() cmdArgs.should.deep.equal(expected) if (expected[0] === 'rev-parse') return Promise.resolve('master') return Promise.resolve('') } mock({ bump: 'patch', changelog: 'foo\n', execFile }) await exec('--sign', true) gitArgs.should.have.lengthOf(0) }) it('fails if git add fails', async function () { const gitArgs = [ ['add', 'CHANGELOG.md', 'package.json'] ] const execFile = (_args, cmd, cmdArgs) => { cmd.should.equal('git') const expected = gitArgs.shift() cmdArgs.should.deep.equal(expected) if (expected[0] === 'add') { return Promise.reject(new Error('Command failed: git\nfailed add')) } return Promise.resolve('') } mock({ bump: 'patch', changelog: 'foo\n', execFile }) try { await exec({}, true) /* istanbul ignore next */ throw new Error('Unexpected success') } catch (error) { error.message.should.match(/failed add/) } }) it('fails if git commit fails', async function () { const gitArgs = [ ['add', 'CHANGELOG.md', 'package.json'], ['commit', 'CHANGELOG.md', 'package.json', '-m', 'chore(release): 1.0.1'] ] const execFile = (_args, cmd, cmdArgs) => { cmd.should.equal('git') const expected = gitArgs.shift() cmdArgs.should.deep.equal(expected) if (expected[0] === 'commit') { return Promise.reject(new Error('Command failed: git\nfailed commit')) } return Promise.resolve('') } mock({ bump: 'patch', changelog: 'foo\n', execFile }) try { await exec({}, true) /* istanbul ignore next */ throw new Error('Unexpected success') } catch (error) { error.message.should.match(/failed commit/) } }) it('fails if git tag fails', async function () { const gitArgs = [ ['add', 'CHANGELOG.md', 'package.json'], ['commit', 'CHANGELOG.md', 'package.json', '-m', 'chore(release): 1.0.1'], ['tag', '-a', 'v1.0.1', '-m', 'chore(release): 1.0.1'] ] const execFile = (_args, cmd, cmdArgs) => { cmd.should.equal('git') const expected = gitArgs.shift() cmdArgs.should.deep.equal(expected) if (expected[0] === 'tag') { return Promise.reject(new Error('Command failed: git\nfailed tag')) } return Promise.resolve('') } mock({ bump: 'patch', changelog: 'foo\n', execFile }) try { await exec({}, true) /* istanbul ignore next */ throw new Error('Unexpected success') } catch (error) { error.message.should.match(/failed tag/) } }) })