Repository: semantic-release/semantic-release (js)

Issue:
Ability to use multiple generateNotes plugins
## New feature motivation

Far as I've seen on here and in documentation generateNotes only allows 1 plugin currently (please correct me if I'm wrong).

But wouldn't it be nice to easily prepend/append release note bits to the default release note generator?

Use case; currently have a graphql-server that's checked into version control, I run semantic release on it to generate release info (notes, tags, etc.). I also happen to have a script that generates a diff (in .md informational form) between the graphql schema's from a previous release and the current working directory. Would like to append this to the default release notes.

Current way of doing this would have me write a custom plugin that calls the default release note generator and then calls my bash script to append the schema differences.

## New feature description

- The generateNotes step should accept an array
- Way of resolving the generateNotes plugins should handle an array
- All plugin output should be present in the notes

Guessing the order/keeping the order might be an issue here?

Pull request description:
 feat: allow to define multiple `generateNotes` plugins
Fix #844

This PR allow to configure multiple `generateNotes` plugins and include a lot of refactoring/fixes/optimization in the plugin management.

While working on the multiple `generateNotes` part I found many related small issues and opportunities to clarify and simplify the code regarding the plugin management, so I included them here.

That's quite a lot of code change, but each individual and independent change is done in it's own commit. So it's a good idea to review commits one by one rather than reviewing the entire PR at once. 


