From d31dcdbd77071f11d9d7603ec8cc901aa2cfbf2b Mon Sep 17 00:00:00 2001 From: "Benjamin E. Coe" Date: Mon, 5 Jun 2017 22:10:29 -0700 Subject: [PATCH] feat: add support for skipping lifecycle steps, polish lifecycle work (#188) --- README.md | 29 ++-- command.js | 8 +- defaults.json | 1 + index.js | 248 ++--------------------------------- lib/checkpoint.js | 6 +- lib/format-commit-message.js | 5 + lib/lifecycles/bump.js | 159 ++++++++++++++++++++++ lib/lifecycles/changelog.js | 62 +++++++++ lib/lifecycles/commit.js | 39 ++++++ lib/lifecycles/tag.js | 34 +++++ lib/run-lifecycle-script.js | 3 +- test.js | 32 +++++ 12 files changed, 377 insertions(+), 249 deletions(-) create mode 100644 lib/format-commit-message.js create mode 100644 lib/lifecycles/bump.js create mode 100644 lib/lifecycles/changelog.js create mode 100644 lib/lifecycles/commit.js create mode 100644 lib/lifecycles/tag.js diff --git a/README.md b/README.md index 9dc47ae17..5e57f4614 100644 --- a/README.md +++ b/README.md @@ -156,18 +156,16 @@ If you have your GPG key set up, add the `--sign` or `-s` flag to your `standard `standard-version` supports lifecycle scripts. These allow you to execute your own supplementary commands during the release. The following -hooks are available: +hooks are available and execute in the order documented: -* `prebump`: executed before the version bump is calculated. If the `prebump` +* `prebump`/`postbump`: executed before and after the version is bumped. If the `prebump` script returns a version #, it will be used rather than the version calculated by `standard-version`. -* `postbump`: executed after the version has been bumped and written to - package.json. The flag `--new-version` is populated with the version that is - being released. -* `precommit`: called after CHANGELOG.md and package.json have been updated, - but before changes have been committed to git. +* `prechangelog`/`postchangelog`: executes before and after the CHANGELOG is generated. +* `precommit`/`postcommit`: called before and after the commit step. +* `pretag`/`posttag`: called before and after the tagging step. -Simply add the following to your package.json, to enable lifecycle scripts: +Simply add the following to your package.json to configure lifecycle scripts: ```json { @@ -179,6 +177,21 @@ Simply add the following to your package.json, to enable lifecycle scripts: } ``` +### Skipping lifecycle steps + +You can skip any of the lifecycle steps (`bump`, `changelog`, `commit`, `tag`), +by adding the following to your package.json: + +```json +{ + "standard-version": { + "skip": { + "changelog": true + } + } +} +``` + ### Committing generated artifacts in the release commit If you want to commit generated artifacts in the release commit (e.g. [#96](https://github.com/conventional-changelog/standard-version/issues/96)), you can use the `--commit-all` or `-a` flag. You will need to stage the artifacts you want to commit, so your `release` command could look like this: diff --git a/command.js b/command.js index 3c6522693..0fa0442ca 100755 --- a/command.js +++ b/command.js @@ -60,7 +60,11 @@ module.exports = require('yargs') default: defaults.tagPrefix }) .option('scripts', { - describe: 'Scripts to execute for lifecycle events (prebump, precommit, etc.,)', + describe: 'Provide scripts to execute for lifecycle events (prebump, precommit, etc.,)', + default: defaults.scripts + }) + .option('skip', { + describe: 'Map of steps in the release process that should be skipped', default: defaults.scripts }) .option('dry-run', { @@ -71,6 +75,8 @@ module.exports = require('yargs') .check((argv) => { if (typeof argv.scripts !== 'object' || Array.isArray(argv.scripts)) { throw Error('scripts must be an object') + } else if (typeof argv.skip !== 'object' || Array.isArray(argv.skip)) { + throw Error('skip must be an object') } else { return true } diff --git a/defaults.json b/defaults.json index b737c0c68..2834fb345 100644 --- a/defaults.json +++ b/defaults.json @@ -8,5 +8,6 @@ "silent": false, "tagPrefix": "v", "scripts": {}, + "skip": {}, "dryRun": false } diff --git a/index.js b/index.js index 5b56f3c42..b5936eeb2 100755 --- a/index.js +++ b/index.js @@ -1,19 +1,10 @@ -const conventionalRecommendedBump = require('conventional-recommended-bump') -const conventionalChangelog = require('conventional-changelog') const path = require('path') - -const chalk = require('chalk') -const figures = require('figures') -const fs = require('fs') -const accessSync = require('fs-access').sync -const semver = require('semver') -const util = require('util') - -const checkpoint = require('./lib/checkpoint') const printError = require('./lib/print-error') -const runExec = require('./lib/run-exec') -const runLifecycleScript = require('./lib/run-lifecycle-script') -const writeFile = require('./lib/write-file') + +const bump = require('./lib/lifecycles/bump') +const changelog = require('./lib/lifecycles/changelog') +const commit = require('./lib/lifecycles/commit') +const tag = require('./lib/lifecycles/tag') module.exports = function standardVersion (argv) { var pkgPath = path.resolve(process.cwd(), './package.json') @@ -22,30 +13,17 @@ module.exports = function standardVersion (argv) { var defaults = require('./defaults') var args = Object.assign({}, defaults, argv) - return runLifecycleScript(args, 'prebump', null) - .then((stdout) => { - if (stdout && stdout.trim().length) args.releaseAs = stdout.trim() - return bumpVersion(args.releaseAs) - }) - .then((release) => { - if (!args.firstRelease) { - var releaseType = getReleaseType(args.prerelease, release.releaseType, pkg.version) - newVersion = semver.valid(releaseType) || semver.inc(pkg.version, releaseType, args.prerelease) - updateConfigs(args, newVersion) - } else { - checkpoint(args, 'skip version bump on first release', [], chalk.red(figures.cross)) - } - - return runLifecycleScript(args, 'postbump', newVersion, args) - }) + return Promise.resolve() .then(() => { - return outputChangelog(args, newVersion) + return bump(args, pkg) }) - .then(() => { - return runLifecycleScript(args, 'precommit', newVersion, args) + .then((_newVersion) => { + // if bump runs, it calculaes the new version that we + // should release at. + if (_newVersion) newVersion = _newVersion + return changelog(args, newVersion) }) - .then((message) => { - if (message && message.length) args.message = message + .then(() => { return commit(args, newVersion) }) .then(() => { @@ -56,203 +34,3 @@ module.exports = function standardVersion (argv) { throw err }) } - -/** - * attempt to update the version # in a collection of common config - * files, e.g., package.json, bower.json. - * - * @param args config object - * @param newVersion version # to update to. - * @return {string} - */ -var configsToUpdate = {} -function updateConfigs (args, newVersion) { - configsToUpdate[path.resolve(process.cwd(), './package.json')] = false - configsToUpdate[path.resolve(process.cwd(), './npm-shrinkwrap.json')] = false - configsToUpdate[path.resolve(process.cwd(), './bower.json')] = false - Object.keys(configsToUpdate).forEach(function (configPath) { - try { - var stat = fs.lstatSync(configPath) - if (stat.isFile()) { - var config = require(configPath) - var filename = path.basename(configPath) - checkpoint(args, 'bumping version in ' + filename + ' from %s to %s', [config.version, newVersion]) - config.version = newVersion - writeFile(args, configPath, JSON.stringify(config, null, 2) + '\n') - // flag any config files that we modify the version # for - // as having been updated. - configsToUpdate[configPath] = true - } - } catch (err) { - if (err.code !== 'ENOENT') console.warn(err.message) - } - }) -} - -function getReleaseType (prerelease, expectedReleaseType, currentVersion) { - if (isString(prerelease)) { - if (isInPrerelease(currentVersion)) { - if (shouldContinuePrerelease(currentVersion, expectedReleaseType) || - getTypePriority(getCurrentActiveType(currentVersion)) > getTypePriority(expectedReleaseType) - ) { - return 'prerelease' - } - } - - return 'pre' + expectedReleaseType - } else { - return expectedReleaseType - } -} - -function isString (val) { - return typeof val === 'string' -} - -/** - * if a version is currently in pre-release state, - * and if it current in-pre-release type is same as expect type, - * it should continue the pre-release with the same type - * - * @param version - * @param expectType - * @return {boolean} - */ -function shouldContinuePrerelease (version, expectType) { - return getCurrentActiveType(version) === expectType -} - -function isInPrerelease (version) { - return Array.isArray(semver.prerelease(version)) -} - -var TypeList = ['major', 'minor', 'patch'].reverse() - -/** - * extract the in-pre-release type in target version - * - * @param version - * @return {string} - */ -function getCurrentActiveType (version) { - var typelist = TypeList - for (var i = 0; i < typelist.length; i++) { - if (semver[typelist[i]](version)) { - return typelist[i] - } - } -} - -/** - * calculate the priority of release type, - * major - 2, minor - 1, patch - 0 - * - * @param type - * @return {number} - */ -function getTypePriority (type) { - return TypeList.indexOf(type) -} - -function bumpVersion (releaseAs, callback) { - return new Promise((resolve, reject) => { - if (releaseAs) { - return resolve({ - releaseType: releaseAs - }) - } else { - conventionalRecommendedBump({ - preset: 'angular' - }, function (err, release) { - if (err) return reject(err) - else return resolve(release) - }) - } - }) -} - -function outputChangelog (args, newVersion) { - return new Promise((resolve, reject) => { - createIfMissing(args) - var header = '# Change Log\n\nAll notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.\n' - var oldContent = args.dryRun ? '' : fs.readFileSync(args.infile, 'utf-8') - // find the position of the last release and remove header: - if (oldContent.indexOf(' { - return runExec(args, 'git commit ' + verify + (args.sign ? '-S ' : '') + (args.commitAll ? '' : (args.infile + toAdd)) + ' -m "' + formatCommitMessage(args.message, newVersion) + '"') - }) -} - -function formatCommitMessage (msg, newVersion) { - return String(msg).indexOf('%s') !== -1 ? util.format(msg, newVersion) : msg -} - -function tag (newVersion, pkgPrivate, args) { - var tagOption - if (args.sign) { - tagOption = '-s ' - } else { - tagOption = '-a ' - } - checkpoint(args, 'tagging release %s', [newVersion]) - return runExec(args, 'git tag ' + tagOption + args.tagPrefix + newVersion + ' -m "' + formatCommitMessage(args.message, newVersion) + '"') - .then(() => { - var message = 'git push --follow-tags origin master' - if (pkgPrivate !== true) message += '; npm publish' - - checkpoint(args, 'Run `%s` to publish', [message], chalk.blue(figures.info)) - }) -} - -function createIfMissing (args) { - try { - accessSync(args.infile, fs.F_OK) - } catch (err) { - if (err.code === 'ENOENT') { - checkpoint(args, 'created %s', [args.infile]) - args.outputUnreleased = true - writeFile(args, args.infile, '\n') - } - } -} diff --git a/lib/checkpoint.js b/lib/checkpoint.js index 516ab568f..634c8b5f7 100644 --- a/lib/checkpoint.js +++ b/lib/checkpoint.js @@ -2,10 +2,10 @@ const chalk = require('chalk') const figures = require('figures') const util = require('util') -module.exports = function (args, msg, vars, figure) { +module.exports = function (argv, msg, args, figure) { const defaultFigure = args.dryRun ? chalk.yellow(figures.tick) : chalk.green(figures.tick) - if (!args.silent) { - console.info((figure || defaultFigure) + ' ' + util.format.apply(util, [msg].concat(vars.map(function (arg) { + if (!argv.silent) { + console.info((figure || defaultFigure) + ' ' + util.format.apply(util, [msg].concat(args.map(function (arg) { return chalk.bold(arg) })))) } diff --git a/lib/format-commit-message.js b/lib/format-commit-message.js new file mode 100644 index 000000000..06b8702fe --- /dev/null +++ b/lib/format-commit-message.js @@ -0,0 +1,5 @@ +const util = require('util') + +module.exports = function (msg, newVersion) { + return String(msg).indexOf('%s') !== -1 ? util.format(msg, newVersion) : msg +} diff --git a/lib/lifecycles/bump.js b/lib/lifecycles/bump.js new file mode 100644 index 000000000..2508b69a8 --- /dev/null +++ b/lib/lifecycles/bump.js @@ -0,0 +1,159 @@ +const chalk = require('chalk') +const checkpoint = require('../checkpoint') +const conventionalRecommendedBump = require('conventional-recommended-bump') +const figures = require('figures') +const fs = require('fs') +const path = require('path') +const runLifecycleScript = require('../run-lifecycle-script') +const semver = require('semver') +const writeFile = require('../write-file') + +var configsToUpdate = {} + +function Bump (args, pkg) { + // reset the cache of updated config files each + // time we perform the version bump step. + configsToUpdate = {} + + if (args.skip.bump) return Promise.resolve() + var newVersion = pkg.version + return runLifecycleScript(args, 'prebump') + .then((stdout) => { + if (stdout && stdout.trim().length) args.releaseAs = stdout.trim() + return bumpVersion(args.releaseAs) + }) + .then((release) => { + if (!args.firstRelease) { + var releaseType = getReleaseType(args.prerelease, release.releaseType, pkg.version) + newVersion = semver.valid(releaseType) || semver.inc(pkg.version, releaseType, args.prerelease) + updateConfigs(args, newVersion) + } else { + checkpoint(args, 'skip version bump on first release', [], chalk.red(figures.cross)) + } + }) + .then(() => { + return runLifecycleScript(args, 'postbump') + }) + .then(() => { + return newVersion + }) +} + +Bump.getUpdatedConfigs = function () { + return configsToUpdate +} + +function getReleaseType (prerelease, expectedReleaseType, currentVersion) { + if (isString(prerelease)) { + if (isInPrerelease(currentVersion)) { + if (shouldContinuePrerelease(currentVersion, expectedReleaseType) || + getTypePriority(getCurrentActiveType(currentVersion)) > getTypePriority(expectedReleaseType) + ) { + return 'prerelease' + } + } + + return 'pre' + expectedReleaseType + } else { + return expectedReleaseType + } +} + +function isString (val) { + return typeof val === 'string' +} + +/** + * if a version is currently in pre-release state, + * and if it current in-pre-release type is same as expect type, + * it should continue the pre-release with the same type + * + * @param version + * @param expectType + * @return {boolean} + */ +function shouldContinuePrerelease (version, expectType) { + return getCurrentActiveType(version) === expectType +} + +function isInPrerelease (version) { + return Array.isArray(semver.prerelease(version)) +} + +var TypeList = ['major', 'minor', 'patch'].reverse() + +/** + * extract the in-pre-release type in target version + * + * @param version + * @return {string} + */ +function getCurrentActiveType (version) { + var typelist = TypeList + for (var i = 0; i < typelist.length; i++) { + if (semver[typelist[i]](version)) { + return typelist[i] + } + } +} + +/** + * calculate the priority of release type, + * major - 2, minor - 1, patch - 0 + * + * @param type + * @return {number} + */ +function getTypePriority (type) { + return TypeList.indexOf(type) +} + +function bumpVersion (releaseAs, callback) { + return new Promise((resolve, reject) => { + if (releaseAs) { + return resolve({ + releaseType: releaseAs + }) + } else { + conventionalRecommendedBump({ + preset: 'angular' + }, function (err, release) { + if (err) return reject(err) + else return resolve(release) + }) + } + }) +} + +/** + * attempt to update the version # in a collection of common config + * files, e.g., package.json, bower.json. + * + * @param args config object + * @param newVersion version # to update to. + * @return {string} + */ +function updateConfigs (args, newVersion) { + configsToUpdate[path.resolve(process.cwd(), './package.json')] = false + configsToUpdate[path.resolve(process.cwd(), './npm-shrinkwrap.json')] = false + configsToUpdate[path.resolve(process.cwd(), './bower.json')] = false + Object.keys(configsToUpdate).forEach(function (configPath) { + try { + var stat = fs.lstatSync(configPath) + if (stat.isFile()) { + var config = require(configPath) + var filename = path.basename(configPath) + checkpoint(args, 'bumping version in ' + filename + ' from %s to %s', [config.version, newVersion]) + config.version = newVersion + writeFile(args, configPath, JSON.stringify(config, null, 2) + '\n') + // flag any config files that we modify the version # for + // as having been updated. + configsToUpdate[configPath] = true + } + } catch (err) { + if (err.code !== 'ENOENT') console.warn(err.message) + } + }) +} + +module.exports = Bump diff --git a/lib/lifecycles/changelog.js b/lib/lifecycles/changelog.js new file mode 100644 index 000000000..1eb16178e --- /dev/null +++ b/lib/lifecycles/changelog.js @@ -0,0 +1,62 @@ +const accessSync = require('fs-access').sync +const chalk = require('chalk') +const checkpoint = require('../checkpoint') +const conventionalChangelog = require('conventional-changelog') +const fs = require('fs') +const runLifecycleScript = require('../run-lifecycle-script') +const writeFile = require('../write-file') + +module.exports = function (args, newVersion) { + if (args.skip.changelog) return Promise.resolve() + return runLifecycleScript(args, 'prechangelog') + .then(() => { + return outputChangelog(args, newVersion) + }) + .then(() => { + return runLifecycleScript(args, 'postchangelog') + }) +} + +function outputChangelog (args, newVersion) { + return new Promise((resolve, reject) => { + createIfMissing(args) + var header = '# Change Log\n\nAll notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.\n' + var oldContent = args.dryRun ? '' : fs.readFileSync(args.infile, 'utf-8') + // find the position of the last release and remove header: + if (oldContent.indexOf(' { + if (message && message.length) args.message = message + return execCommit(args, newVersion) + }) + .then(() => { + return runLifecycleScript(args, 'postcommit') + }) +} + +function execCommit (args, newVersion) { + var msg = 'committing %s' + var paths = [args.infile] + var verify = args.verify === false || args.n ? '--no-verify ' : '' + var toAdd = '' + // commit any of the config files that we've updated + // the version # for. + Object.keys(bump.getUpdatedConfigs()).forEach(function (p) { + if (bump.getUpdatedConfigs()[p]) { + msg += ' and %s' + paths.unshift(path.basename(p)) + toAdd += ' ' + path.relative(process.cwd(), p) + } + }) + checkpoint(args, msg, paths) + return runExec(args, 'git add' + toAdd + ' ' + args.infile) + .then(() => { + return runExec(args, 'git commit ' + verify + (args.sign ? '-S ' : '') + (args.commitAll ? '' : (args.infile + toAdd)) + ' -m "' + formatCommitMessage(args.message, newVersion) + '"') + }) +} diff --git a/lib/lifecycles/tag.js b/lib/lifecycles/tag.js new file mode 100644 index 000000000..b5bf7aefc --- /dev/null +++ b/lib/lifecycles/tag.js @@ -0,0 +1,34 @@ +const chalk = require('chalk') +const checkpoint = require('../checkpoint') +const figures = require('figures') +const formatCommitMessage = require('../format-commit-message') +const runExec = require('../run-exec') +const runLifecycleScript = require('../run-lifecycle-script') + +module.exports = function (newVersion, pkgPrivate, args) { + if (args.skip.tag) return Promise.resolve() + return runLifecycleScript(args, 'pretag') + .then(() => { + return execTag(newVersion, pkgPrivate, args) + }) + .then(() => { + return runLifecycleScript(args, 'posttag') + }) +} + +function execTag (newVersion, pkgPrivate, args) { + var tagOption + if (args.sign) { + tagOption = '-s ' + } else { + tagOption = '-a ' + } + checkpoint(args, 'tagging release %s', [newVersion]) + return runExec(args, 'git tag ' + tagOption + args.tagPrefix + newVersion + ' -m "' + formatCommitMessage(args.message, newVersion) + '"') + .then(() => { + var message = 'git push --follow-tags origin master' + if (pkgPrivate !== true) message += '; npm publish' + + checkpoint(args, 'Run `%s` to publish', [message], chalk.blue(figures.info)) + }) +} diff --git a/lib/run-lifecycle-script.js b/lib/run-lifecycle-script.js index f4c2baa07..a5014482b 100644 --- a/lib/run-lifecycle-script.js +++ b/lib/run-lifecycle-script.js @@ -3,11 +3,10 @@ const checkpoint = require('./checkpoint') const figures = require('figures') const runExec = require('./run-exec') -module.exports = function (args, hookName, newVersion) { +module.exports = function (args, hookName) { const scripts = args.scripts if (!scripts || !scripts[hookName]) return Promise.resolve() var command = scripts[hookName] - if (newVersion) command += ' --new-version="' + newVersion + '"' checkpoint(args, 'Running lifecycle script "%s"', [hookName]) checkpoint(args, '- execute command: "%s"', [command], chalk.blue(figures.info)) return runExec(args, command) diff --git a/test.js b/test.js index e1702fd3a..d5fc9bbbe 100644 --- a/test.js +++ b/test.js @@ -688,4 +688,36 @@ describe('standard-version', function () { return done() }) }) + + describe('skip', () => { + it('allows bump and changelog generation to be skipped', function () { + let changelogContent = 'legacy header format\n' + writePackageJson('1.0.0') + fs.writeFileSync('CHANGELOG.md', changelogContent, 'utf-8') + + commit('feat: first commit') + return execCliAsync('--skip.bump true --skip.changelog true') + .then(function () { + getPackageVersion().should.equal('1.0.0') + var content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.equal(changelogContent) + }) + }) + + it('allows the commit phase to be skipped', function () { + let changelogContent = 'legacy header format\n' + writePackageJson('1.0.0') + fs.writeFileSync('CHANGELOG.md', changelogContent, 'utf-8') + + commit('feat: new feature from branch') + return execCliAsync('--skip.commit true') + .then(function () { + getPackageVersion().should.equal('1.1.0') + var content = fs.readFileSync('CHANGELOG.md', 'utf-8') + content.should.match(/new feature from branch/) + // check last commit message + shell.exec('git log --oneline -n1').stdout.should.match(/feat: new feature from branch/) + }) + }) + }) })