Repository: semantic-release/semantic-release
Pull request #984 (2018-11-13 23:40:59)

feat: add logs about `prepare`, `publish`, `success` and `fail` plugins skipped in dry-run mode
This should avoid confusion when trying out semantic-release in dry-run mode regarding why `prepare`, `publish`, `success` and `fail` plugins are not executed.


Patch:
diff --git a/index.js b/index.js
index 8ab1ba3..1baeb7a 100644
--- a/index.js
+++ b/index.js
@@ -24,7 +24,7 @@ async function run(context, plugins) {
   const {isCi, branch: ciBranch, isPr} = envCi({env, cwd});
 
   if (!isCi && !options.dryRun && !options.noCi) {
-    logger.log('This run was not triggered in a known CI environment, running in dry-run mode.');
+    logger.warn('This run was not triggered in a known CI environment, running in dry-run mode.');
     options.dryRun = true;
   } else {
     // When running on CI, set the commits author and commiter info and prevent the `git` CLI to prompt for username/password. See #703.
@@ -52,7 +52,9 @@ async function run(context, plugins) {
     );
     return false;
   }
-  logger.success(`Run automated release from branch ${ciBranch}`);
+  logger[options.dryRun ? 'warn' : 'success'](
+    `Run automated release from branch ${ciBranch}${options.dryRun ? ' in dry-run mode' : ''}`
+  );
 
   await verify(context);
 
@@ -98,24 +100,28 @@ async function run(context, plugins) {
 
   nextRelease.notes = await plugins.generateNotes(context);
 
+  await plugins.prepare(context);
+
   if (options.dryRun) {
-    logger.log(`Release note for version ${nextRelease.version}:`);
-    if (nextRelease.notes) {
-      context.stdout.write(marked(nextRelease.notes));
-    }
+    logger.warn(`Skip ${nextRelease.gitTag} tag creation in dry-run mode`);
   } else {
-    await plugins.prepare(context);
-
     // Create the tag before calling the publish plugins as some require the tag to exists
     await tag(nextRelease.gitTag, {cwd, env});
     await push(options.repositoryUrl, options.branch, {cwd, env});
     logger.success(`Created tag ${nextRelease.gitTag}`);
+  }
 
-    context.releases = await plugins.publish(context);
+  context.releases = await plugins.publish(context);
 
-    await plugins.success(context);
+  await plugins.success(context);
 
-    logger.success(`Published release ${nextRelease.version}`);
+  logger.success(`Published release ${nextRelease.version}`);
+
+  if (options.dryRun) {
+    logger.log(`Release note for version ${nextRelease.version}:`);
+    if (nextRelease.notes) {
+      context.stdout.write(marked(nextRelease.notes));
+    }
   }
 
   return pick(context, ['lastRelease', 'commits', 'nextRelease', 'releases']);
@@ -162,9 +168,7 @@ module.exports = async (opts = {}, {cwd = process.cwd(), env = process.env, stdo
       unhook();
       return result;
     } catch (error) {
-      if (!options.dryRun) {
-        await callFail(context, plugins, error);
-      }
+      await callFail(context, plugins, error);
       throw error;
     }
   } catch (error) {
diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js
index 7fb6fe6..c171b97 100644
--- a/lib/definitions/plugins.js
+++ b/lib/definitions/plugins.js
@@ -7,11 +7,13 @@ const {RELEASE_TYPE, RELEASE_NOTES_SEPARATOR} = require('./constants');
 module.exports = {
   verifyConditions: {
     required: false,
+    dryRun: true,
     pipelineConfig: () => ({settleAll: true}),
   },
   analyzeCommits: {
     default: ['@semantic-release/commit-analyzer'],
     required: true,
+    dryRun: true,
     outputValidator: output => !output || RELEASE_TYPE.includes(output),
     preprocess: ({commits, ...inputs}) => ({
       ...inputs,
@@ -27,10 +29,12 @@ module.exports = {
   },
   verifyRelease: {
     required: false,
+    dryRun: true,
     pipelineConfig: () => ({settleAll: true}),
   },
   generateNotes: {
     required: false,
+    dryRun: true,
     outputValidator: output => !output || isString(output),
     pipelineConfig: () => ({
       getNextInput: ({nextRelease, ...context}, notes) => ({
@@ -45,6 +49,7 @@ module.exports = {
   },
   prepare: {
     required: false,
+    dryRun: false,
     pipelineConfig: ({generateNotes}) => ({
       getNextInput: async context => {
         const newGitHead = await gitHead({cwd: context.cwd});
@@ -61,6 +66,7 @@ module.exports = {
   },
   publish: {
     required: false,
+    dryRun: false,
     outputValidator: output => !output || isPlainObject(output),
     pipelineConfig: () => ({
       // Add `nextRelease` and plugin properties to published release
@@ -73,11 +79,13 @@ module.exports = {
   },
   success: {
     required: false,
+    dryRun: false,
     pipelineConfig: () => ({settleAll: true}),
     preprocess: ({releases, env, ...inputs}) => ({...inputs, env, releases: hideSensitiveValues(env, releases)}),
   },
   fail: {
     required: false,
+    dryRun: false,
     pipelineConfig: () => ({settleAll: true}),
     preprocess: ({errors, env, ...inputs}) => ({...inputs, env, errors: hideSensitiveValues(env, errors)}),
   },
diff --git a/lib/get-logger.js b/lib/get-logger.js
index 52b8f0d..d37da6b 100644
--- a/lib/get-logger.js
+++ b/lib/get-logger.js
@@ -3,7 +3,7 @@ const figures = require('figures');
 
 module.exports = ({stdout, stderr}) =>
   new Signale({
-    config: {displayTimestamp: true, underlineMessage: true, displayLabel: false},
+    config: {displayTimestamp: true, underlineMessage: false, displayLabel: false},
     disabled: false,
     interactive: false,
     scope: 'semantic-release',
diff --git a/lib/plugins/normalize.js b/lib/plugins/normalize.js
index a10f8c4..56af5b6 100644
--- a/lib/plugins/normalize.js
+++ b/lib/plugins/normalize.js
@@ -27,20 +27,23 @@ module.exports = (context, type, pluginOpt, pluginsPath) => {
   }
 
   const validator = async input => {
-    const {outputValidator} = PLUGINS_DEFINITIONS[type] || {};
+    const {dryRun, outputValidator} = PLUGINS_DEFINITIONS[type] || {};
     try {
-      logger.log(`Start step "${type}" of plugin "${pluginName}"`);
-      const result = await func({
-        ...cloneDeep(omit(input, ['stdout', 'stderr', 'logger'])),
-        stdout,
-        stderr,
-        logger: logger.scope(logger.scopeName, pluginName),
-      });
-      if (outputValidator && !outputValidator(result)) {
-        throw getError(`E${type.toUpperCase()}OUTPUT`, {result, pluginName});
+      if (!input.options.dryRun || dryRun) {
+        logger.log(`Start step "${type}" of plugin "${pluginName}"`);
+        const result = await func({
+          ...cloneDeep(omit(input, ['stdout', 'stderr', 'logger'])),
+          stdout,
+          stderr,
+          logger: logger.scope(logger.scopeName, pluginName),
+        });
+        if (outputValidator && !outputValidator(result)) {
+          throw getError(`E${type.toUpperCase()}OUTPUT`, {result, pluginName});
+        }
+        logger.success(`Completed step "${type}" of plugin "${pluginName}"`);
+        return result;
       }
-      logger.success(`Completed step "${type}" of plugin "${pluginName}"`);
-      return result;
+      logger.warn(`Skip step "${type}" of plugin "${pluginName}" in dry-run mode`);
     } catch (error) {
       logger.error(`Failed step "${type}" of plugin "${pluginName}"`);
       extractErrors(error).forEach(err => Object.assign(err, {pluginName}));


Test patch:
diff --git a/test/index.test.js b/test/index.test.js
index 81cfcb1..046d316 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -25,10 +25,12 @@ test.beforeEach(t => {
   t.context.log = spy();
   t.context.error = spy();
   t.context.success = spy();
+  t.context.warn = spy();
   t.context.logger = {
     log: t.context.log,
     error: t.context.error,
     success: t.context.success,
+    warn: t.context.warn,
     scope: () => t.context.logger,
   };
 });
@@ -428,7 +430,7 @@ test('Log all "verifyRelease" errors', async t => {
   t.deepEqual(fail.args[0][1].errors, [error1, error2]);
 });
 
-test('Dry-run skips publish and success', async t => {
+test('Dry-run skips prepare, publish and success', async t => {
   // Create a git repository, set the current working directory at the root of the repo
   const {cwd, repositoryUrl} = await gitRepo(true);
   // Add commits to the master branch
@@ -446,6 +448,7 @@ test('Dry-run skips publish and success', async t => {
   const analyzeCommits = stub().resolves(nextRelease.type);
   const verifyRelease = stub().resolves();
   const generateNotes = stub().resolves(notes);
+  const prepare = stub().resolves();
   const publish = stub().resolves();
   const success = stub().resolves();
 
@@ -457,7 +460,7 @@ test('Dry-run skips publish and success', async t => {
     analyzeCommits,
     verifyRelease,
     generateNotes,
-    prepare: stub().resolves(),
+    prepare,
     publish,
     success,
   };
@@ -475,13 +478,17 @@ test('Dry-run skips publish and success', async t => {
     })
   );
 
-  t.not(t.context.log.args[0][0], 'This run was not triggered in a known CI environment, running in dry-run mode.');
+  t.not(t.context.warn.args[0][0], 'This run was not triggered in a known CI environment, running in dry-run mode.');
   t.is(verifyConditions.callCount, 1);
   t.is(analyzeCommits.callCount, 1);
   t.is(verifyRelease.callCount, 1);
   t.is(generateNotes.callCount, 1);
+  t.is(prepare.callCount, 0);
+  t.true(t.context.warn.calledWith(`Skip step "prepare" of plugin "[Function: ${prepare.name}]" in dry-run mode`));
   t.is(publish.callCount, 0);
+  t.true(t.context.warn.calledWith(`Skip step "publish" of plugin "[Function: ${publish.name}]" in dry-run mode`));
   t.is(success.callCount, 0);
+  t.true(t.context.warn.calledWith(`Skip step "success" of plugin "[Function: ${success.name}]" in dry-run mode`));
 });
 
 test('Dry-run skips fail', async t => {
@@ -521,6 +528,7 @@ test('Dry-run skips fail', async t => {
   t.true(t.context.error.calledWith('ERR1 error 1'));
   t.true(t.context.error.calledWith('ERR2 error 2'));
   t.is(fail.callCount, 0);
+  t.true(t.context.warn.calledWith(`Skip step "fail" of plugin "[Function: ${fail.name}]" in dry-run mode`));
 });
 
 test('Force a dry-run if not on a CI and "noCi" is not explicitly set', async t => {
@@ -571,7 +579,7 @@ test('Force a dry-run if not on a CI and "noCi" is not explicitly set', async t
     })
   );
 
-  t.is(t.context.log.args[1][0], 'This run was not triggered in a known CI environment, running in dry-run mode.');
+  t.true(t.context.warn.calledWith('This run was not triggered in a known CI environment, running in dry-run mode.'));
   t.is(verifyConditions.callCount, 1);
   t.is(analyzeCommits.callCount, 1);
   t.is(verifyRelease.callCount, 1);
diff --git a/test/plugins/normalize.test.js b/test/plugins/normalize.test.js
index 96ae1d8..d42ae47 100644
--- a/test/plugins/normalize.test.js
+++ b/test/plugins/normalize.test.js
@@ -62,7 +62,7 @@ test('Wrap plugin in a function that add the "pluginName" to the error"', async
     './plugin-error': './test/fixtures',
   });
 
-  const error = await t.throws(plugin());
+  const error = await t.throws(plugin({options: {}}));
 
   t.is(error.pluginName, './plugin-error');
 });
@@ -72,7 +72,7 @@ test('Wrap plugin in a function that add the "pluginName" to multiple errors"',
     './plugin-errors': './test/fixtures',
   });
 
-  const errors = [...(await t.throws(plugin()))];
+  const errors = [...(await t.throws(plugin({options: {}})))];
   for (const error of errors) {
     t.is(error.pluginName, './plugin-errors');
   }
@@ -107,7 +107,7 @@ test('Wrap "analyzeCommits" plugin in a function that validate the output of the
     {}
   );
 
-  const error = await t.throws(plugin());
+  const error = await t.throws(plugin({options: {}}));
 
   t.is(error.code, 'EANALYZECOMMITSOUTPUT');
   t.is(error.name, 'SemanticReleaseError');
@@ -125,7 +125,7 @@ test('Wrap "generateNotes" plugin in a function that validate the output of the
     {}
   );
 
-  const error = await t.throws(plugin());
+  const error = await t.throws(plugin({options: {}}));
 
   t.is(error.code, 'EGENERATENOTESOUTPUT');
   t.is(error.name, 'SemanticReleaseError');
@@ -143,7 +143,7 @@ test('Wrap "publish" plugin in a function that validate the output of the plugin
     {}
   );
 
-  const error = await t.throws(plugin());
+  const error = await t.throws(plugin({options: {}}));
 
   t.is(error.code, 'EPUBLISHOUTPUT');
   t.is(error.name, 'SemanticReleaseError');
@@ -157,7 +157,7 @@ test('Plugin is called with "pluginConfig" (with object definition) and input',
   const pluginConf = {path: pluginFunction, conf: 'confValue'};
   const options = {global: 'globalValue'};
   const plugin = normalize({cwd, options, logger: t.context.logger}, '', pluginConf, {});
-  await plugin({param: 'param'});
+  await plugin({options: {}, param: 'param'});
 
   t.true(
     pluginFunction.calledWithMatch(
@@ -172,7 +172,7 @@ test('Plugin is called with "pluginConfig" (with array definition) and input', a
   const pluginConf = [pluginFunction, {conf: 'confValue'}];
   const options = {global: 'globalValue'};
   const plugin = normalize({cwd, options, logger: t.context.logger}, '', pluginConf, {});
-  await plugin({param: 'param'});
+  await plugin({options: {}, param: 'param'});
 
   t.true(
     pluginFunction.calledWithMatch(
@@ -189,7 +189,7 @@ test('Prevent plugins to modify "pluginConfig"', async t => {
   const pluginConf = {path: pluginFunction, conf: {subConf: 'originalConf'}};
   const options = {globalConf: {globalSubConf: 'originalGlobalConf'}};
   const plugin = normalize({cwd, options, logger: t.context.logger}, '', pluginConf, {});
-  await plugin();
+  await plugin({options: {}});
 
   t.is(pluginConf.conf.subConf, 'originalConf');
   t.is(options.globalConf.globalSubConf, 'originalGlobalConf');
@@ -199,7 +199,7 @@ test('Prevent plugins to modify its input', async t => {
   const pluginFunction = stub().callsFake((pluginConfig, options) => {
     options.param.subParam = 'otherParam';
   });
-  const input = {param: {subParam: 'originalSubParam'}};
+  const input = {param: {subParam: 'originalSubParam'}, options: {}};
   const plugin = normalize({cwd, options: {}, logger: t.context.logger}, '', pluginFunction, {});
   await plugin(input);
 
@@ -220,7 +220,7 @@ test('Always pass a defined "pluginConfig" for plugin defined with string', asyn
     './test/fixtures/plugin-result-config',
     {}
   );
-  const pluginResult = await plugin();
+  const pluginResult = await plugin({options: {}});
 
   t.deepEqual(pluginResult.pluginConfig, {});
 });
@@ -233,7 +233,7 @@ test('Always pass a defined "pluginConfig" for plugin defined with path', async
     {path: './test/fixtures/plugin-result-config'},
     {}
   );
-  const pluginResult = await plugin();
+  const pluginResult = await plugin({options: {}});
 
   t.deepEqual(pluginResult.pluginConfig, {});
 });
diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js
index a002062..bdfa07b 100644
--- a/test/plugins/plugins.test.js
+++ b/test/plugins/plugins.test.js
@@ -63,14 +63,14 @@ test('Export plugins based on "plugins" config (array)', async t => {
     {}
   );
 
-  await plugins.verifyConditions({});
+  await plugins.verifyConditions({options: {}});
   t.true(plugin1.verifyConditions.calledOnce);
   t.true(plugin2.verifyConditions.calledOnce);
 
-  await plugins.publish({});
+  await plugins.publish({options: {}});
   t.true(plugin1.publish.calledOnce);
 
-  await plugins.verifyRelease({});
+  await plugins.verifyRelease({options: {}});
   t.true(plugin2.verifyRelease.notCalled);
 
   // Verify the module returns a function for each plugin
@@ -88,10 +88,10 @@ test('Export plugins based on "plugins" config (single definition)', async t =>
   const plugin1 = {verifyConditions: stub(), publish: stub()};
   const plugins = getPlugins({cwd, logger: t.context.logger, options: {plugins: plugin1}}, {});
 
-  await plugins.verifyConditions({});
+  await plugins.verifyConditions({options: {}});
   t.true(plugin1.verifyConditions.calledOnce);
 
-  await plugins.publish({});
+  await plugins.publish({options: {}});
   t.true(plugin1.publish.calledOnce);
 
   // Verify the module returns a function for each plugin
@@ -118,14 +118,14 @@ test('Merge global options, "plugins" options and step options', async t => {
     {}
   );
 
-  await plugins.verifyConditions({});
+  await plugins.verifyConditions({options: {}});
   t.deepEqual(plugin1[0].verifyConditions.args[0][0], {globalOpt: 'global', pluginOpt1: 'plugin1'});
   t.deepEqual(plugin2[0].verifyConditions.args[0][0], {globalOpt: 'global', pluginOpt2: 'plugin2'});
 
-  await plugins.publish({});
+  await plugins.publish({options: {}});
   t.deepEqual(plugin1[0].publish.args[0][0], {globalOpt: 'global', pluginOpt1: 'plugin1'});
 
-  await plugins.verifyRelease({});
+  await plugins.verifyRelease({options: {}});
   t.deepEqual(plugin3[0].args[0][0], {globalOpt: 'global', pluginOpt3: 'plugin3'});
 });
 
@@ -248,7 +248,7 @@ test('Merge global options with plugin options', async t => {
     {}
   );
 
-  const [result] = await plugins.verifyRelease();
+  const [result] = await plugins.verifyRelease({options: {}});
 
   t.deepEqual(result.pluginConfig, {localOpt: 'local', globalOpt: 'global', otherOpt: 'locally-defined'});
 });