Patch:
diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md
index 614649f..3f549a7 100644
--- a/docs/usage/configuration.md
+++ b/docs/usage/configuration.md
@@ -145,7 +145,7 @@ See [Plugins configuration](plugins.md#configuration) for more details.
 
 Type: `String`, `Object`
 
-Default: `['@semantic-release/commit-analyzer']`
+Default: `'@semantic-release/commit-analyzer'`
 
 CLI argument: `--analyze-commits`
 
@@ -167,13 +167,13 @@ See [Plugins configuration](plugins.md#configuration) for more details.
 
 ### generateNotes
 
-Type: `String`, `Object`
+Type: `Array`, `String`, `Object`
 
 Default: `['@semantic-release/release-notes-generator']`
 
 CLI argument: `--generate-notes`
 
-Define the [generate notes plugin](plugins.md#generatenotes-plugin).
+Define the [generate notes plugins](plugins.md#generatenotes-plugin).
 
 See [Plugins configuration](plugins.md#configuration) for more details.
 
diff --git a/docs/usage/plugins.md b/docs/usage/plugins.md
index 5dfd462..2b5d0c3 100644
--- a/docs/usage/plugins.md
+++ b/docs/usage/plugins.md
@@ -20,13 +20,13 @@ Default implementation: [@semantic-release/commit-analyzer](https://github.com/s
 
 ### verifyRelease plugin
 
-Responsible for verifying the parameters (version, type, dist-tag etc...) of the release that is about to be published match certain expectations. For example the [cracks plugin](https://github.com/semantic-release/cracks) is able to verify that if a release contains breaking changes, its type must be `major`.
+Responsible for verifying the parameters (version, type, dist-tag etc...) of the release that is about to be published. For example the [cracks plugin](https://github.com/semantic-release/cracks) is able to verify that if a release contains breaking changes, its type must be `major`.
 
 Default implementation: none.
 
 ### generateNotes plugin
 
-Responsible for generating release notes.
+Responsible for generating release notes. If multiple `generateNotes` plugins are defined, the release notes will be the result of the concatenation of plugin output.
 
 Default implementation: [@semantic-release/release-notes-generator](https://github.com/semantic-release/release-notes-generator).
 
diff --git a/index.js b/index.js
index e539599..250d5a5 100644
--- a/index.js
+++ b/index.js
@@ -1,4 +1,4 @@
-const {template, isPlainObject, castArray} = require('lodash');
+const {template} = require('lodash');
 const marked = require('marked');
 const TerminalRenderer = require('marked-terminal');
 const envCi = require('env-ci');
@@ -72,21 +72,14 @@ async function run(options, plugins) {
 
   logger.log('Run automated release from branch %s', options.branch);
 
-  logger.log('Call plugin %s', 'verify-conditions');
-  await plugins.verifyConditions({options, logger}, {settleAll: true});
+  await plugins.verifyConditions({options, logger});
 
   await fetch(options.repositoryUrl);
 
   const lastRelease = await getLastRelease(options.tagFormat, logger);
   const commits = await getCommits(lastRelease.gitHead, options.branch, logger);
 
-  logger.log('Call plugin %s', 'analyze-commits');
-  const type = await plugins.analyzeCommits({
-    options,
-    logger,
-    lastRelease,
-    commits: commits.filter(commit => !/\[skip\s+release\]|\[release\s+skip\]/i.test(commit.message)),
-  });
+  const type = await plugins.analyzeCommits({options, logger, lastRelease, commits});
   if (!type) {
     logger.log('There are no relevant changes, so no new version is released.');
     return;
@@ -94,57 +87,28 @@ async function run(options, plugins) {
   const version = getNextVersion(type, lastRelease, logger);
   const nextRelease = {type, version, gitHead: await getGitHead(), gitTag: template(options.tagFormat)({version})};
 
-  logger.log('Call plugin %s', 'verify-release');
-  await plugins.verifyRelease({options, logger, lastRelease, commits, nextRelease}, {settleAll: true});
+  await plugins.verifyRelease({options, logger, lastRelease, commits, nextRelease});
 
   const generateNotesParam = {options, logger, lastRelease, commits, nextRelease};
 
   if (options.dryRun) {
-    logger.log('Call plugin %s', 'generate-notes');
     const notes = await plugins.generateNotes(generateNotesParam);
     logger.log('Release note for version %s:\n', nextRelease.version);
     if (notes) {
       process.stdout.write(`${marked(notes)}\n`);
     }
   } else {
-    logger.log('Call plugin %s', 'generateNotes');
     nextRelease.notes = await plugins.generateNotes(generateNotesParam);
-
-    logger.log('Call plugin %s', 'prepare');
-    await plugins.prepare(
-      {options, logger, lastRelease, commits, nextRelease},
-      {
-        getNextInput: async lastResult => {
-          const newGitHead = await getGitHead();
-          // If previous prepare plugin has created a commit (gitHead changed)
-          if (lastResult.nextRelease.gitHead !== newGitHead) {
-            nextRelease.gitHead = newGitHead;
-            // Regenerate the release notes
-            logger.log('Call plugin %s', 'generateNotes');
-            nextRelease.notes = await plugins.generateNotes(generateNotesParam);
-          }
-          // Call the next publish plugin with the updated `nextRelease`
-          return {options, logger, lastRelease, commits, nextRelease};
-        },
-      }
-    );
+    await plugins.prepare({options, logger, lastRelease, commits, nextRelease});
 
     // 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})}
-    );
+    const releases = await plugins.publish({options, logger, lastRelease, commits, nextRelease});
 
-    await plugins.success(
-      {options, logger, lastRelease, commits, nextRelease, releases: castArray(releases)},
-      {settleAll: true}
-    );
+    await plugins.success({options, logger, lastRelease, commits, nextRelease, releases});
 
     logger.log('Published release: %s', nextRelease.version);
   }
@@ -169,7 +133,7 @@ async function callFail(plugins, options, error) {
   const errors = extractErrors(error).filter(error => error.semanticRelease);
   if (errors.length > 0) {
     try {
-      await plugins.fail({options, logger, errors}, {settleAll: true});
+      await plugins.fail({options, logger, errors});
     } catch (err) {
       logErrors(err);
     }
diff --git a/lib/definitions/constants.js b/lib/definitions/constants.js
index e09b8b5..bbce03d 100644
--- a/lib/definitions/constants.js
+++ b/lib/definitions/constants.js
@@ -6,4 +6,6 @@ const COMMIT_NAME = 'semantic-release-bot';
 
 const COMMIT_EMAIL = 'semantic-release-bot@martynus.net';
 
-module.exports = {RELEASE_TYPE, FIRST_RELEASE, COMMIT_NAME, COMMIT_EMAIL};
+const RELEASE_NOTES_SEPARATOR = '\n\n';
+
+module.exports = {RELEASE_TYPE, FIRST_RELEASE, COMMIT_NAME, COMMIT_EMAIL, RELEASE_NOTES_SEPARATOR};
diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js
index 232c4c2..f5aa40b 100644
--- a/lib/definitions/errors.js
+++ b/lib/definitions/errors.js
@@ -4,7 +4,7 @@ const {toLower, isString} = require('lodash');
 const pkg = require('../../package.json');
 const {RELEASE_TYPE} = require('./constants');
 
-const homepage = url.format({...url.parse(pkg.homepage), ...{hash: null}});
+const homepage = url.format({...url.parse(pkg.homepage), hash: null});
 const stringify = obj => (isString(obj) ? obj : inspect(obj, {breakLength: Infinity, depth: 2, maxArrayLength: 5}));
 const linkify = file => `${homepage}/blob/caribou/${file}`;
 
@@ -55,25 +55,25 @@ Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`
 
 Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`,
   }),
-  EPLUGINCONF: ({pluginType, pluginConf}) => ({
-    message: `The \`${pluginType}\` plugin configuration is invalid.`,
-    details: `The [${pluginType} plugin configuration](${linkify(
-      `docs/usage/plugins.md#${toLower(pluginType)}-plugin`
+  EPLUGINCONF: ({type, pluginConf}) => ({
+    message: `The \`${type}\` plugin configuration is invalid.`,
+    details: `The [${type} plugin configuration](${linkify(
+      `docs/usage/plugins.md#${toLower(type)}-plugin`
     )}) if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a \`path\` property.
 
-    Your configuration for the \`${pluginType}\` plugin is \`${stringify(pluginConf)}\`.`,
+    Your configuration for the \`${type}\` plugin is \`${stringify(pluginConf)}\`.`,
   }),
-  EPLUGIN: ({pluginName, pluginType}) => ({
-    message: `A plugin configured in the step ${pluginType} is not a valid semantic-release plugin.`,
-    details: `A valid \`${pluginType}\` **semantic-release** plugin must be a function or an object with a function in the property \`${pluginType}\`.
+  EPLUGIN: ({pluginName, type}) => ({
+    message: `A plugin configured in the step ${type} is not a valid semantic-release plugin.`,
+    details: `A valid \`${type}\` **semantic-release** plugin must be a function or an object with a function in the property \`${type}\`.
 
-The plugin \`${pluginName}\` doesn't have the property \`${pluginType}\` and cannot be used for the \`${pluginType}\` step.
+The plugin \`${pluginName}\` doesn't have the property \`${type}\` and cannot be used for the \`${type}\` step.
 
 Please refer to the \`${pluginName}\` and [semantic-release plugins configuration](${linkify(
       'docs/usage/plugins.md'
     )}) documentation for more details.`,
   }),
-  EANALYZEOUTPUT: ({result, pluginName}) => ({
+  EANALYZECOMMITSOUTPUT: ({result, pluginName}) => ({
     message: 'The `analyzeCommits` plugin returned an invalid value. It must return a valid semver release type.',
     details: `The \`analyzeCommits\` plugin must return a valid [semver](https://semver.org) release type. The valid values are: ${RELEASE_TYPE.map(
       type => `\`${type}\``
@@ -89,7 +89,7 @@ We recommend to report the issue to the \`${pluginName}\` authors, providing the
       'docs/developer-guide/plugin.md'
     )})`,
   }),
-  ERELEASENOTESOUTPUT: ({result, pluginName}) => ({
+  EGENERATENOTESOUTPUT: ({result, pluginName}) => ({
     message: 'The `generateNotes` plugin returned an invalid value. It must return a `String`.',
     details: `The \`generateNotes\` plugin must return a \`String\`.
 
diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js
index 2fdf337..144197e 100644
--- a/lib/definitions/plugins.js
+++ b/lib/definitions/plugins.js
@@ -1,67 +1,84 @@
 const {isString, isFunction, isArray, isPlainObject} = require('lodash');
-const {RELEASE_TYPE} = require('./constants');
+const {gitHead} = require('../git');
+const {RELEASE_TYPE, RELEASE_NOTES_SEPARATOR} = require('./constants');
 
 const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf);
 
 module.exports = {
   verifyConditions: {
     default: ['@semantic-release/npm', '@semantic-release/github'],
-    config: {
-      validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
-    },
+    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    pipelineConfig: () => ({settleAll: true}),
   },
   analyzeCommits: {
     default: '@semantic-release/commit-analyzer',
-    config: {
-      validator: conf => Boolean(conf) && validatePluginConfig(conf),
-    },
-    output: {
-      validator: output => !output || RELEASE_TYPE.includes(output),
-      error: 'EANALYZEOUTPUT',
-    },
+    configValidator: conf => Boolean(conf) && validatePluginConfig(conf),
+    outputValidator: output => !output || RELEASE_TYPE.includes(output),
+    preprocess: ({commits, ...inputs}) => ({
+      ...inputs,
+      commits: commits.filter(commit => !/\[skip\s+release\]|\[release\s+skip\]/i.test(commit.message)),
+    }),
+    postprocess: ([result]) => result,
   },
   verifyRelease: {
     default: false,
-    config: {
-      validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
-    },
+    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    pipelineConfig: () => ({settleAll: true}),
   },
   generateNotes: {
-    default: '@semantic-release/release-notes-generator',
-    config: {
-      validator: conf => !conf || validatePluginConfig(conf),
-    },
-    output: {
-      validator: output => !output || isString(output),
-      error: 'ERELEASENOTESOUTPUT',
-    },
+    default: ['@semantic-release/release-notes-generator'],
+    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    outputValidator: output => !output || isString(output),
+    pipelineConfig: () => ({
+      getNextInput: ({nextRelease, ...generateNotesParam}, notes) => ({
+        ...generateNotesParam,
+        nextRelease: {
+          ...nextRelease,
+          notes: `${nextRelease.notes ? `${nextRelease.notes}${RELEASE_NOTES_SEPARATOR}` : ''}${notes}`,
+        },
+      }),
+    }),
+    postprocess: results => results.filter(Boolean).join(RELEASE_NOTES_SEPARATOR),
   },
   prepare: {
     default: ['@semantic-release/npm'],
-    config: {
-      validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
-    },
+    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    pipelineConfig: ({generateNotes}, logger) => ({
+      getNextInput: async ({nextRelease, ...prepareParam}) => {
+        const newGitHead = await gitHead();
+        // If previous prepare plugin has created a commit (gitHead changed)
+        if (nextRelease.gitHead !== newGitHead) {
+          nextRelease.gitHead = newGitHead;
+          // Regenerate the release notes
+          logger.log('Call plugin %s', 'generateNotes');
+          nextRelease.notes = await generateNotes({nextRelease, ...prepareParam});
+        }
+        // Call the next publish plugin with the updated `nextRelease`
+        return {...prepareParam, nextRelease};
+      },
+    }),
   },
   publish: {
     default: ['@semantic-release/npm', '@semantic-release/github'],
-    config: {
-      validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
-    },
-    output: {
-      validator: output => !output || isPlainObject(output),
-      error: 'EPUBLISHOUTPUT',
-    },
+    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    outputValidator: output => !output || isPlainObject(output),
+    pipelineConfig: () => ({
+      // Add `nextRelease` and plugin properties to published release
+      transform: (release, step, {nextRelease}) => ({
+        ...(isPlainObject(release) ? release : {}),
+        ...nextRelease,
+        ...step,
+      }),
+    }),
   },
   success: {
     default: ['@semantic-release/github'],
-    config: {
-      validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
-    },
+    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    pipelineConfig: () => ({settleAll: true}),
   },
   fail: {
     default: ['@semantic-release/github'],
-    config: {
-      validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
-    },
+    configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
+    pipelineConfig: () => ({settleAll: true}),
   },
 };
diff --git a/lib/get-config.js b/lib/get-config.js
index 1937c54..2456087 100644
--- a/lib/get-config.js
+++ b/lib/get-config.js
@@ -34,9 +34,9 @@ module.exports = async (opts, logger) => {
 
         // For each plugin defined in a shareable config, save in `pluginsPath` the extendable config path,
         // so those plugin will be loaded relatively to the config file
-        Object.keys(extendsOpts).reduce((pluginsPath, option) => {
+        Object.entries(extendsOpts).reduce((pluginsPath, [option, value]) => {
           if (PLUGINS_DEFINITIONS[option]) {
-            castArray(extendsOpts[option])
+            castArray(value)
               .filter(plugin => isString(plugin) || (isPlainObject(plugin) && isString(plugin.path)))
               .map(plugin => (isString(plugin) ? plugin : plugin.path))
               .forEach(plugin => {
diff --git a/lib/get-git-auth-url.js b/lib/get-git-auth-url.js
index a52b44e..0c47344 100644
--- a/lib/get-git-auth-url.js
+++ b/lib/get-git-auth-url.js
@@ -35,10 +35,7 @@ module.exports = async ({repositoryUrl, branch}) => {
 
     // Replace `git+https` and `git+http` with `https` or `http`
     if (protocols.includes('http') || protocols.includes('https')) {
-      repositoryUrl = format({
-        ...parse(repositoryUrl),
-        ...{protocol: protocols.includes('https') ? 'https' : 'http'},
-      });
+      repositoryUrl = format({...parse(repositoryUrl), protocol: protocols.includes('https') ? 'https' : 'http'});
     }
   }
 
diff --git a/lib/plugins/index.js b/lib/plugins/index.js
index 133fa9b..7c482b1 100644
--- a/lib/plugins/index.js
+++ b/lib/plugins/index.js
@@ -1,4 +1,4 @@
-const {isArray, isObject, omit, castArray, isUndefined} = require('lodash');
+const {identity, isPlainObject, omit, castArray, isUndefined} = require('lodash');
 const AggregateError = require('aggregate-error');
 const getError = require('../get-error');
 const PLUGINS_DEFINITIONS = require('../definitions/plugins');
@@ -7,32 +7,37 @@ const normalize = require('./normalize');
 
 module.exports = (options, pluginsPath, logger) => {
   const errors = [];
-  const plugins = Object.keys(PLUGINS_DEFINITIONS).reduce((plugins, pluginType) => {
-    const {config, default: def} = PLUGINS_DEFINITIONS[pluginType];
-    let pluginConfs;
+  const plugins = Object.entries(PLUGINS_DEFINITIONS).reduce(
+    (
+      plugins,
+      [type, {configValidator, default: def, pipelineConfig, postprocess = identity, preprocess = identity}]
+    ) => {
+      let pluginConfs;
 
-    if (isUndefined(options[pluginType])) {
-      pluginConfs = def;
-    } else {
-      // If an object is passed and the path is missing, set the default one for single plugins
-      if (isObject(options[pluginType]) && !options[pluginType].path && !isArray(def)) {
-        options[pluginType].path = def;
+      if (isUndefined(options[type])) {
+        pluginConfs = def;
+      } else {
+        // If an object is passed and the path is missing, set the default one for single plugins
+        if (isPlainObject(options[type]) && !options[type].path && castArray(def).length === 1) {
+          options[type].path = def;
+        }
+        if (configValidator && !configValidator(options[type])) {
+          errors.push(getError('EPLUGINCONF', {type, pluginConf: options[type]}));
+          return plugins;
+        }
+        pluginConfs = options[type];
       }
-      if (config && !config.validator(options[pluginType])) {
-        errors.push(getError('EPLUGINCONF', {pluginType, pluginConf: options[pluginType]}));
-        return plugins;
-      }
-      pluginConfs = options[pluginType];
-    }
 
-    const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITIONS));
+      const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITIONS));
+      const steps = castArray(pluginConfs).map(conf => normalize(type, pluginsPath, globalOpts, conf, logger));
 
-    plugins[pluginType] = pipeline(
-      castArray(pluginConfs).map(conf => normalize(pluginType, pluginsPath, globalOpts, conf, logger))
-    );
+      plugins[type] = async input =>
+        postprocess(await pipeline(steps, pipelineConfig && pipelineConfig(plugins, logger))(await preprocess(input)));
 
-    return plugins;
-  }, {});
+      return plugins;
+    },
+    {}
+  );
   if (errors.length > 0) {
     throw new AggregateError(errors);
   }
diff --git a/lib/plugins/normalize.js b/lib/plugins/normalize.js
index d2fa173..1b7032e 100644
--- a/lib/plugins/normalize.js
+++ b/lib/plugins/normalize.js
@@ -5,7 +5,9 @@ const getError = require('../get-error');
 const {extractErrors} = require('../utils');
 const PLUGINS_DEFINITIONS = require('../definitions/plugins');
 
-module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger) => {
+/* eslint max-params: ["error", 5] */
+
+module.exports = (type, pluginsPath, globalOpts, pluginOpts, logger) => {
   if (!pluginOpts) {
     return noop;
   }
@@ -15,9 +17,9 @@ module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger) => {
 
   if (!isFunction(pluginOpts)) {
     if (pluginsPath[path]) {
-      logger.log('Load plugin "%s" from %s in shareable config %s', pluginType, path, pluginsPath[path]);
+      logger.log('Load plugin "%s" from %s in shareable config %s', type, path, pluginsPath[path]);
     } else {
-      logger.log('Load plugin "%s" from %s', pluginType, path);
+      logger.log('Load plugin "%s" from %s', type, path);
     }
   }
 
@@ -31,18 +33,19 @@ module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger) => {
   let func;
   if (isFunction(plugin)) {
     func = plugin.bind(null, cloneDeep({...globalOpts, ...config}));
-  } else if (isPlainObject(plugin) && plugin[pluginType] && isFunction(plugin[pluginType])) {
-    func = plugin[pluginType].bind(null, cloneDeep({...globalOpts, ...config}));
+  } else if (isPlainObject(plugin) && plugin[type] && isFunction(plugin[type])) {
+    func = plugin[type].bind(null, cloneDeep({...globalOpts, ...config}));
   } else {
-    throw getError('EPLUGIN', {pluginType, pluginName});
+    throw getError('EPLUGIN', {type, pluginName});
   }
 
   const validator = async input => {
-    const definition = PLUGINS_DEFINITIONS[pluginType];
+    const {outputValidator} = PLUGINS_DEFINITIONS[type] || {};
     try {
+      logger.log('Call plugin "%s"', type);
       const result = await func(cloneDeep(input));
-      if (definition && definition.output && !definition.output.validator(result)) {
-        throw getError(PLUGINS_DEFINITIONS[pluginType].output.error, {result, pluginName});
+      if (outputValidator && !outputValidator(result)) {
+        throw getError(`E${type.toUpperCase()}OUTPUT`, {result, pluginName});
       }
       return result;
     } catch (err) {
diff --git a/lib/plugins/pipeline.js b/lib/plugins/pipeline.js
index 69bc9d3..6364c05 100644
--- a/lib/plugins/pipeline.js
+++ b/lib/plugins/pipeline.js
@@ -8,10 +8,6 @@ const {extractErrors} = require('../utils');
  *
  * @typedef {Function} Pipeline
  * @param {Any} input Argument to pass to the first step in the pipeline.
- * @param {Object} options Pipeline options.
- * @param {Boolean} [options.settleAll=false] If `true` all the steps in the pipeline are executed, even if one rejects, if `false` the execution stops after a steps rejects.
- * @param {Function} [options.getNextInput=identity] Function called after each step is executed, with the last and current step results; the returned value will be used as the argument of the next step.
- * @param {Function} [options.transform=identity] Function called after each step is executed, with the current step result and the step function; the returned value will be saved in the pipeline results.
  *
  * @return {Array<*>|*} An Array with the result of each step in the pipeline; if there is only 1 step in the pipeline, the result of this step is returned directly.
  *
@@ -22,18 +18,23 @@ const {extractErrors} = require('../utils');
  * Create a Pipeline with a list of Functions.
  *
  * @param {Array<Function>} steps The list of Function to execute.
+ * @param {Object} options Pipeline options.
+ * @param {Boolean} [options.settleAll=false] If `true` all the steps in the pipeline are executed, even if one rejects, if `false` the execution stops after a steps rejects.
+ * @param {Function} [options.getNextInput=identity] Function called after each step is executed, with the last step input and the current current step result; the returned value will be used as the input of the next step.
+ * @param {Function} [options.transform=identity] Function called after each step is executed, with the current step result, the step function and the last step input; the returned value will be saved in the pipeline results.
+ *
  * @return {Pipeline} A Function that execute the `steps` sequencially
  */
-module.exports = steps => async (input, {settleAll = false, getNextInput = identity, transform = identity} = {}) => {
+module.exports = (steps, {settleAll = false, getNextInput = identity, transform = identity} = {}) => async input => {
   const results = [];
   const errors = [];
   await pReduce(
     steps,
-    async (lastResult, step) => {
+    async (lastInput, step) => {
       let result;
       try {
         // Call the step with the input computed at the end of the previous iteration and save intermediary result
-        result = await transform(await step(lastResult), step);
+        result = await transform(await step(lastInput), step, lastInput);
         results.push(result);
       } catch (err) {
         if (settleAll) {
@@ -43,13 +44,13 @@ module.exports = steps => async (input, {settleAll = false, getNextInput = ident
           throw err;
         }
       }
-      // Prepare input for the next step, passing the result of the last iteration (or initial parameter for the first iteration) and the current one
-      return getNextInput(lastResult, result);
+      // Prepare input for the next step, passing the input of the last iteration (or initial parameter for the first iteration) and the result of the current one
+      return getNextInput(lastInput, result);
     },
     input
   );
   if (errors.length > 0) {
-    throw errors.length === 1 ? errors[0] : new AggregateError(errors);
+    throw new AggregateError(errors);
   }
-  return results.length <= 1 ? results[0] : results;
+  return results;
 };


Test patch:
diff --git a/test/definitions/plugins.test.js b/test/definitions/plugins.test.js
index f016c59..eedda95 100644
--- a/test/definitions/plugins.test.js
+++ b/test/definitions/plugins.test.js
@@ -1,133 +1,129 @@
 import test from 'ava';
 import plugins from '../../lib/definitions/plugins';
-import errors from '../../lib/definitions/errors';
+import {RELEASE_NOTES_SEPARATOR} from '../../lib/definitions/constants';
 
 test('The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition', t => {
-  t.false(plugins.verifyConditions.config.validator({}));
-  t.false(plugins.verifyConditions.config.validator({path: null}));
-
-  t.true(plugins.verifyConditions.config.validator({path: 'plugin-path.js'}));
-  t.true(plugins.verifyConditions.config.validator());
-  t.true(plugins.verifyConditions.config.validator('plugin-path.js'));
-  t.true(plugins.verifyConditions.config.validator(() => {}));
-  t.true(plugins.verifyConditions.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
+  t.false(plugins.verifyConditions.configValidator({}));
+  t.false(plugins.verifyConditions.configValidator({path: null}));
+
+  t.true(plugins.verifyConditions.configValidator({path: 'plugin-path.js'}));
+  t.true(plugins.verifyConditions.configValidator());
+  t.true(plugins.verifyConditions.configValidator('plugin-path.js'));
+  t.true(plugins.verifyConditions.configValidator(() => {}));
+  t.true(plugins.verifyConditions.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
 });
 
 test('The "analyzeCommits" plugin is mandatory, and must be a single plugin definition', t => {
-  t.false(plugins.analyzeCommits.config.validator({}));
-  t.false(plugins.analyzeCommits.config.validator({path: null}));
-  t.false(plugins.analyzeCommits.config.validator([]));
-  t.false(plugins.analyzeCommits.config.validator());
-
-  t.true(plugins.analyzeCommits.config.validator({path: 'plugin-path.js'}));
-  t.true(plugins.analyzeCommits.config.validator('plugin-path.js'));
-  t.true(plugins.analyzeCommits.config.validator(() => {}));
+  t.false(plugins.analyzeCommits.configValidator({}));
+  t.false(plugins.analyzeCommits.configValidator({path: null}));
+  t.false(plugins.analyzeCommits.configValidator([]));
+  t.false(plugins.analyzeCommits.configValidator());
+
+  t.true(plugins.analyzeCommits.configValidator({path: 'plugin-path.js'}));
+  t.true(plugins.analyzeCommits.configValidator('plugin-path.js'));
+  t.true(plugins.analyzeCommits.configValidator(() => {}));
 });
 
 test('The "verifyRelease" 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', () => {}]));
+  t.false(plugins.verifyRelease.configValidator({}));
+  t.false(plugins.verifyRelease.configValidator({path: null}));
+
+  t.true(plugins.verifyRelease.configValidator({path: 'plugin-path.js'}));
+  t.true(plugins.verifyRelease.configValidator());
+  t.true(plugins.verifyRelease.configValidator('plugin-path.js'));
+  t.true(plugins.verifyRelease.configValidator(() => {}));
+  t.true(plugins.verifyRelease.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
 });
 
-test('The "generateNotes" plugin, if defined, must be a single plugin definition', t => {
-  t.false(plugins.generateNotes.config.validator({}));
-  t.false(plugins.generateNotes.config.validator({path: null}));
-  t.false(plugins.generateNotes.config.validator([]));
+test('The "generateNotes" plugin, if defined, must be a single or an array of plugins definition', t => {
+  t.false(plugins.generateNotes.configValidator({}));
+  t.false(plugins.generateNotes.configValidator({path: null}));
 
-  t.true(plugins.generateNotes.config.validator());
-  t.true(plugins.generateNotes.config.validator({path: 'plugin-path.js'}));
-  t.true(plugins.generateNotes.config.validator('plugin-path.js'));
-  t.true(plugins.generateNotes.config.validator(() => {}));
+  t.true(plugins.generateNotes.configValidator({path: 'plugin-path.js'}));
+  t.true(plugins.generateNotes.configValidator());
+  t.true(plugins.generateNotes.configValidator('plugin-path.js'));
+  t.true(plugins.generateNotes.configValidator(() => {}));
+  t.true(plugins.generateNotes.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
 });
 
 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', () => {}]));
+  t.false(plugins.verifyRelease.configValidator({}));
+  t.false(plugins.verifyRelease.configValidator({path: null}));
+
+  t.true(plugins.verifyRelease.configValidator({path: 'plugin-path.js'}));
+  t.true(plugins.verifyRelease.configValidator());
+  t.true(plugins.verifyRelease.configValidator('plugin-path.js'));
+  t.true(plugins.verifyRelease.configValidator(() => {}));
+  t.true(plugins.verifyRelease.configValidator([{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}));
-
-  t.true(plugins.publish.config.validator({path: 'plugin-path.js'}));
-  t.true(plugins.publish.config.validator());
-  t.true(plugins.publish.config.validator('plugin-path.js'));
-  t.true(plugins.publish.config.validator(() => {}));
-  t.true(plugins.publish.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
+  t.false(plugins.publish.configValidator({}));
+  t.false(plugins.publish.configValidator({path: null}));
+
+  t.true(plugins.publish.configValidator({path: 'plugin-path.js'}));
+  t.true(plugins.publish.configValidator());
+  t.true(plugins.publish.configValidator('plugin-path.js'));
+  t.true(plugins.publish.configValidator(() => {}));
+  t.true(plugins.publish.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
 });
 
 test('The "success" plugin, if defined, must be a single or an array of plugins definition', t => {
-  t.false(plugins.success.config.validator({}));
-  t.false(plugins.success.config.validator({path: null}));
-
-  t.true(plugins.success.config.validator({path: 'plugin-path.js'}));
-  t.true(plugins.success.config.validator());
-  t.true(plugins.success.config.validator('plugin-path.js'));
-  t.true(plugins.success.config.validator(() => {}));
-  t.true(plugins.success.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
+  t.false(plugins.success.configValidator({}));
+  t.false(plugins.success.configValidator({path: null}));
+
+  t.true(plugins.success.configValidator({path: 'plugin-path.js'}));
+  t.true(plugins.success.configValidator());
+  t.true(plugins.success.configValidator('plugin-path.js'));
+  t.true(plugins.success.configValidator(() => {}));
+  t.true(plugins.success.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
 });
 
 test('The "fail" plugin, if defined, must be a single or an array of plugins definition', t => {
-  t.false(plugins.fail.config.validator({}));
-  t.false(plugins.fail.config.validator({path: null}));
-
-  t.true(plugins.fail.config.validator({path: 'plugin-path.js'}));
-  t.true(plugins.fail.config.validator());
-  t.true(plugins.fail.config.validator('plugin-path.js'));
-  t.true(plugins.fail.config.validator(() => {}));
-  t.true(plugins.fail.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
+  t.false(plugins.fail.configValidator({}));
+  t.false(plugins.fail.configValidator({path: null}));
+
+  t.true(plugins.fail.configValidator({path: 'plugin-path.js'}));
+  t.true(plugins.fail.configValidator());
+  t.true(plugins.fail.configValidator('plugin-path.js'));
+  t.true(plugins.fail.configValidator(() => {}));
+  t.true(plugins.fail.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
 });
 
 test('The "analyzeCommits" plugin output must be either undefined or a valid semver release type', t => {
-  t.false(plugins.analyzeCommits.output.validator('invalid'));
-  t.false(plugins.analyzeCommits.output.validator(1));
-  t.false(plugins.analyzeCommits.output.validator({}));
+  t.false(plugins.analyzeCommits.outputValidator('invalid'));
+  t.false(plugins.analyzeCommits.outputValidator(1));
+  t.false(plugins.analyzeCommits.outputValidator({}));
 
-  t.true(plugins.analyzeCommits.output.validator());
-  t.true(plugins.analyzeCommits.output.validator(null));
-  t.true(plugins.analyzeCommits.output.validator('major'));
+  t.true(plugins.analyzeCommits.outputValidator());
+  t.true(plugins.analyzeCommits.outputValidator(null));
+  t.true(plugins.analyzeCommits.outputValidator('major'));
 });
 
 test('The "generateNotes" plugin output, if defined, must be a string', t => {
-  t.false(plugins.generateNotes.output.validator(1));
-  t.false(plugins.generateNotes.output.validator({}));
+  t.false(plugins.generateNotes.outputValidator(1));
+  t.false(plugins.generateNotes.outputValidator({}));
 
-  t.true(plugins.generateNotes.output.validator());
-  t.true(plugins.generateNotes.output.validator(null));
-  t.true(plugins.generateNotes.output.validator(''));
-  t.true(plugins.generateNotes.output.validator('string'));
+  t.true(plugins.generateNotes.outputValidator());
+  t.true(plugins.generateNotes.outputValidator(null));
+  t.true(plugins.generateNotes.outputValidator(''));
+  t.true(plugins.generateNotes.outputValidator('string'));
 });
 
 test('The "publish" plugin output, if defined, must be an object', t => {
-  t.false(plugins.publish.output.validator(1));
-  t.false(plugins.publish.output.validator('string'));
+  t.false(plugins.publish.outputValidator(1));
+  t.false(plugins.publish.outputValidator('string'));
 
-  t.true(plugins.publish.output.validator({}));
-  t.true(plugins.publish.output.validator());
-  t.true(plugins.publish.output.validator(null));
-  t.true(plugins.publish.output.validator(''));
+  t.true(plugins.publish.outputValidator({}));
+  t.true(plugins.publish.outputValidator());
+  t.true(plugins.publish.outputValidator(null));
+  t.true(plugins.publish.outputValidator(''));
 });
 
-test('The "analyzeCommits" plugin output definition return an existing error code', t => {
-  t.true(Object.keys(errors).includes(plugins.analyzeCommits.output.error));
-});
-
-test('The "generateNotes" plugin output definition return an existing error code', t => {
-  t.true(Object.keys(errors).includes(plugins.generateNotes.output.error));
-});
-
-test('The "publish" plugin output definition return an existing error code', t => {
-  t.true(Object.keys(errors).includes(plugins.publish.output.error));
+test('The "generateNotes" plugins output are concatenated with separator', t => {
+  t.is(plugins.generateNotes.postprocess(['note 1', 'note 2']), `note 1${RELEASE_NOTES_SEPARATOR}note 2`);
+  t.is(plugins.generateNotes.postprocess(['', 'note']), 'note');
+  t.is(plugins.generateNotes.postprocess([undefined, 'note']), 'note');
+  t.is(plugins.generateNotes.postprocess(['note 1', '', 'note 2']), `note 1${RELEASE_NOTES_SEPARATOR}note 2`);
+  t.is(plugins.generateNotes.postprocess(['note 1', undefined, 'note 2']), `note 1${RELEASE_NOTES_SEPARATOR}note 2`);
 });
diff --git a/test/index.test.js b/test/index.test.js
index 22cc0cd..31e958b 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -4,7 +4,6 @@ import {spy, stub} from 'sinon';
 import clearModule from 'clear-module';
 import AggregateError from 'aggregate-error';
 import SemanticReleaseError from '@semantic-release/error';
-import DEFINITIONS from '../lib/definitions/plugins';
 import {COMMIT_NAME, COMMIT_EMAIL} from '../lib/definitions/constants';
 import {
   gitHead as getGitHead,
@@ -62,12 +61,16 @@ test.serial('Plugins are called with expected values', async t => {
 
   const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'};
   const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
-  const notes = 'Release notes';
+  const notes1 = 'Release notes 1';
+  const notes2 = 'Release notes 2';
+  const notes3 = 'Release notes 3';
   const verifyConditions1 = stub().resolves();
   const verifyConditions2 = stub().resolves();
   const analyzeCommits = stub().resolves(nextRelease.type);
   const verifyRelease = stub().resolves();
-  const generateNotes = stub().resolves(notes);
+  const generateNotes1 = stub().resolves(notes1);
+  const generateNotes2 = stub().resolves(notes2);
+  const generateNotes3 = stub().resolves(notes3);
   const release1 = {name: 'Release 1', url: 'https://release1.com'};
   const prepare = stub().resolves();
   const publish1 = stub().resolves(release1);
@@ -79,7 +82,7 @@ test.serial('Plugins are called with expected values', async t => {
     verifyConditions: [verifyConditions1, verifyConditions2],
     analyzeCommits,
     verifyRelease,
-    generateNotes,
+    generateNotes: [generateNotes1, generateNotes2, generateNotes3],
     prepare,
     publish: [publish1, pluginNoop],
     success,
@@ -114,14 +117,32 @@ test.serial('Plugins are called with expected values', async t => {
   t.deepEqual(verifyRelease.args[0][1].commits[0].message, commits[0].message);
   t.deepEqual(verifyRelease.args[0][1].nextRelease, nextRelease);
 
-  t.is(generateNotes.callCount, 1);
-  t.deepEqual(generateNotes.args[0][0], config);
-  t.deepEqual(generateNotes.args[0][1].options, options);
-  t.deepEqual(generateNotes.args[0][1].logger, t.context.logger);
-  t.deepEqual(generateNotes.args[0][1].lastRelease, lastRelease);
-  t.deepEqual(generateNotes.args[0][1].commits[0].hash, commits[0].hash);
-  t.deepEqual(generateNotes.args[0][1].commits[0].message, commits[0].message);
-  t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease);
+  t.is(generateNotes1.callCount, 1);
+  t.deepEqual(generateNotes1.args[0][0], config);
+  t.deepEqual(generateNotes1.args[0][1].options, options);
+  t.deepEqual(generateNotes1.args[0][1].logger, t.context.logger);
+  t.deepEqual(generateNotes1.args[0][1].lastRelease, lastRelease);
+  t.deepEqual(generateNotes1.args[0][1].commits[0].hash, commits[0].hash);
+  t.deepEqual(generateNotes1.args[0][1].commits[0].message, commits[0].message);
+  t.deepEqual(generateNotes1.args[0][1].nextRelease, nextRelease);
+
+  t.is(generateNotes2.callCount, 1);
+  t.deepEqual(generateNotes2.args[0][0], config);
+  t.deepEqual(generateNotes2.args[0][1].options, options);
+  t.deepEqual(generateNotes2.args[0][1].logger, t.context.logger);
+  t.deepEqual(generateNotes2.args[0][1].lastRelease, lastRelease);
+  t.deepEqual(generateNotes2.args[0][1].commits[0].hash, commits[0].hash);
+  t.deepEqual(generateNotes2.args[0][1].commits[0].message, commits[0].message);
+  t.deepEqual(generateNotes2.args[0][1].nextRelease, {...nextRelease, notes: notes1});
+
+  t.is(generateNotes3.callCount, 1);
+  t.deepEqual(generateNotes3.args[0][0], config);
+  t.deepEqual(generateNotes3.args[0][1].options, options);
+  t.deepEqual(generateNotes3.args[0][1].logger, t.context.logger);
+  t.deepEqual(generateNotes3.args[0][1].lastRelease, lastRelease);
+  t.deepEqual(generateNotes3.args[0][1].commits[0].hash, commits[0].hash);
+  t.deepEqual(generateNotes3.args[0][1].commits[0].message, commits[0].message);
+  t.deepEqual(generateNotes3.args[0][1].nextRelease, {...nextRelease, notes: `${notes1}\n\n${notes2}`});
 
   t.is(prepare.callCount, 1);
   t.deepEqual(prepare.args[0][0], config);
@@ -130,7 +151,7 @@ test.serial('Plugins are called with expected values', async t => {
   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.deepEqual(prepare.args[0][1].nextRelease, {...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`});
 
   t.is(publish1.callCount, 1);
   t.deepEqual(publish1.args[0][0], config);
@@ -139,7 +160,7 @@ test.serial('Plugins are called with expected values', async t => {
   t.deepEqual(publish1.args[0][1].lastRelease, lastRelease);
   t.deepEqual(publish1.args[0][1].commits[0].hash, commits[0].hash);
   t.deepEqual(publish1.args[0][1].commits[0].message, commits[0].message);
-  t.deepEqual(publish1.args[0][1].nextRelease, {...nextRelease, ...{notes}});
+  t.deepEqual(publish1.args[0][1].nextRelease, {...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`});
 
   t.is(success.callCount, 1);
   t.deepEqual(success.args[0][0], config);
@@ -148,10 +169,10 @@ test.serial('Plugins are called with expected values', async t => {
   t.deepEqual(success.args[0][1].lastRelease, lastRelease);
   t.deepEqual(success.args[0][1].commits[0].hash, commits[0].hash);
   t.deepEqual(success.args[0][1].commits[0].message, commits[0].message);
-  t.deepEqual(success.args[0][1].nextRelease, {...nextRelease, ...{notes}});
+  t.deepEqual(success.args[0][1].nextRelease, {...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`});
   t.deepEqual(success.args[0][1].releases, [
-    {...release1, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}},
-    {...nextRelease, ...{notes}, ...{pluginName: pluginNoop}},
+    {...release1, ...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`, pluginName: '[Function: proxy]'},
+    {...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`, pluginName: pluginNoop},
   ]);
 
   // Verify the tag has been created on the local and remote repo and reference the gitHead
@@ -242,16 +263,16 @@ test.serial('Use new gitHead, and recreate release notes if a prepare plugin cre
   t.is(generateNotes.callCount, 2);
   t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease);
   t.is(prepare1.callCount, 1);
-  t.deepEqual(prepare1.args[0][1].nextRelease, {...nextRelease, ...{notes}});
+  t.deepEqual(prepare1.args[0][1].nextRelease, {...nextRelease, notes});
 
   nextRelease.gitHead = await getGitHead();
 
-  t.deepEqual(generateNotes.args[1][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.deepEqual(prepare2.args[0][1].nextRelease, {...nextRelease, notes});
 
   t.is(publish.callCount, 1);
-  t.deepEqual(publish.args[0][1].nextRelease, {...nextRelease, ...{notes}});
+  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);
@@ -300,14 +321,10 @@ test.serial('Call all "success" plugins even if one errors out', async t => {
 
   t.is(success1.callCount, 1);
   t.deepEqual(success1.args[0][0], config);
-  t.deepEqual(success1.args[0][1].releases, [
-    {...release, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}},
-  ]);
+  t.deepEqual(success1.args[0][1].releases, [{...release, ...nextRelease, notes, pluginName: '[Function: proxy]'}]);
 
   t.is(success2.callCount, 1);
-  t.deepEqual(success2.args[0][1].releases, [
-    {...release, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}},
-  ]);
+  t.deepEqual(success2.args[0][1].releases, [{...release, ...nextRelease, notes, pluginName: '[Function: proxy]'}]);
 });
 
 test.serial('Log all "verifyConditions" errors', async t => {
@@ -626,7 +643,9 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins',
   const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
   const analyzeCommits = stub().resolves(nextRelease.type);
   const verifyRelease = stub().resolves();
-  const generateNotes = stub().resolves();
+  const generateNotes1 = stub().resolves();
+  const notes2 = 'Release notes 2';
+  const generateNotes2 = stub().resolves(notes2);
   const publish = stub().resolves();
 
   const options = {
@@ -635,7 +654,7 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins',
     verifyConditions: stub().resolves(),
     analyzeCommits,
     verifyRelease,
-    generateNotes,
+    generateNotes: [generateNotes1, generateNotes2],
     prepare: stub().resolves(),
     publish,
     success: stub().resolves(),
@@ -654,12 +673,15 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins',
   t.is(verifyRelease.callCount, 1);
   t.deepEqual(verifyRelease.args[0][1].lastRelease, lastRelease);
 
-  t.is(generateNotes.callCount, 1);
-  t.deepEqual(generateNotes.args[0][1].lastRelease, lastRelease);
+  t.is(generateNotes1.callCount, 1);
+  t.deepEqual(generateNotes1.args[0][1].lastRelease, lastRelease);
+
+  t.is(generateNotes2.callCount, 1);
+  t.deepEqual(generateNotes2.args[0][1].lastRelease, lastRelease);
 
   t.is(publish.callCount, 1);
   t.deepEqual(publish.args[0][1].lastRelease, lastRelease);
-  t.falsy(publish.args[0][1].nextRelease.notes);
+  t.is(publish.args[0][1].nextRelease.notes, notes2);
 });
 
 test.serial('Returns falsy value if triggered by a PR', async t => {
@@ -931,8 +953,6 @@ test.serial('Throw an Error if plugin returns an unexpected value', async t => {
   });
   const error = await t.throws(semanticRelease(options), Error);
 
-  // Verify error message
-  t.regex(error.message, new RegExp(DEFINITIONS.analyzeCommits.output.message));
   t.regex(error.details, /string/);
 });
 
diff --git a/test/integration.test.js b/test/integration.test.js
index 3afe241..3b5f90c 100644
--- a/test/integration.test.js
+++ b/test/integration.test.js
@@ -614,7 +614,7 @@ test.serial('Exit with 1 if missing permission to push to the remote repository'
   const {stdout, code} = await execa(
     cli,
     ['--repository-url', 'http://user:wrong_pass@localhost:2080/git/unauthorized.git'],
-    {env: {...env, ...{GH_TOKEN: 'user:wrong_pass'}}, reject: false}
+    {env: {...env, GH_TOKEN: 'user:wrong_pass'}, reject: false}
   );
   // Verify the type and message are logged
   t.regex(stdout, /EGITNOPERMISSION/);
diff --git a/test/plugins/normalize.test.js b/test/plugins/normalize.test.js
index 4b26c00..d8ab789 100644
--- a/test/plugins/normalize.test.js
+++ b/test/plugins/normalize.test.js
@@ -94,8 +94,10 @@ test('Wrap "analyzeCommits" plugin in a function that validate the output of the
 
   const error = await t.throws(plugin());
 
-  t.is(error.code, 'EANALYZEOUTPUT');
+  t.is(error.code, 'EANALYZECOMMITSOUTPUT');
   t.is(error.name, 'SemanticReleaseError');
+  t.truthy(error.message);
+  t.truthy(error.details);
   t.regex(error.details, /2/);
 });
 
@@ -105,8 +107,10 @@ test('Wrap "generateNotes" plugin in a function that validate the output of the
 
   const error = await t.throws(plugin());
 
-  t.is(error.code, 'ERELEASENOTESOUTPUT');
+  t.is(error.code, 'EGENERATENOTESOUTPUT');
   t.is(error.name, 'SemanticReleaseError');
+  t.truthy(error.message);
+  t.truthy(error.details);
   t.regex(error.details, /2/);
 });
 
@@ -123,6 +127,8 @@ test('Wrap "publish" plugin in a function that validate the output of the plugin
 
   t.is(error.code, 'EPUBLISHOUTPUT');
   t.is(error.name, 'SemanticReleaseError');
+  t.truthy(error.message);
+  t.truthy(error.details);
   t.regex(error.details, /2/);
 });
 
@@ -187,6 +193,8 @@ test('Throws an error if the plugin return an object without the expected plugin
 
   t.is(error.code, 'EPLUGIN');
   t.is(error.name, 'SemanticReleaseError');
+  t.truthy(error.message);
+  t.truthy(error.details);
 });
 
 test('Throws an error if the plugin is not found', t => {
diff --git a/test/plugins/pipeline.test.js b/test/plugins/pipeline.test.js
index 97cfc4b..2a67394 100644
--- a/test/plugins/pipeline.test.js
+++ b/test/plugins/pipeline.test.js
@@ -18,24 +18,6 @@ test('Execute each function in series passing the same input', async t => {
   t.true(step2.calledBefore(step3));
 });
 
-test('With one step, returns the step values rather than an Array ', async t => {
-  const step1 = stub().resolves(1);
-
-  const result = await pipeline([step1])(0);
-
-  t.deepEqual(result, 1);
-  t.true(step1.calledWith(0));
-});
-
-test('With one step, throws the error rather than an AggregateError ', async t => {
-  const error = new Error('test error 1');
-  const step1 = stub().rejects(error);
-
-  const thrown = await t.throws(pipeline([step1])(0));
-
-  t.is(error, thrown);
-});
-
 test('Execute each function in series passing a transformed input from "getNextInput"', async t => {
   const step1 = stub().resolves(1);
   const step2 = stub().resolves(2);
@@ -43,7 +25,7 @@ test('Execute each function in series passing a transformed input from "getNextI
   const step4 = stub().resolves(4);
   const getNextInput = (lastResult, result) => lastResult + result;
 
-  const result = await pipeline([step1, step2, step3, step4])(0, {settleAll: false, getNextInput});
+  const result = await pipeline([step1, step2, step3, step4], {settleAll: false, getNextInput})(0);
 
   t.deepEqual(result, [1, 2, 3, 4]);
   t.true(step1.calledWith(0));
@@ -62,7 +44,7 @@ test('Execute each function in series passing the "lastResult" and "result" to "
   const step4 = stub().resolves(4);
   const getNextInput = stub().returnsArg(0);
 
-  const result = await pipeline([step1, step2, step3, step4])(5, {settleAll: false, getNextInput});
+  const result = await pipeline([step1, step2, step3, step4], {settleAll: false, getNextInput})(5);
 
   t.deepEqual(result, [1, 2, 3, 4]);
   t.deepEqual(getNextInput.args, [[5, 1], [5, 2], [5, 3], [5, 4]]);
@@ -76,7 +58,7 @@ test('Execute each function in series calling "transform" to modify the results'
   const getNextInput = stub().returnsArg(0);
   const transform = stub().callsFake(result => result + 1);
 
-  const result = await pipeline([step1, step2, step3, step4])(5, {getNextInput, transform});
+  const result = await pipeline([step1, step2, step3, step4], {getNextInput, transform})(5);
 
   t.deepEqual(result, [1 + 1, 2 + 1, 3 + 1, 4 + 1]);
   t.deepEqual(getNextInput.args, [[5, 1 + 1], [5, 2 + 1], [5, 3 + 1], [5, 4 + 1]]);
@@ -90,13 +72,13 @@ test('Execute each function in series calling "transform" to modify the results
   const getNextInput = stub().returnsArg(0);
   const transform = stub().callsFake(result => result + 1);
 
-  const result = await pipeline([step1, step2, step3, step4])(5, {settleAll: true, getNextInput, transform});
+  const result = await pipeline([step1, step2, step3, step4], {settleAll: true, getNextInput, transform})(5);
 
   t.deepEqual(result, [1 + 1, 2 + 1, 3 + 1, 4 + 1]);
   t.deepEqual(getNextInput.args, [[5, 1 + 1], [5, 2 + 1], [5, 3 + 1], [5, 4 + 1]]);
 });
 
-test('Stop execution and throw error is a step rejects', async t => {
+test('Stop execution and throw error if a step rejects', async t => {
   const step1 = stub().resolves(1);
   const step2 = stub().rejects(new Error('test error'));
   const step3 = stub().resolves(3);
@@ -131,7 +113,7 @@ test('Execute all even if a Promise rejects', async t => {
   const step2 = stub().rejects(error1);
   const step3 = stub().rejects(error2);
 
-  const errors = await t.throws(pipeline([step1, step2, step3])(0, {settleAll: true}));
+  const errors = await t.throws(pipeline([step1, step2, step3], {settleAll: true})(0));
 
   t.deepEqual([...errors], [error1, error2]);
   t.true(step1.calledWith(0));
@@ -147,7 +129,7 @@ test('Throw all errors from all steps throwing an AggregateError', async t => {
   const step1 = stub().rejects(new AggregateError([error1, error2]));
   const step2 = stub().rejects(new AggregateError([error3, error4]));
 
-  const errors = await t.throws(pipeline([step1, step2])(0, {settleAll: true}));
+  const errors = await t.throws(pipeline([step1, step2], {settleAll: true})(0));
 
   t.deepEqual([...errors], [error1, error2, error3, error4]);
   t.true(step1.calledWith(0));
@@ -163,7 +145,7 @@ test('Execute each function in series passing a transformed input even if a step
   const step4 = stub().resolves(4);
   const getNextInput = (prevResult, result) => prevResult + result;
 
-  const errors = await t.throws(pipeline([step1, step2, step3, step4])(0, {settleAll: true, getNextInput}));
+  const errors = await t.throws(pipeline([step1, step2, step3, step4], {settleAll: true, getNextInput})(0));
 
   t.deepEqual([...errors], [error2, error3]);
   t.true(step1.calledWith(0));
diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js
index c81214e..945da15 100644
--- a/test/plugins/plugins.test.js
+++ b/test/plugins/plugins.test.js
@@ -116,11 +116,20 @@ test.serial('Export plugins loaded from the dependency of a shareable config fil
 });
 
 test('Use default when only options are passed for a single plugin', t => {
-  const plugins = getPlugins({generateNotes: {}, analyzeCommits: {}}, {}, t.context.logger);
+  const analyzeCommits = {};
+  const success = () => {};
+  const fail = [() => {}];
+
+  const plugins = getPlugins({analyzeCommits, success, fail}, {}, t.context.logger);
 
   // Verify the module returns a function for each plugin
-  t.is(typeof plugins.generateNotes, 'function');
   t.is(typeof plugins.analyzeCommits, 'function');
+  t.is(typeof plugins.success, 'function');
+  t.is(typeof plugins.fail, 'function');
+
+  // Verify only the plugins defined as an object with no `path` are set to the default value
+  t.falsy(success.path);
+  t.falsy(fail.path);
 });
 
 test('Merge global options with plugin options', async t => {
@@ -134,7 +143,7 @@ test('Merge global options with plugin options', async t => {
     t.context.logger
   );
 
-  const result = await plugins.verifyRelease();
+  const [result] = await plugins.verifyRelease();
 
   t.deepEqual(result.pluginConfig, {localOpt: 'local', globalOpt: 'global', otherOpt: 'locally-defined'});
 });