diff --git a/lib/lifecycles/bump.js b/lib/lifecycles/bump.js index da72199f6..270e66896 100644 --- a/lib/lifecycles/bump.js +++ b/lib/lifecycles/bump.js @@ -20,16 +20,46 @@ async function Bump (args, version) { configsToUpdate = {} if (args.skip.bump) return version + + if (args.releaseAs && !(['major', 'minor', 'patch'].includes(args.releaseAs.toLowerCase()) || semver.valid(args.releaseAs))) { + throw new Error("releaseAs must be one of 'major', 'minor' or 'patch', or a valid semvar version.") + } + let newVersion = version await runLifecycleScript(args, 'prerelease') const stdout = await runLifecycleScript(args, 'prebump') - if (stdout && stdout.trim().length) args.releaseAs = stdout.trim() - const release = await bumpVersion(args.releaseAs, version, args) + if (stdout?.trim().length) { + const prebumpString = stdout.trim() + if (semver.valid(prebumpString)) args.releaseAs = prebumpString + } if (!args.firstRelease) { - const releaseType = getReleaseType(args.prerelease, release.releaseType, version) - const releaseTypeAsVersion = releaseType === 'pre' + release.releaseType ? semver.valid(release.releaseType + '-' + args.prerelease + '.0') : semver.valid(releaseType) + if (semver.valid(args.releaseAs)) { + const releaseAs = new semver.SemVer(args.releaseAs) + if (isString(args.prerelease) && releaseAs.prerelease.length && releaseAs.prerelease.slice(0, -1).join('.') !== args.prerelease) { + // If both releaseAs and the prerelease identifier are supplied, they must match. The behavior + // for a mismatch is undefined, so error out instead. + throw new Error('releaseAs and prerelease have conflicting prerelease identifiers') + } else if (isString(args.prerelease) && releaseAs.prerelease.length) { + newVersion = releaseAs.version + } else if (isString(args.prerelease)) { + newVersion = `${releaseAs.major}.${releaseAs.minor}.${releaseAs.patch}-${args.prerelease}.0` + } else { + newVersion = releaseAs.version + } + + // Check if the previous version is the same version and prerelease, and increment if so + if (isString(args.prerelease) && ['prerelease', null].includes(semver.diff(version, newVersion)) && semver.lte(newVersion, version)) { + newVersion = semver.inc(version, 'prerelease', args.prerelease) + } - newVersion = releaseTypeAsVersion || semver.inc(version, releaseType, args.prerelease) + // Append any build info from releaseAs + newVersion = semvarToVersionStr(newVersion, releaseAs.build) + } else { + const release = await bumpVersion(args.releaseAs, version, args) + const releaseType = getReleaseType(args.prerelease, release.releaseType, version) + + newVersion = semver.inc(version, releaseType, args.prerelease) + } updateConfigs(args, newVersion) } else { checkpoint(args, 'skip version bump on first release', [], chalk.red(figures.cross)) @@ -42,6 +72,16 @@ Bump.getUpdatedConfigs = function () { return configsToUpdate } +/** + * Convert a semver object to a full version string including build metadata + * @param {string} semverVersion The semvar version string + * @param {string[]} semverBuild An array of the build metadata elements, to be joined with '.' + * @returns {string} + */ +function semvarToVersionStr(semverVersion, semverBuild) { + return [semverVersion, semverBuild.join('.')].filter(Boolean).join('+') +} + function getReleaseType (prerelease, expectedReleaseType, currentVersion) { if (isString(prerelease)) { if (isInPrerelease(currentVersion)) { diff --git a/package-lock.json b/package-lock.json index b58814d42..8b7ead2ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ }, "devDependencies": { "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", "eslint": "^8.16.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.26.0", @@ -983,6 +984,18 @@ "node": ">=4" } }, + "node_modules/chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "dependencies": { + "check-error": "^1.0.2" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 5" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -6565,6 +6578,15 @@ "type-detect": "^4.0.5" } }, + "chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "requires": { + "check-error": "^1.0.2" + } + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", diff --git a/package.json b/package.json index bd5038504..629a33113 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", "eslint": "^8.16.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.26.0", diff --git a/test/config-files.spec.js b/test/config-files.spec.js index 8f39693f6..50bda4c62 100644 --- a/test/config-files.spec.js +++ b/test/config-files.spec.js @@ -8,7 +8,9 @@ const { Readable } = require('stream') const mockery = require('mockery') const stdMocks = require('std-mocks') -require('chai').should() +const chai = require('chai') +const expect = chai.expect +chai.use(require('chai-as-promised')) function exec () { const cli = require('../command') @@ -168,12 +170,7 @@ describe('config files', () => { it('throws an error when a non-object is returned from .versionrc.js', async function () { mock({ bump: 'minor' }) fs.writeFileSync('.versionrc.js', 'module.exports = 3', 'utf-8') - try { - await exec() - /* istanbul ignore next */ - throw new Error('Unexpected success') - } catch (error) { - error.message.should.match(/Invalid configuration/) - } + + expect(exec).to.throw(/Invalid configuration/) }) }) diff --git a/test/core.spec.js b/test/core.spec.js index a6dfbf0fe..5ea3276bf 100644 --- a/test/core.spec.js +++ b/test/core.spec.js @@ -14,7 +14,10 @@ const stripAnsi = require('strip-ansi') const cli = require('../command') const formatCommitMessage = require('../lib/format-commit-message') -const should = require('chai').should() +const chai = require('chai') +const should = chai.should() +const expect = chai.expect +chai.use(require('chai-as-promised')) // set by mock() let standardVersion @@ -157,12 +160,7 @@ describe('cli', 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') - } + expect(() => fs.readFileSync('CHANGELOG.md', 'utf-8')).to.throw(/ENOENT/) }) }) @@ -217,12 +215,7 @@ describe('cli', function () { 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/) - } + expect(exec('--changelogHeader="## 3.0.2"')).to.be.rejectedWith(/custom changelog header must not match/) }) }) @@ -249,17 +242,11 @@ describe('cli', function () { fs: { 'CHANGELOG.md': 'legacy header format\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/) - } + expect(exec({ + scripts: { + prerelease: "node -e \"throw new Error('prerelease' + ' fail')\"" + } + })).to.be.rejectedWith(/prerelease fail/) }) }) @@ -277,6 +264,49 @@ describe('cli', function () { }) const { stdout } = flush() stdout.join('').should.match(/9\.9\.9/) + getPackageVersion().should.equal('9.9.9') + }) + + it('should not allow prebump hook to return a releaseAs command', async function () { + mock({ + bump: 'minor', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + + await exec({ + scripts: { + prebump: "node -e \"console.log('major')\"" + } + }) + getPackageVersion().should.equal('1.1.0') + }) + + it('should allow prebump hook to return an arbitrary string', async function () { + mock({ + bump: 'minor', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + + await exec({ + scripts: { + prebump: "node -e \"console.log('Hello World')\"" + } + }) + getPackageVersion().should.equal('1.1.0') + }) + + it('should allow prebump hook to return a version with build info', async function () { + mock({ + bump: 'minor', + fs: { 'CHANGELOG.md': 'legacy header format\n' } + }) + + await exec({ + scripts: { + prebump: "node -e \"console.log('9.9.9-test+build')\"" + } + }) + getPackageVersion().should.equal('9.9.9-test+build') }) }) @@ -302,18 +332,12 @@ describe('cli', function () { fs: { 'CHANGELOG.md': 'legacy header format\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/) - } + expect(exec({ + scripts: { + postbump: "node -e \"throw new Error('postbump' + ' fail')\"" + } + })).to.be.rejectedWith(/postbump fail/) + expect(exec('--patch')).to.be.rejectedWith(/postbump fail/) }) }) }) @@ -345,6 +369,12 @@ describe('cli', function () { getPackageVersion().should.equal(`${nextVersion[type]}-${type}.0`) }) }) + + it('exits with error if an invalid release type is provided', async function () { + mock({ bump: 'minor', fs: { 'CHANGELOG.md': '' } }) + + expect(exec('--release-as invalid')).to.be.rejectedWith(/releaseAs must be one of/) + }) }) describe('release-as-exact', function () { @@ -395,11 +425,59 @@ describe('cli', function () { bump: 'patch', fs: { 'CHANGELOG.md': 'legacy header format\n' }, pkg: { - version: '100.0.0-amazing.0' + version: '100.0.0-amazing.1' } }) await exec('--release-as 100.0.0-amazing.0 --prerelease amazing') - should.equal(getPackageVersion(), '100.0.0-amazing.1') + should.equal(getPackageVersion(), '100.0.0-amazing.2') + }) + + it('release 100.0.0 with prerelease amazing correctly sets version', async function () { + mock({ + bump: 'patch', + fs: { 'CHANGELOG.md': 'legacy header format\n' }, + pkg: { + version: '99.0.0-amazing.0' + } + }) + await exec('--release-as 100.0.0 --prerelease amazing') + should.equal(getPackageVersion(), '100.0.0-amazing.0') + }) + + it('release 100.0.0-amazing.0 with prerelease amazing correctly sets version', async function () { + mock({ + bump: 'patch', + fs: { 'CHANGELOG.md': 'legacy header format\n' }, + pkg: { + version: '99.0.0-amazing.0' + } + }) + await exec('--release-as 100.0.0-amazing.0 --prerelease amazing') + should.equal(getPackageVersion(), '100.0.0-amazing.0') + }) + + it('release 100.0.0-amazing.0 with prerelease amazing retains build metadata', async function () { + mock({ + bump: 'patch', + fs: { 'CHANGELOG.md': 'legacy header format\n' }, + pkg: { + version: '100.0.0-amazing.0' + } + }) + await exec('--release-as 100.0.0-amazing.0+build.1234 --prerelease amazing') + should.equal(getPackageVersion(), '100.0.0-amazing.1+build.1234') + }) + + it('release 100.0.0-amazing.3 with prerelease amazing correctly sets prerelease version', async function () { + mock({ + bump: 'patch', + fs: { 'CHANGELOG.md': 'legacy header format\n' }, + pkg: { + version: '100.0.0-amazing.0' + } + }) + await exec('--release-as 100.0.0-amazing.3 --prerelease amazing') + should.equal(getPackageVersion(), '100.0.0-amazing.3') }) }) @@ -427,6 +505,19 @@ describe('cli', function () { await exec('--prerelease dev') getPackageVersion().should.equal('1.1.0-dev.2') }) + + it('exits with error if an invalid release version is provided', async function () { + mock({ bump: 'minor', fs: { 'CHANGELOG.md': '' } }) + + expect(exec('--release-as 10.2')).to.be.rejectedWith(/releaseAs must be one of/) + }) + + it('exits with error if release version conflicts with prerelease', async function () { + mock({ bump: 'minor', fs: { 'CHANGELOG.md': '' } }) + + expect(exec('--release-as 1.2.3-amazing.2 --prerelease awesome')).to.be + .rejectedWith(/releaseAs and prerelease have conflicting prerelease identifiers/) + }) }) it('appends line feed at end of package.json', async function () { @@ -478,35 +569,20 @@ describe('commit-and-tag-version', function () { 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/) - } + + expect(exec()).to.be.rejectedWith(/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/) - } + + expect(exec()).to.be.rejectedWith(/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') - } + + expect(exec({ gitTagFallback: false })).to.be.rejectedWith('no package file found') }) it('bumps version # in bower.json', async function () { @@ -880,24 +956,20 @@ describe('with mocked git', function () { it('fails if git add fails', async function () { const gitArgs = [['add', 'CHANGELOG.md', 'package.json']] + const gitError = new Error('Command failed: git\nfailed add') 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.reject(gitError) } 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/) - } + expect(exec({}, true)).to.be.rejectedWith(gitError) }) it('fails if git commit fails', async function () { @@ -905,24 +977,19 @@ describe('with mocked git', function () { ['add', 'CHANGELOG.md', 'package.json'], ['commit', 'CHANGELOG.md', 'package.json', '-m', 'chore(release): 1.0.1'] ] + const gitError = new Error('Command failed: git\nfailed commit') 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.reject(gitError) } 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/) - } + expect(exec({}, true)).to.be.rejectedWith(gitError) }) it('fails if git tag fails', async function () { @@ -931,23 +998,18 @@ describe('with mocked git', function () { ['commit', 'CHANGELOG.md', 'package.json', '-m', 'chore(release): 1.0.1'], ['tag', '-a', 'v1.0.1', '-m', 'chore(release): 1.0.1'] ] + const gitError = new Error('Command failed: git\nfailed tag') 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.reject(gitError) } 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/) - } + expect(exec({}, true)).to.be.rejectedWith(gitError) }) }) diff --git a/test/git.spec.js b/test/git.spec.js index 22b345a04..4f3652346 100644 --- a/test/git.spec.js +++ b/test/git.spec.js @@ -8,7 +8,9 @@ const { Readable } = require('stream') const mockery = require('mockery') const stdMocks = require('std-mocks') -require('chai').should() +const chai = require('chai') +const expect = chai.expect +chai.use(require('chai-as-promised')) function exec (opt = '') { if (typeof opt === 'string') { @@ -356,13 +358,7 @@ describe('git', function () { ) mock({ bump: 'minor' }) - try { - await exec('--patch') - /* istanbul ignore next */ - throw new Error('Unexpected success') - } catch (error) { - error.message.should.match(/precommit-failure/) - } + expect(exec('--patch')).to.be.rejectedWith(/precommit-failure/) }) it('should allow an alternate commit message to be provided by precommit script', async function () {