Repository: semantic-release/semantic-release
Pull request #983 (2018-11-12 19:59:17)

feat: support multiple plugins for the `analyzeCommits` step
Fix #982

- Simplify plugins configuration (all plugins can be multiple now, so no more exception)
- Simplify code
- Make plugins config/docs more consistent


Patch:
diff --git a/docs/usage/plugins.md b/docs/usage/plugins.md
index beaf195..a1f792a 100644
--- a/docs/usage/plugins.md
+++ b/docs/usage/plugins.md
@@ -4,16 +4,18 @@ Each [release step](../../README.md#release-steps) is implemented by configurabl
 
 A plugin is a npm module that can implement one or more of the following steps:
 
-| Step               | Accept multiple | Required | Description                                                                                                                                                                                   |
-|--------------------|-----------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `verifyConditions` | Yes             | No       | Responsible for verifying conditions necessary to proceed with the release: configuration is correct, authentication token are valid, etc...                                                  |
-| `analyzeCommits`   | No              | Yes      | Responsible for determining the type of the next release (`major`, `minor` or `patch`).                                                                                                       |
-| `verifyRelease`    | Yes             | No       | Responsible for verifying the parameters (version, type, dist-tag etc...) of the release that is about to be published.                                                                       |
-| `generateNotes`    | Yes             | No       | Responsible for generating the content of the release note. If multiple `generateNotes` plugins are defined, the release notes will be the result of the concatenation of each plugin output. |
-| `prepare`          | Yes             | No       | Responsible for preparing the release, for example creating or updating files such as `package.json`, `CHANGELOG.md`, documentation or compiled assets and pushing a commit.                  |
-| `publish`          | Yes             | No       | Responsible for publishing the release.                                                                                                                                                       |
-| `success`          | Yes             | No       | Responsible for notifying of a new release.                                                                                                                                                   |
-| `fail`             | Yes             | No       | Responsible for notifying of a failed release.                                                                                                                                                |
+| Step               | Required | Description                                                                                                                                                                                                          |
+|--------------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `verifyConditions` | No       | Responsible for verifying conditions necessary to proceed with the release: configuration is correct, authentication token are valid, etc...                                                                         |
+| `analyzeCommits`   | Yes      | Responsible for determining the type of the next release (`major`, `minor` or `patch`). If multiple plugins with a `analyzeCommits` step are defined, the release type will be the highest one among plugins output. |
+| `verifyRelease`    | No       | Responsible for verifying the parameters (version, type, dist-tag etc...) of the release that is about to be published.                                                                                              |
+| `generateNotes`    | No       | Responsible for generating the content of the release note. If multiple plugins with a `generateNotes` step are defined, the release notes will be the result of the concatenation of each plugin output.            |
+| `prepare`          | No       | Responsible for preparing the release, for example creating or updating files such as `package.json`, `CHANGELOG.md`, documentation or compiled assets and pushing a commit.                                         |
+| `publish`          | No       | Responsible for publishing the release.                                                                                                                                                                              |
+| `success`          | No       | Responsible for notifying of a new release.                                                                                                                                                                          |
+| `fail`             | No       | Responsible for notifying of a failed release.                                                                                                                                                                       |
+
+**Note:** If no plugin with a `analyzeCommits` step is defined `@semantic-release/commit-analyzer` will be used.
 
 See [available plugins](../extending/plugins-list.md).
 
diff --git a/lib/definitions/constants.js b/lib/definitions/constants.js
index 947ec28..300733c 100644
--- a/lib/definitions/constants.js
+++ b/lib/definitions/constants.js
@@ -1,4 +1,4 @@
-const RELEASE_TYPE = ['major', 'premajor', 'minor', 'preminor', 'patch', 'prepatch', 'prerelease'];
+const RELEASE_TYPE = ['prerelease', 'prepatch', 'patch', 'preminor', 'minor', 'premajor', 'major'];
 
 const FIRST_RELEASE = '1.0.0';
 
diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js
index ccc60ef..d5eddb2 100644
--- a/lib/definitions/errors.js
+++ b/lib/definitions/errors.js
@@ -55,13 +55,11 @@ Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`
 
 Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`,
   }),
-  EPLUGINCONF: ({type, multiple, required, pluginConf}) => ({
+  EPLUGINCONF: ({type, required, pluginConf}) => ({
     message: `The \`${type}\` plugin configuration is invalid.`,
     details: `The [${type} plugin configuration](${linkify(`docs/usage/plugins.md#${toLower(type)}-plugin`)}) ${
       required ? 'is required and ' : ''
-    }must be ${
-      multiple ? 'a single or an array of plugins' : 'a single plugin'
-    } definition. A plugin definition is an npm module name, optionnaly wrapped in an array with an object.
+    } must be a single or an array of plugins definition. A plugin definition is an npm module name, optionnaly wrapped in an array with an object.
 
 Your configuration for the \`${type}\` plugin is \`${stringify(pluginConf)}\`.`,
   }),
diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js
index 97138e8..7fb6fe6 100644
--- a/lib/definitions/plugins.js
+++ b/lib/definitions/plugins.js
@@ -6,28 +6,30 @@ const {RELEASE_TYPE, RELEASE_NOTES_SEPARATOR} = require('./constants');
 
 module.exports = {
   verifyConditions: {
-    multiple: true,
     required: false,
     pipelineConfig: () => ({settleAll: true}),
   },
   analyzeCommits: {
     default: ['@semantic-release/commit-analyzer'],
-    multiple: false,
     required: true,
     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,
+    postprocess: results =>
+      RELEASE_TYPE[
+        results.reduce((highest, result) => {
+          const typeIndex = RELEASE_TYPE.indexOf(result);
+          return typeIndex > highest ? typeIndex : highest;
+        }, -1)
+      ],
   },
   verifyRelease: {
-    multiple: true,
     required: false,
     pipelineConfig: () => ({settleAll: true}),
   },
   generateNotes: {
-    multiple: true,
     required: false,
     outputValidator: output => !output || isString(output),
     pipelineConfig: () => ({
@@ -42,16 +44,14 @@ module.exports = {
     postprocess: (results, {env}) => hideSensitive(env)(results.filter(Boolean).join(RELEASE_NOTES_SEPARATOR)),
   },
   prepare: {
-    multiple: true,
     required: false,
-    pipelineConfig: ({generateNotes}, logger) => ({
+    pipelineConfig: ({generateNotes}) => ({
       getNextInput: async context => {
         const newGitHead = await gitHead({cwd: context.cwd});
         // If previous prepare plugin has created a commit (gitHead changed)
         if (context.nextRelease.gitHead !== newGitHead) {
           context.nextRelease.gitHead = newGitHead;
           // Regenerate the release notes
-          logger.log('Call plugin %s', 'generateNotes');
           context.nextRelease.notes = await generateNotes(context);
         }
         // Call the next prepare plugin with the updated `nextRelease`
@@ -60,7 +60,6 @@ module.exports = {
     }),
   },
   publish: {
-    multiple: true,
     required: false,
     outputValidator: output => !output || isPlainObject(output),
     pipelineConfig: () => ({
@@ -73,13 +72,11 @@ module.exports = {
     }),
   },
   success: {
-    multiple: true,
     required: false,
     pipelineConfig: () => ({settleAll: true}),
     preprocess: ({releases, env, ...inputs}) => ({...inputs, env, releases: hideSensitiveValues(env, releases)}),
   },
   fail: {
-    multiple: true,
     required: false,
     pipelineConfig: () => ({settleAll: true}),
     preprocess: ({errors, env, ...inputs}) => ({...inputs, env, errors: hideSensitiveValues(env, errors)}),
diff --git a/lib/plugins/index.js b/lib/plugins/index.js
index e3ee202..abbc754 100644
--- a/lib/plugins/index.js
+++ b/lib/plugins/index.js
@@ -24,7 +24,7 @@ module.exports = (context, pluginsPath) => {
                   writable: false,
                   enumerable: true,
                 });
-                plugins[type] = [...(PLUGINS_DEFINITIONS[type].multiple ? plugins[type] || [] : []), [func, config]];
+                plugins[type] = [...(plugins[type] || []), [func, config]];
               }
             });
           } else {
@@ -45,10 +45,7 @@ module.exports = (context, pluginsPath) => {
   options = {...plugins, ...options};
 
   const pluginsConf = Object.entries(PLUGINS_DEFINITIONS).reduce(
-    (
-      pluginsConf,
-      [type, {multiple, required, default: def, pipelineConfig, postprocess = identity, preprocess = identity}]
-    ) => {
+    (pluginsConf, [type, {required, default: def, pipelineConfig, postprocess = identity, preprocess = identity}]) => {
       let pluginOpts;
 
       if (isNil(options[type]) && def) {
@@ -60,8 +57,8 @@ module.exports = (context, pluginsPath) => {
             plugin ? [plugin[0], Object.assign(plugin[1], options[type])] : plugin
           );
         }
-        if (!validateStep({multiple, required}, options[type])) {
-          errors.push(getError('EPLUGINCONF', {type, multiple, required, pluginConf: options[type]}));
+        if (!validateStep({required}, options[type])) {
+          errors.push(getError('EPLUGINCONF', {type, required, pluginConf: options[type]}));
           return pluginsConf;
         }
         pluginOpts = options[type];
diff --git a/lib/plugins/utils.js b/lib/plugins/utils.js
index 8a9421d..2a21340 100644
--- a/lib/plugins/utils.js
+++ b/lib/plugins/utils.js
@@ -2,28 +2,25 @@ const {dirname} = require('path');
 const {isString, isFunction, castArray, isArray, isPlainObject, isNil} = require('lodash');
 const resolveFrom = require('resolve-from');
 
-const validateStepArrayDefinition = conf =>
-  isArray(conf) &&
-  (conf.length === 1 || conf.length === 2) &&
-  (isString(conf[0]) || isFunction(conf[0])) &&
-  (isNil(conf[1]) || isPlainObject(conf[1]));
-
-const validateSingleStep = conf => {
-  if (validateStepArrayDefinition(conf)) {
-    return true;
-  }
-  conf = castArray(conf);
-
-  if (conf.length !== 1) {
-    return false;
-  }
+const validateSteps = conf => {
+  return conf.every(conf => {
+    if (
+      isArray(conf) &&
+      (conf.length === 1 || conf.length === 2) &&
+      (isString(conf[0]) || isFunction(conf[0])) &&
+      (isNil(conf[1]) || isPlainObject(conf[1]))
+    ) {
+      return true;
+    }
+    conf = castArray(conf);
 
-  const [name, config] = parseConfig(conf[0]);
-  return (isString(name) || isFunction(name)) && isPlainObject(config);
-};
+    if (conf.length !== 1) {
+      return false;
+    }
 
-const validateMultipleStep = conf => {
-  return conf.every(conf => validateSingleStep(conf));
+    const [name, config] = parseConfig(conf[0]);
+    return (isString(name) || isFunction(name)) && isPlainObject(config);
+  });
 };
 
 function validatePlugin(conf) {
@@ -37,12 +34,12 @@ function validatePlugin(conf) {
   );
 }
 
-function validateStep({multiple, required}, conf) {
+function validateStep({required}, conf) {
   conf = castArray(conf).filter(Boolean);
   if (required) {
-    return conf.length >= 1 && (multiple ? validateMultipleStep : validateSingleStep)(conf);
+    return conf.length >= 1 && validateSteps(conf);
   }
-  return conf.length === 0 || (multiple ? validateMultipleStep : validateSingleStep)(conf);
+  return conf.length === 0 || validateSteps(conf);
 }
 
 function loadPlugin({cwd}, name, pluginsPath) {


Test patch:
diff --git a/test/definitions/plugins.test.js b/test/definitions/plugins.test.js
index 61a86c7..9f869d9 100644
--- a/test/definitions/plugins.test.js
+++ b/test/definitions/plugins.test.js
@@ -51,3 +51,13 @@ test('The "generateNotes" plugins output are concatenated with separator and sen
     `Note 1: Exposing token ${SECRET_REPLACEMENT}${RELEASE_NOTES_SEPARATOR}Note 2: Exposing token ${SECRET_REPLACEMENT}`
   );
 });
+
+test('The "analyzeCommits" plugins output are reduced to the highest release type', t => {
+  t.is(plugins.analyzeCommits.postprocess(['major', 'minor']), 'major');
+  t.is(plugins.analyzeCommits.postprocess(['', 'minor']), 'minor');
+  t.is(plugins.analyzeCommits.postprocess([undefined, 'patch']), 'patch');
+  t.is(plugins.analyzeCommits.postprocess([null, 'patch']), 'patch');
+  t.is(plugins.analyzeCommits.postprocess(['wrong_type', 'minor']), 'minor');
+  t.is(plugins.analyzeCommits.postprocess([]), undefined);
+  t.is(plugins.analyzeCommits.postprocess(['wrong_type']), undefined);
+});
diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js
index d33f4e8..a002062 100644
--- a/test/plugins/plugins.test.js
+++ b/test/plugins/plugins.test.js
@@ -105,26 +105,6 @@ test('Export plugins based on "plugins" config (single definition)', async t =>
   t.is(typeof plugins.fail, 'function');
 });
 
-test('Use only last definition of single plugin steps declared in "plugins" config', async t => {
-  const plugin1 = {analyzeCommits: stub()};
-  const plugin2 = {analyzeCommits: stub()};
-  const plugins = getPlugins({cwd, logger: t.context.logger, options: {plugins: [plugin1, plugin2]}}, {});
-
-  await plugins.analyzeCommits({commits: []});
-  t.true(plugin1.analyzeCommits.notCalled);
-  t.true(plugin2.analyzeCommits.calledOnce);
-
-  // Verify the module returns a function for each plugin
-  t.is(typeof plugins.verifyConditions, 'function');
-  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');
-});
-
 test('Merge global options, "plugins" options and step options', async t => {
   const plugin1 = [{verifyConditions: stub(), publish: stub()}, {pluginOpt1: 'plugin1'}];
   const plugin2 = [{verifyConditions: stub()}, {pluginOpt2: 'plugin2'}];
diff --git a/test/plugins/utils.test.js b/test/plugins/utils.test.js
index b7c31e3..e781316 100644
--- a/test/plugins/utils.test.js
+++ b/test/plugins/utils.test.js
@@ -25,7 +25,7 @@ test('validatePlugin', t => {
   t.false(validatePlugin({path: 1}), 'Object definition, wrong path');
 });
 
-test('validateStep: multiple/optional plugin configuration', t => {
+test('validateStep: optional plugin configuration', t => {
   const type = {multiple: true, required: false};
 
   // Empty config
@@ -107,8 +107,8 @@ test('validateStep: multiple/optional plugin configuration', t => {
   );
 });
 
-test('validateStep: multiple/required plugin configuration', t => {
-  const type = {multiple: true, required: true};
+test('validateStep: required plugin configuration', t => {
+  const type = {required: true};
 
   // Empty config
   t.false(validateStep(type));
@@ -189,98 +189,6 @@ test('validateStep: multiple/required plugin configuration', t => {
   );
 });
 
-test('validateStep: single/required plugin configuration', t => {
-  const type = {multiple: false, required: true};
-
-  // Empty config
-  t.false(validateStep(type));
-  t.false(validateStep(type, []));
-
-  // Single value definition
-  t.true(validateStep(type, 'plugin-path.js'));
-  t.true(validateStep(type, () => {}));
-  t.true(validateStep(type, ['plugin-path.js']));
-  t.true(validateStep(type, [() => {}]));
-  t.false(validateStep(type, {}));
-  t.false(validateStep(type, [{}]));
-
-  // Array type definition
-  t.true(validateStep(type, [['plugin-path.js']]));
-  t.true(validateStep(type, [['plugin-path.js', {options: 'value'}]]));
-  t.true(validateStep(type, [[() => {}, {options: 'value'}]]));
-  t.false(validateStep(type, [['plugin-path.js', 1]]));
-
-  // Object type definition
-  t.true(validateStep(type, {path: 'plugin-path.js'}));
-  t.true(validateStep(type, {path: 'plugin-path.js', options: 'value'}));
-  t.true(validateStep(type, {path: () => {}, options: 'value'}));
-  t.false(validateStep(type, {path: null}));
-
-  // Considered as one Array definition and not as an Array of 2 definitions in case of single plugin type
-  t.true(validateStep(type, [() => {}, {options: 'value'}]));
-  t.true(validateStep(type, ['plugin-path.js', {options: 'value'}]));
-
-  // Multiple definitions
-  t.false(
-    validateStep(type, [
-      'plugin-path.js',
-      () => {},
-      ['plugin-path.js'],
-      ['plugin-path.js', {options: 'value'}],
-      [() => {}, {options: 'value'}],
-      {path: 'plugin-path.js'},
-      {path: 'plugin-path.js', options: 'value'},
-      {path: () => {}, options: 'value'},
-    ])
-  );
-});
-
-test('validateStep: single/optional plugin configuration', t => {
-  const type = {multiple: false, required: false};
-
-  // Empty config
-  t.true(validateStep(type));
-  t.true(validateStep(type, []));
-
-  // Single value definition
-  t.true(validateStep(type, 'plugin-path.js'));
-  t.true(validateStep(type, () => {}));
-  t.true(validateStep(type, ['plugin-path.js']));
-  t.true(validateStep(type, [() => {}]));
-  t.false(validateStep(type, {}));
-  t.false(validateStep(type, [{}]));
-
-  // Array type definition
-  t.true(validateStep(type, [['plugin-path.js']]));
-  t.true(validateStep(type, [['plugin-path.js', {options: 'value'}]]));
-  t.true(validateStep(type, [[() => {}, {options: 'value'}]]));
-  t.false(validateStep(type, [['plugin-path.js', 1]]));
-
-  // Object type definition
-  t.true(validateStep(type, {path: 'plugin-path.js'}));
-  t.true(validateStep(type, {path: 'plugin-path.js', options: 'value'}));
-  t.true(validateStep(type, {path: () => {}, options: 'value'}));
-  t.false(validateStep(type, {path: null}));
-
-  // Considered as one Array definition and not as an Array of 2 definitions in case of single plugin type
-  t.true(validateStep(type, [() => {}, {options: 'value'}]));
-  t.true(validateStep(type, ['plugin-path.js', {options: 'value'}]));
-
-  // Multiple definitions
-  t.false(
-    validateStep(type, [
-      'plugin-path.js',
-      () => {},
-      ['plugin-path.js'],
-      ['plugin-path.js', {options: 'value'}],
-      [() => {}, {options: 'value'}],
-      {path: 'plugin-path.js'},
-      {path: 'plugin-path.js', options: 'value'},
-      {path: () => {}, options: 'value'},
-    ])
-  );
-});
-
 test('loadPlugin', t => {
   const cwd = process.cwd();
   const func = () => {};