Repository: semantic-release/semantic-release
Pull request #663 (2018-02-12 22:28:56)

feat: add the `prepare` plugin hook
Proof of concept for #649.
Related to semantic-release/npm#52.

This PR introduce the `prepare` plugin hook, that will be called before create the Git Tag and the `publish` hook.

Currently plugin that create a file that can be committed (e.g. a `CHANGELOG.md`), create a commit or publish to a registry all operate in the `publish` hook. As some plugins require to have the Git tag created in the remote repo before publishing we currently have to create the tag before calling the `publish` plugins and update the tag whenever a plugin create a commit.
This is a quite inelegant solution...

That leads to creation/deletion/re-creation of Git tags for each plugin doing a commit as described in #649.

The addition of the `prepare` hook make things more organized and cleaner:
- `prepare` => Do all the things required before pushing the Git tag, which can include creating a `CHANGELOG.md`, update the `package.json`, create compiled files, make a commit etc...
- `publish` => Do all the things required after pushing the Git tag, which is mostly publish the release

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, otherwise the commit will be done after the Git tag is pushed.

TODO:
- [x] Change the `@semantic-release/npm` dependency once semantic-release/npm#52 is released
- [x] Change `@semantic-release/changelog` plugin `publish` to `prepare`=> merge semantic-release/changelog#12
- [x] Change `@semantic-release/git` plugin `publish` to `prepare`=> merge semantic-release/git#35
- [x] Add `prepare` to `@semantic-release/exec` plugin => merge semantic-release/exec#14



pvdlg: > Code changes look good! Just to be clear, this is not committing the version to package.json in the main branch on GitHub, right?

No. Nothing change in that regards.
We just split `publish` into `publish` and `prepare` so it makes it easier to use the `git` plugin to push the updated `package.json`. But it's still optional and has to be set up by using the `git` plugin.

Patch:
diff --git a/README.md b/README.md
index 4368f49..58c43af 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 7fe2a8c..a459cef 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 bfc8230..84ea855 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 b88d709..c03f72b 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 62e2c05..d53dbc3 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 e7c77d7..81249a3 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 2e956e3..6417126 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 dcca5da..99612a3 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 c3ca836..ce9da62 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",


Test patch:
diff --git a/test/cli.test.js b/test/cli.test.js
index fd057e4..b03b145 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 1dd7b5f..744c8a4 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 853ee2a..f6ce186 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 2059538..a0c5163 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 ba976f6..c81214e 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');