From c2beb643fa19d6f620de643e31cdb0112720ae67 Mon Sep 17 00:00:00 2001 From: Pierre Vanduynslager Date: Mon, 12 Feb 2018 17:14:24 -0500 Subject: [PATCH] feat: add the `prepare` plugin hook BREAKING CHANGE: Committing or creating files in the `publish` plugin hook is not supported anymore and now must be done in the `prepare` hook Plugins with a `publish` hook that makes a commit or create a file that can be committed must use the `prepare` hook. --- README.md | 1 + cli.js | 1 + docs/extending/plugins-list.md | 6 +++-- docs/usage/configuration.md | 12 +++++++++ docs/usage/plugins.md | 8 ++++++ index.js | 36 ++++++++++++-------------- lib/definitions/plugins.js | 6 +++++ lib/git.js | 17 ------------ package.json | 2 +- test/cli.test.js | 4 +++ test/definitions/plugins.test.js | 11 ++++++++ test/git.test.js | 13 ---------- test/index.test.js | 44 +++++++++++++++++++++++++------- test/plugins/plugins.test.js | 4 +++ 14 files changed, 104 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 4368f490..58c43aff 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ After running the tests the command `semantic-release` will execute the followin | Verify release | Verify the release conformity with the [verify release plugins](docs/usage/plugins.md#verifyrelease-plugin). | | Generate notes | Generate release notes with the [generate notes plugin](docs/usage/plugins.md#generatenotes-plugin) for the commits added since the last release. | | Create Git tag | Create a Git tag corresponding the new release version | +| Prepare | Prepare the release with the [prepare plugins](docs/usage/plugins.md#prepare-plugin). | | Publish | Publish the release with the [publish plugins](docs/usage/plugins.md#publish-plugin). | | Notify | Notify of new releases or errors with the [success](docs/usage/plugins.md#success-plugin) and [fail](docs/usage/plugins.md#fail-plugin) plugins. | diff --git a/cli.js b/cli.js index 7fe2a8cb..a459cefa 100755 --- a/cli.js +++ b/cli.js @@ -25,6 +25,7 @@ Usage: .option('analyze-commits', {type: 'string', group: 'Plugins'}) .option('verify-release', {...stringList, group: 'Plugins'}) .option('generate-notes', {type: 'string', group: 'Plugins'}) + .option('prepare', {...stringList, group: 'Plugins'}) .option('publish', {...stringList, group: 'Plugins'}) .option('success', {...stringList, group: 'Plugins'}) .option('fail', {...stringList, group: 'Plugins'}) diff --git a/docs/extending/plugins-list.md b/docs/extending/plugins-list.md index bfc82308..84ea855e 100644 --- a/docs/extending/plugins-list.md +++ b/docs/extending/plugins-list.md @@ -9,6 +9,7 @@ - [fail](https://github.com/semantic-release/github#fail): Open a GitHub issue when a release fails - [@semantic-release/npm](https://github.com/semantic-release/npm) - [verifyConditions](https://github.com/semantic-release/npm#verifyconditions): Verify the presence and the validity of the npm authentication and release configuration + - [prepare](https://github.com/semantic-release/npm#prepare): Update the package.json version and create the npm package tarball - [publish](https://github.com/semantic-release/npm#publish): Publish the package on the npm registry ## Official plugins @@ -18,15 +19,16 @@ - [publish](https://github.com/semantic-release/gitlab#publish): Publish a [GitLab release](https://docs.gitlab.com/ce/workflow/releases.html) - [@semantic-release/git](https://github.com/semantic-release/git) - [verifyConditions](https://github.com/semantic-release/git#verifyconditions): Verify the presence and the validity of the Git authentication and release configuration - - [publish](https://github.com/semantic-release/git#publish): Push a release commit and tag, including configurable files + - [prepare](https://github.com/semantic-release/git#prepare): Push a release commit and tag, including configurable files - [@semantic-release/changelog](https://github.com/semantic-release/changelog) - [verifyConditions](https://github.com/semantic-release/changelog#verifyconditions): Verify the presence and the validity of the configuration - - [publish](https://github.com/semantic-release/changelog#publish): Create or update the changelog file in the local project repository + - [prepare](https://github.com/semantic-release/changelog#prepare): Create or update the changelog file in the local project repository - [@semantic-release/exec](https://github.com/semantic-release/exec) - [verifyConditions](https://github.com/semantic-release/exec#verifyconditions): Execute a shell command to verify if the release should happen - [analyzeCommits](https://github.com/semantic-release/exec#analyzecommits): Execute a shell command to determine the type of release - [verifyRelease](https://github.com/semantic-release/exec#verifyrelease): Execute a shell command to verifying a release that was determined before and is about to be published. - [generateNotes](https://github.com/semantic-release/exec#analyzecommits): Execute a shell command to generate the release note + - [prepare](https://github.com/semantic-release/exec#prepare): Execute a shell command to prepare the release - [publish](https://github.com/semantic-release/exec#publish): Execute a shell command to publish the release - [success](https://github.com/semantic-release/exec#success): Execute a shell command to notify of a new release - [fail](https://github.com/semantic-release/exec#fail): Execute a shell command to notify of a failed release diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index b88d7093..c03f72b8 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -155,6 +155,18 @@ Define the [generate notes plugin](plugins.md#generatenotes-plugin). See [Plugins configuration](plugins.md#configuration) for more details. +### prepare + +Type: `Array`, `String`, `Object` + +Default: `['@semantic-release/npm']` + +CLI argument: `--prepare` + +Define the list of [prepare plugins](plugins.md#prepare-plugin). Plugins will run in series, in the order defined in the `Array`. + +See [Plugins configuration](plugins.md#configuration) for more details. + ### publish Type: `Array`, `String`, `Object` diff --git a/docs/usage/plugins.md b/docs/usage/plugins.md index 62e2c05d..d53dbc3a 100644 --- a/docs/usage/plugins.md +++ b/docs/usage/plugins.md @@ -28,6 +28,14 @@ Plugin responsible for generating release notes. Default implementation: [@semantic-release/release-notes-generator](https://github.com/semantic-release/release-notes-generator). +### prepare plugin + +Plugin responsible for preparing the release, including: +- Creating or updating files such as `package.json`, `CHANGELOG.md`, documentation or compiled assets. +- Create and push commits + +Default implementation: [npm](https://github.com/semantic-release/npm#prepare). + ### publish plugin Plugin responsible for publishing the release. diff --git a/index.js b/index.js index e7c77d78..81249a3d 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,7 @@ const getCommits = require('./lib/get-commits'); const getLastRelease = require('./lib/get-last-release'); const {extractErrors} = require('./lib/utils'); const logger = require('./lib/logger'); -const {unshallow, gitHead: getGitHead, tag, push, deleteTag} = require('./lib/git'); +const {unshallow, gitHead: getGitHead, tag, push} = require('./lib/git'); marked.setOptions({renderer: new TerminalRenderer()}); @@ -41,7 +41,7 @@ async function run(options, plugins) { await verify(options); logger.log('Run automated release from branch %s', options.branch); - + console.log(options); logger.log('Call plugin %s', 'verify-conditions'); await plugins.verifyConditions({options, logger}, {settleAll: true}); @@ -79,26 +79,14 @@ async function run(options, plugins) { logger.log('Call plugin %s', 'generateNotes'); nextRelease.notes = await plugins.generateNotes(generateNotesParam); - // Create the tag before calling the publish plugins as some require the tag to exists - logger.log('Create tag %s', nextRelease.gitTag); - await tag(nextRelease.gitTag); - await push(options.repositoryUrl, branch); - - logger.log('Call plugin %s', 'publish'); - const releases = await plugins.publish( + logger.log('Call plugin %s', 'prepare'); + await plugins.prepare( {options, logger, lastRelease, commits, nextRelease}, { getNextInput: async lastResult => { const newGitHead = await getGitHead(); - // If previous publish plugin has created a commit (gitHead changed) + // If previous prepare plugin has created a commit (gitHead changed) if (lastResult.nextRelease.gitHead !== newGitHead) { - // Delete the previously created tag - await deleteTag(options.repositoryUrl, nextRelease.gitTag); - // Recreate the tag, referencing the new gitHead - logger.log('Create tag %s', nextRelease.gitTag); - await tag(nextRelease.gitTag); - await push(options.repositoryUrl, branch); - nextRelease.gitHead = newGitHead; // Regenerate the release notes logger.log('Call plugin %s', 'generateNotes'); @@ -107,11 +95,21 @@ async function run(options, plugins) { // Call the next publish plugin with the updated `nextRelease` return {options, logger, lastRelease, commits, nextRelease}; }, - // Add nextRelease and plugin properties to published release - transform: (release, step) => ({...(isPlainObject(release) ? release : {}), ...nextRelease, ...step}), } ); + // Create the tag before calling the publish plugins as some require the tag to exists + logger.log('Create tag %s', nextRelease.gitTag); + await tag(nextRelease.gitTag); + await push(options.repositoryUrl, branch); + + logger.log('Call plugin %s', 'publish'); + const releases = await plugins.publish( + {options, logger, lastRelease, commits, nextRelease}, + // Add nextRelease and plugin properties to published release + {transform: (release, step) => ({...(isPlainObject(release) ? release : {}), ...nextRelease, ...step})} + ); + await plugins.success( {options, logger, lastRelease, commits, nextRelease, releases: castArray(releases)}, {settleAll: true} diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js index 2e956e32..64171260 100644 --- a/lib/definitions/plugins.js +++ b/lib/definitions/plugins.js @@ -36,6 +36,12 @@ module.exports = { error: 'ERELEASENOTESOUTPUT', }, }, + prepare: { + default: ['@semantic-release/npm'], + config: { + validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + }, + }, publish: { default: ['@semantic-release/npm', '@semantic-release/github'], config: { diff --git a/lib/git.js b/lib/git.js index dcca5da2..99612a39 100644 --- a/lib/git.js +++ b/lib/git.js @@ -115,22 +115,6 @@ async function push(origin, branch) { await execa('git', ['push', '--tags', origin, `HEAD:${branch}`]); } -/** - * Delete a tag locally and remotely. - * - * @param {String} origin The remote repository URL. - * @param {String} tagName The tag name to delete. - */ -async function deleteTag(origin, tagName) { - // Delete the local tag - let shell = await execa('git', ['tag', '-d', tagName], {reject: false}); - debug('delete local tag', shell); - - // Delete the tag remotely - shell = await execa('git', ['push', '--delete', origin, tagName], {reject: false}); - debug('delete remote tag', shell); -} - /** * Verify a tag name is a valid Git reference. * @@ -157,6 +141,5 @@ module.exports = { verifyAuth, tag, push, - deleteTag, verifyTagName, }; diff --git a/package.json b/package.json index c3ca8368..ce9da627 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@semantic-release/commit-analyzer": "^5.0.0", "@semantic-release/error": "^2.2.0", "@semantic-release/github": "^4.1.0", - "@semantic-release/npm": "^3.1.0", + "@semantic-release/npm": "^3.2.0", "@semantic-release/release-notes-generator": "^6.0.0", "aggregate-error": "^1.0.0", "chalk": "^2.3.0", diff --git a/test/cli.test.js b/test/cli.test.js index fd057e49..b03b145e 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -53,6 +53,9 @@ test.serial('Pass options to semantic-release API', async t => { 'verify2', '--generate-notes', 'notes', + '--prepare', + 'prepare1', + 'prepare2', '--publish', 'publish1', 'publish2', @@ -76,6 +79,7 @@ test.serial('Pass options to semantic-release API', async t => { t.is(run.args[0][0].analyzeCommits, 'analyze'); t.deepEqual(run.args[0][0].verifyRelease, ['verify1', 'verify2']); t.is(run.args[0][0].generateNotes, 'notes'); + t.deepEqual(run.args[0][0].prepare, ['prepare1', 'prepare2']); t.deepEqual(run.args[0][0].publish, ['publish1', 'publish2']); t.deepEqual(run.args[0][0].success, ['success1', 'success2']); t.deepEqual(run.args[0][0].fail, ['fail1', 'fail2']); diff --git a/test/definitions/plugins.test.js b/test/definitions/plugins.test.js index 1dd7b5fd..744c8a4f 100644 --- a/test/definitions/plugins.test.js +++ b/test/definitions/plugins.test.js @@ -46,6 +46,17 @@ test('The "generateNotes" plugin, if defined, must be a single plugin definition t.true(plugins.generateNotes.config.validator(() => {})); }); +test('The "prepare" plugin, if defined, must be a single or an array of plugins definition', t => { + t.false(plugins.verifyRelease.config.validator({})); + t.false(plugins.verifyRelease.config.validator({path: null})); + + t.true(plugins.verifyRelease.config.validator({path: 'plugin-path.js'})); + t.true(plugins.verifyRelease.config.validator()); + t.true(plugins.verifyRelease.config.validator('plugin-path.js')); + t.true(plugins.verifyRelease.config.validator(() => {})); + t.true(plugins.verifyRelease.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); +}); + test('The "publish" plugin is mandatory, and must be a single or an array of plugins definition', t => { t.false(plugins.publish.config.validator({})); t.false(plugins.publish.config.validator({path: null})); diff --git a/test/git.test.js b/test/git.test.js index 853ee2aa..f6ce186f 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -10,7 +10,6 @@ import { push, gitTags, isGitRepo, - deleteTag, verifyTagName, } from '../lib/git'; import { @@ -139,18 +138,6 @@ test.serial('Add tag on head commit', async t => { await t.is(await gitCommitTag(commits[0].hash), 'tag_name'); }); -test.serial('Delete a tag', async t => { - // Create a git repository with a remote, set the current working directory at the root of the repo - const repo = await gitRepo(true); - await gitCommits(['Test commit']); - await tag('tag_name'); - await push(repo, 'master'); - - await deleteTag(repo, 'tag_name'); - t.falsy(await gitTagHead('tag_name')); - t.falsy(await gitRemoteTagHead(repo, 'tag_name')); -}); - test.serial('Push tag and commit to remote repository', async t => { // Create a git repository with a remote, set the current working directory at the root of the repo const repo = await gitRepo(true); diff --git a/test/index.test.js b/test/index.test.js index 20595389..a0c51630 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -67,6 +67,7 @@ test.serial('Plugins are called with expected values', async t => { const verifyRelease = stub().resolves(); const generateNotes = stub().resolves(notes); const release1 = {name: 'Release 1', url: 'https://release1.com'}; + const prepare = stub().resolves(); const publish1 = stub().resolves(release1); const success = stub().resolves(); @@ -77,6 +78,7 @@ test.serial('Plugins are called with expected values', async t => { analyzeCommits, verifyRelease, generateNotes, + prepare, publish: [publish1, pluginNoop], success, }; @@ -119,6 +121,15 @@ test.serial('Plugins are called with expected values', async t => { t.deepEqual(generateNotes.args[0][1].commits[0].message, commits[0].message); t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease); + t.is(prepare.callCount, 1); + t.deepEqual(prepare.args[0][0], config); + t.deepEqual(prepare.args[0][1].options, options); + t.deepEqual(prepare.args[0][1].logger, t.context.logger); + t.deepEqual(prepare.args[0][1].lastRelease, lastRelease); + t.deepEqual(prepare.args[0][1].commits[0].hash, commits[0].hash); + t.deepEqual(prepare.args[0][1].commits[0].message, commits[0].message); + t.deepEqual(prepare.args[0][1].nextRelease, {...nextRelease, ...{notes}}); + t.is(publish1.callCount, 1); t.deepEqual(publish1.args[0][0], config); t.deepEqual(publish1.args[0][1].options, options); @@ -161,6 +172,7 @@ test.serial('Use custom tag format', async t => { analyzeCommits: stub().resolves(nextRelease.type), verifyRelease: stub().resolves(), generateNotes: stub().resolves(notes), + prepare: stub().resolves(), publish: stub().resolves(), success: stub().resolves(), fail: stub().resolves(), @@ -177,7 +189,7 @@ test.serial('Use custom tag format', async t => { t.is(await gitRemoteTagHead(repositoryUrl, nextRelease.gitTag), nextRelease.gitHead); }); -test.serial('Use new gitHead, and recreate release notes if a publish plugin create a commit', async t => { +test.serial('Use new gitHead, and recreate release notes if a prepare plugin create a commit', async t => { // Create a git repository, set the current working directory at the root of the repo const repositoryUrl = await gitRepo(true); // Add commits to the master branch @@ -191,10 +203,11 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre const notes = 'Release notes'; const generateNotes = stub().resolves(notes); - const publish1 = stub().callsFake(async () => { + const prepare1 = stub().callsFake(async () => { commits = (await gitCommits(['Third'])).concat(commits); }); - const publish2 = stub().resolves(); + const prepare2 = stub().resolves(); + const publish = stub().resolves(); const options = { branch: 'master', @@ -203,7 +216,8 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre analyzeCommits: stub().resolves(nextRelease.type), verifyRelease: stub().resolves(), generateNotes, - publish: [publish1, publish2], + prepare: [prepare1, prepare2], + publish, success: stub().resolves(), fail: stub().resolves(), }; @@ -217,14 +231,17 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre t.is(generateNotes.callCount, 2); t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease); - t.is(publish1.callCount, 1); - t.deepEqual(publish1.args[0][1].nextRelease, {...nextRelease, ...{notes}}); + t.is(prepare1.callCount, 1); + t.deepEqual(prepare1.args[0][1].nextRelease, {...nextRelease, ...{notes}}); nextRelease.gitHead = await getGitHead(); - t.deepEqual(generateNotes.secondCall.args[1].nextRelease, {...nextRelease, ...{notes}}); - t.is(publish2.callCount, 1); - t.deepEqual(publish2.args[0][1].nextRelease, {...nextRelease, ...{notes}}); + t.deepEqual(generateNotes.args[1][1].nextRelease, {...nextRelease, ...{notes}}); + t.is(prepare2.callCount, 1); + t.deepEqual(prepare2.args[0][1].nextRelease, {...nextRelease, ...{notes}}); + + t.is(publish.callCount, 1); + t.deepEqual(publish.args[0][1].nextRelease, {...nextRelease, ...{notes}}); // Verify the tag has been created on the local and remote repo and reference the last gitHead t.is(await gitTagHead(nextRelease.gitTag), commits[0].hash); @@ -258,6 +275,7 @@ test.serial('Call all "success" plugins even if one errors out', async t => { verifyConditions: [verifyConditions1, verifyConditions2], analyzeCommits, generateNotes, + prepare: stub().resolves(), publish, success: [success1, success2], }; @@ -383,6 +401,7 @@ test.serial('Dry-run skips publish and success', async t => { analyzeCommits, verifyRelease, generateNotes, + prepare: stub().resolves(), publish, success, }; @@ -464,6 +483,7 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a analyzeCommits, verifyRelease, generateNotes, + prepare: stub().resolves(), publish, success, fail: stub().resolves(), @@ -512,6 +532,7 @@ test.serial('Allow local releases with "noCi" option', async t => { analyzeCommits, verifyRelease, generateNotes, + prepare: stub().resolves(), publish, success, fail: stub().resolves(), @@ -560,6 +581,7 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins', analyzeCommits, verifyRelease, generateNotes, + prepare: stub().resolves(), publish, success: stub().resolves(), fail: stub().resolves(), @@ -611,6 +633,7 @@ test.serial('Returns falsy value if not running from the configured branch', asy analyzeCommits: stub().resolves(), verifyRelease: stub().resolves(), generateNotes: stub().resolves(), + prepare: stub().resolves(), publish: stub().resolves(), success: stub().resolves(), fail: stub().resolves(), @@ -646,6 +669,7 @@ test.serial('Returns falsy value if there is no relevant changes', async t => { analyzeCommits, verifyRelease, generateNotes, + prepare: stub().resolves(), publish, success: stub().resolves(), fail: stub().resolves(), @@ -686,6 +710,7 @@ test.serial('Exclude commits with [skip release] or [release skip] from analysis analyzeCommits, verifyRelease: stub().resolves(), generateNotes: stub().resolves(), + prepare: stub().resolves(), publish: stub().resolves(), success: stub().resolves(), fail: stub().resolves(), @@ -844,6 +869,7 @@ test.serial('Get all commits including the ones not in the shallow clone', async analyzeCommits, verifyRelease: stub().resolves(), generateNotes: stub().resolves(notes), + prepare: stub().resolves(), publish: stub().resolves(), success: stub().resolves(), fail: stub().resolves(), diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js index ba976f6b..c81214e6 100644 --- a/test/plugins/plugins.test.js +++ b/test/plugins/plugins.test.js @@ -27,6 +27,7 @@ test('Export default plugins', t => { t.is(typeof plugins.analyzeCommits, 'function'); t.is(typeof plugins.verifyRelease, 'function'); t.is(typeof plugins.generateNotes, 'function'); + t.is(typeof plugins.prepare, 'function'); t.is(typeof plugins.publish, 'function'); t.is(typeof plugins.success, 'function'); t.is(typeof plugins.fail, 'function'); @@ -49,6 +50,7 @@ test('Export plugins based on config', t => { t.is(typeof plugins.analyzeCommits, 'function'); t.is(typeof plugins.verifyRelease, 'function'); t.is(typeof plugins.generateNotes, 'function'); + t.is(typeof plugins.prepare, 'function'); t.is(typeof plugins.publish, 'function'); t.is(typeof plugins.success, 'function'); t.is(typeof plugins.fail, 'function'); @@ -79,6 +81,7 @@ test.serial('Export plugins loaded from the dependency of a shareable config mod t.is(typeof plugins.analyzeCommits, 'function'); t.is(typeof plugins.verifyRelease, 'function'); t.is(typeof plugins.generateNotes, 'function'); + t.is(typeof plugins.prepare, 'function'); t.is(typeof plugins.publish, 'function'); t.is(typeof plugins.success, 'function'); t.is(typeof plugins.fail, 'function'); @@ -106,6 +109,7 @@ test.serial('Export plugins loaded from the dependency of a shareable config fil t.is(typeof plugins.analyzeCommits, 'function'); t.is(typeof plugins.verifyRelease, 'function'); t.is(typeof plugins.generateNotes, 'function'); + t.is(typeof plugins.prepare, 'function'); t.is(typeof plugins.publish, 'function'); t.is(typeof plugins.success, 'function'); t.is(typeof plugins.fail, 'function');