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

Issue:
Support shareable config
this has probably been discussed before, but i'm not finding any info about it if it has. semantic-release provides a considerable amount of customizability, but i've always been hesitant to get too deep because i didn't want to take on deviating from the default conventions consistently across all repos where my organization(s) use semantic-release. 

i think i'd be far more interested in tweaking semantic-release further if doing so could apply across all of the projects that i manage fairly automatically.

at minimum, this would enable a config to be published as an npm package, such as `semantic-release-config-travi` and used in each semantic-relase managed project using something along the lines of `extends: 'travi'`. ideally, a config could also extend another config, such as `semantic-release-config-greenkeeper-keeper` extending `semantic-release-config-travi`, allowing projects under the `greenkeeper-keeper` org to extend `semantic-release-config-greenkeeper-keeper` but have that mostly configured from my personal config.

has the concept of a shareable config, similar to what [eslint enables](https://eslint.org/docs/developer-guide/shareable-configs) been considered for semantic-release? would something like this be a huge undertaking to add?

Pull request description:
Shareable config
Fix #549

#### Global plugin options
Currently users are forced to redefined all plugin in the pipeline to define a plugin options. That creates a lengthy option object and its error prone.
In addition several plugin uses the same options (i.e. `githubUrl`).

It's now possible to defined options directly under the `release` property, that will be passed to each plugins.
It's still possible to define options within the plugin definition to have it passed only to a specific plugin instance.

#### Shareable config
Adds the options `extends`, which can be defined via configuration file or CLI arguments to a single path or an array of paths of shareable configuration.
A shareable configuration is a file or a module that can be loaded with `require`.
Options is defined by merging in the following order of priority:
- CLI/API
- Configuration file
- Shareable configuration (from right to left)

Options set in a shareable configuration can be unset by setting it to `null` or `undefined` in the main configuration file. If a default value applies to this property it will be used.



Patch:
diff --git a/README.md b/README.md
index 42d0ae8..47dc963 100644
--- a/README.md
+++ b/README.md
@@ -148,7 +148,9 @@ _[This is what happens under the hood.](https://github.com/semantic-release/cli#
 
 ## Options
 
-You can pass options either via command line (in [kebab-case](https://lodash.com/docs#kebabCase)) or in the `release` field of your `package.json` (in [camelCase](https://lodash.com/docs#camelCase)). The following two examples are the same, but CLI arguments take precedence.
+You can pass options either via command line (in [kebab-case](https://lodash.com/docs#kebabCase)) or in the `release` field of your `package.json` (in [camelCase](https://lodash.com/docs#camelCase)). Alternatively the configuration can also be defined in `.releaserc.yml`, `.releaserc.js`, `.releaserc.js` or `release.config.js`.
+
+The following two examples are the same, but CLI arguments take precedence.
 
 ##### CLI
 ```bash
@@ -169,18 +171,17 @@ These options are currently available:
 - `branch`: The branch on which releases should happen. Default: `'master'`
 - `repositoryUrl`: The git repository URL. Default: `repository` property in `package.json` or git origin url. Any valid git url format is supported (See [Git protocols](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols)). If the [Github plugin](https://github.com/semantic-release/github) is used the URL must be a valid Github URL that include the `owner`, the `repository` name and the `host`. The Github shorthand URL is not supported.
 - `dry-run`: Dry-run mode, skipping verifyConditions, publishing and release, printing next version and release notes
+- `extends`: Array of module or files path containing a shareable configuration. Options defined via CLI or in the `release` property will take precedence over the one defined in a shareable configuration.
 - `debug`: Output debugging information
 
 _A few notes on `npm` config_:
 1. The `npm` token can only be defined in the environment as `NPM_TOKEN`, because that’s where `npm` itself is going to read it from.
-
 2. In order to publish to a different `npm` registry you can specify that inside the `package.json`’s [`publishConfig`](https://docs.npmjs.com/files/package.json#publishconfig) field.
-
 3. If you want to use another dist-tag for your publishes than `'latest'` you can specify that inside the `package.json`’s [`publishConfig`](https://docs.npmjs.com/files/package.json#publishconfig) field.
 
 ## Plugins
 
-There are numerous steps where you can customize `semantic-release`’s behaviour using plugins. A plugin is a regular [option](#options), but passed inside the `release` block of `package.json`:
+There are numerous steps where you can customize `semantic-release`’s behavior using plugins. A plugin is a regular [option](#options), but passed inside the `release` block of `package.json`:
 
 ```json
 {
@@ -190,24 +191,27 @@ There are numerous steps where you can customize `semantic-release`’s behaviou
     "verifyConditions": {
       "path": "./path/to/a/module",
       "additional": "config"
-    }
+    },
+    "globalPluginOptions": "globalConfig"
   }
 }
 ```
 
-```
+```bash
 semantic-release --analyze-commits="npm-module-name"
 ```
 
+**Note**: The plugin CLI arguments can be only used to override the plugins to use. Plugins options cannot be defined via CLI arguments and must be defined in the main configuration file or in a shareable config.
+
 A plugin itself is an async function that always receives three arguments.
 
 ```js
 module.exports = function (pluginConfig, config, callback) {}
 ```
 
-- `pluginConfig`: If the user of your plugin specifies additional plugin config in the `package.json` (see the `verifyConditions` example above) then it’s this object.
+- `pluginConfig`: If the user of your plugin specifies additional plugin config in the `package.json` (see the `verifyConditions` example above) then it’s this object. Options defined directly under `release` will be passed to each plugins. Options defined within a plugin will passed only to that instance of the plugin.
 - `config`: A config object containing a lot of information to act upon.
-  - `options`: `semantic-release` options like `debug`, or `branch`
+  - `options`: `semantic-release` options like `repositoryUrl`, or `branch`
   - For certain plugins the `config` object contains even more information. See below.
 
 ### `analyzeCommits`
@@ -222,9 +226,9 @@ Have a look at the [default implementation](https://github.com/semantic-release/
 
 This plugins is responsible for verifying that a release should happen in the first place.
 The default implementations are:
-- [travis](https://github.com/semantic-release/condition-travis/): verifies that the publish is happening on Travis, that it’s the right branch, and that all other build jobs succeeded. 
-- [github](https://github.com/semantic-release/github/): verifies a Github authentication is set and valid. 
-- [npm](https://github.com/semantic-release/npm/): verifies an npm authentication is set and valid. 
+- [travis](https://github.com/semantic-release/condition-travis/): verifies that the publish is happening on Travis, that it’s the right branch, and that all other build jobs succeeded.
+- [github](https://github.com/semantic-release/github/): verifies a Github authentication is set and valid.
+- [npm](https://github.com/semantic-release/npm/): verifies an npm authentication is set and valid.
 
 Passing an array of plugins will run them in series.
 
@@ -245,7 +249,7 @@ It receives a `commits` array, the `lastRelease` and `nextRelease` inside `confi
 
 ### `publish`
 
-This plugins is responsible for publishing the release. The default implementations publish on [npm](https://github.com/semantic-release/npm) and [github](https://github.com/semantic-release/github). 
+This plugins is responsible for publishing the release. The default implementations publish on [npm](https://github.com/semantic-release/npm) and [github](https://github.com/semantic-release/github).
 
 Passing an array of plugins will run them in series.
 
diff --git a/cli.js b/cli.js
index 60c0d66..cf431b8 100755
--- a/cli.js
+++ b/cli.js
@@ -1,4 +1,5 @@
 const program = require('commander');
+const {pickBy, isUndefined} = require('lodash');
 const logger = require('./lib/logger');
 
 function list(values) {
@@ -11,6 +12,7 @@ module.exports = async () => {
     .description('Run automated package publishing')
     .option('-b, --branch <branch>', 'Branch to release from')
     .option('-r, --repositoryUrl <repositoryUrl>', 'Git repository URL')
+    .option('-e, --extends <paths>', 'Comma separated list of shareable config paths or packages name', list)
     .option(
       '--verify-conditions <paths>',
       'Comma separated list of paths or packages name for the verifyConditions plugin(s)',
@@ -42,7 +44,8 @@ module.exports = async () => {
       program.outputHelp();
       process.exitCode = 1;
     } else {
-      await require('.')(program.opts());
+      // Remove option with undefined values, as commander.js sets non defined options as `undefined`
+      await require('.')(pickBy(program.opts(), value => !isUndefined(value)));
     }
   } catch (err) {
     // If error is a SemanticReleaseError then it's an expected exception case (no release to be done, running on a PR etc..) and the cli will return with 0
diff --git a/index.js b/index.js
index a42467a..f305703 100644
--- a/index.js
+++ b/index.js
@@ -15,10 +15,6 @@ module.exports = async opts => {
   const config = await getConfig(opts, logger);
   const {plugins, options} = config;
 
-  if (!options.repositoryUrl) {
-    throw new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL');
-  }
-
   logger.log('Run automated release from branch %s', options.branch);
 
   if (!options.dryRun) {
diff --git a/lib/get-config.js b/lib/get-config.js
index d7fd4c8..2f32521 100644
--- a/lib/get-config.js
+++ b/lib/get-config.js
@@ -1,14 +1,56 @@
 const readPkgUp = require('read-pkg-up');
-const {defaults} = require('lodash');
+const {castArray, pickBy, isUndefined, isNull, isString, isPlainObject} = require('lodash');
 const cosmiconfig = require('cosmiconfig');
+const resolveFrom = require('resolve-from');
+const SemanticReleaseError = require('@semantic-release/error');
 const debug = require('debug')('semantic-release:config');
 const {repoUrl} = require('./git');
+const PLUGINS_DEFINITION = require('./plugins/definitions');
 const plugins = require('./plugins');
 
 module.exports = async (opts, logger) => {
   const {config} = (await cosmiconfig('release', {rcExtensions: true}).load(process.cwd())) || {};
-  const options = defaults(opts, config, {branch: 'master', repositoryUrl: (await pkgRepoUrl()) || (await repoUrl())});
+  // Merge config file options and CLI/API options
+  let options = {...config, ...opts};
+  const pluginsPath = {};
+  let extendPaths;
+  ({extends: extendPaths, ...options} = options);
+  if (extendPaths) {
+    // If `extends` is defined, load and merge each shareable config with `options`
+    options = {
+      ...castArray(extendPaths).reduce((result, extendPath) => {
+        const extendsOpts = require(resolveFrom.silent(__dirname, extendPath) ||
+          resolveFrom(process.cwd(), extendPath));
 
+        // 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) => {
+          if (PLUGINS_DEFINITION[option]) {
+            castArray(extendsOpts[option])
+              .filter(plugin => isString(plugin) || (isPlainObject(plugin) && isString(plugin.path)))
+              .map(plugin => (isString(plugin) ? plugin : plugin.path))
+              .forEach(plugin => {
+                pluginsPath[plugin] = extendPath;
+              });
+          }
+          return pluginsPath;
+        }, pluginsPath);
+
+        return {...result, ...extendsOpts};
+      }, {}),
+      ...options,
+    };
+  }
+
+  // Set default options values if not defined yet
+  options = {
+    branch: 'master',
+    repositoryUrl: (await pkgRepoUrl()) || (await repoUrl()),
+    // Remove `null` and `undefined` options so they can be replaced with default ones
+    ...pickBy(options, option => !isUndefined(option) && !isNull(option)),
+  };
+
+  debug('options values: %O', Object.keys(options));
   debug('name: %O', options.name);
   debug('branch: %O', options.branch);
   debug('repositoryUrl: %O', options.repositoryUrl);
@@ -18,10 +60,14 @@ module.exports = async (opts, logger) => {
   debug('verifyRelease: %O', options.verifyRelease);
   debug('publish: %O', options.publish);
 
-  return {options, plugins: await plugins(options, logger)};
+  if (!options.repositoryUrl) {
+    throw new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL');
+  }
+
+  return {options, plugins: await plugins(options, pluginsPath, logger)};
 };
 
 async function pkgRepoUrl() {
   const {pkg} = await readPkgUp();
-  return pkg && pkg.repository ? pkg.repository.url : null;
+  return pkg && pkg.repository ? pkg.repository.url : undefined;
 }
diff --git a/lib/git.js b/lib/git.js
index dbff850..fce3d86 100644
--- a/lib/git.js
+++ b/lib/git.js
@@ -73,10 +73,10 @@ async function gitHead() {
 }
 
 /**
- * @return {string|null} The value of the remote git URL.
+ * @return {string|undefined} The value of the remote git URL.
  */
 async function repoUrl() {
-  return (await execa.stdout('git', ['remote', 'get-url', 'origin'], {reject: false})) || null;
+  return (await execa.stdout('git', ['remote', 'get-url', 'origin'], {reject: false})) || undefined;
 }
 
 /**
diff --git a/lib/plugins/index.js b/lib/plugins/index.js
index 70bc69d..9bbd16d 100644
--- a/lib/plugins/index.js
+++ b/lib/plugins/index.js
@@ -1,11 +1,12 @@
-const {isArray, isObject} = require('lodash');
-const DEFINITIONS = require('./definitions');
+const {isArray, isObject, omit} = require('lodash');
+const SemanticReleaseError = require('@semantic-release/error');
+const PLUGINS_DEFINITION = require('./definitions');
 const pipeline = require('./pipeline');
 const normalize = require('./normalize');
 
-module.exports = (options, logger) =>
-  Object.keys(DEFINITIONS).reduce((plugins, pluginType) => {
-    const {config, output, default: def} = DEFINITIONS[pluginType];
+module.exports = (options, pluginsPath, logger) =>
+  Object.keys(PLUGINS_DEFINITION).reduce((plugins, pluginType) => {
+    const {config, output, default: def} = PLUGINS_DEFINITION[pluginType];
     let pluginConfs;
     if (options[pluginType]) {
       // If an object is passed and the path is missing, set the default one for single plugins
@@ -13,16 +14,18 @@ module.exports = (options, logger) =>
         options[pluginType].path = def;
       }
       if (config && !config.validator(options[pluginType])) {
-        throw new Error(config.message);
+        throw new SemanticReleaseError(config.message, 'EPLUGINCONF');
       }
       pluginConfs = options[pluginType];
     } else {
       pluginConfs = def;
     }
 
+    const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITION));
+
     plugins[pluginType] = isArray(pluginConfs)
-      ? pipeline(pluginConfs.map(conf => normalize(pluginType, conf, logger, output)))
-      : normalize(pluginType, pluginConfs, logger, output);
+      ? pipeline(pluginConfs.map(conf => normalize(pluginType, pluginsPath, globalOpts, conf, logger, output)))
+      : normalize(pluginType, pluginsPath, globalOpts, pluginConfs, logger, output);
 
     return plugins;
   }, {});
diff --git a/lib/plugins/normalize.js b/lib/plugins/normalize.js
index 8cae2fd..d045cd8 100644
--- a/lib/plugins/normalize.js
+++ b/lib/plugins/normalize.js
@@ -1,25 +1,39 @@
+const {dirname} = require('path');
 const {inspect} = require('util');
+const SemanticReleaseError = require('@semantic-release/error');
 const {isString, isObject, isFunction, noop, cloneDeep} = require('lodash');
-const importFrom = require('import-from');
+const resolveFrom = require('resolve-from');
 
-module.exports = (pluginType, pluginConfig, logger, validator) => {
-  if (!pluginConfig) {
+module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger, validator) => {
+  if (!pluginOpts) {
     return noop;
   }
-  const {path, ...config} = isString(pluginConfig) || isFunction(pluginConfig) ? {path: pluginConfig} : pluginConfig;
-  if (!isFunction(pluginConfig)) {
-    logger.log('Load plugin %s from %s', pluginType, path);
+
+  const {path, ...config} = isString(pluginOpts) || isFunction(pluginOpts) ? {path: pluginOpts} : pluginOpts;
+  if (!isFunction(pluginOpts)) {
+    if (pluginsPath[path]) {
+      logger.log('Load plugin %s from %s in shareable config %s', pluginType, path, pluginsPath[path]);
+    } else {
+      logger.log('Load plugin %s from %s', pluginType, path);
+    }
   }
-  const plugin = isFunction(path) ? path : importFrom.silent(__dirname, path) || importFrom(process.cwd(), path);
+
+  const basePath = pluginsPath[path]
+    ? dirname(resolveFrom.silent(__dirname, pluginsPath[path]) || resolveFrom(process.cwd(), pluginsPath[path]))
+    : __dirname;
+  const plugin = isFunction(path)
+    ? path
+    : require(resolveFrom.silent(basePath, path) || resolveFrom(process.cwd(), path));
 
   let func;
   if (isFunction(plugin)) {
-    func = plugin.bind(null, cloneDeep(config));
+    func = plugin.bind(null, cloneDeep({...globalOpts, ...config}));
   } else if (isObject(plugin) && plugin[pluginType] && isFunction(plugin[pluginType])) {
-    func = plugin[pluginType].bind(null, cloneDeep(config));
+    func = plugin[pluginType].bind(null, cloneDeep({...globalOpts, ...config}));
   } else {
-    throw new Error(
-      `The ${pluginType} plugin must be a function, or an object with a function in the property ${pluginType}.`
+    throw new SemanticReleaseError(
+      `The ${pluginType} plugin must be a function, or an object with a function in the property ${pluginType}.`,
+      'EPLUGINCONF'
     );
   }
 
diff --git a/package.json b/package.json
index 051256b..88eb0fb 100644
--- a/package.json
+++ b/package.json
@@ -28,12 +28,12 @@
     "execa": "^0.8.0",
     "get-stream": "^3.0.0",
     "git-log-parser": "^1.2.0",
-    "import-from": "^2.1.0",
     "lodash": "^4.0.0",
     "marked": "^0.3.6",
     "marked-terminal": "^2.0.0",
     "p-reduce": "^1.0.0",
     "read-pkg-up": "^3.0.0",
+    "resolve-from": "^4.0.0",
     "semver": "^5.4.1"
   },
   "devDependencies": {


Test patch:
diff --git a/test/fixtures/index.js b/test/fixtures/index.js
new file mode 100644
index 0000000..3246d10
--- /dev/null
+++ b/test/fixtures/index.js
@@ -0,0 +1,2 @@
+module.exports = () => {};
+
diff --git a/test/get-config.test.js b/test/get-config.test.js
index 9593935..5f472e2 100644
--- a/test/get-config.test.js
+++ b/test/get-config.test.js
@@ -1,5 +1,7 @@
+import {format} from 'util';
 import test from 'ava';
-import {writeFile, writeJson} from 'fs-extra';
+import {writeFile, outputJson} from 'fs-extra';
+import {omit} from 'lodash';
 import proxyquire from 'proxyquire';
 import {stub} from 'sinon';
 import yaml from 'js-yaml';
@@ -30,7 +32,7 @@ test.serial('Default values, reading repositoryUrl from package.json', async t =
   // Add remote.origin.url config
   await gitAddConfig('remote.origin.url', 'git@repo.com:owner/module.git');
   // Create package.json in repository root
-  await writeJson('./package.json', pkg);
+  await outputJson('./package.json', pkg);
 
   const {options} = await t.context.getConfig();
 
@@ -57,7 +59,7 @@ test.serial('Default values, reading repositoryUrl (http url) from package.json
   // Create a git repository, set the current working directory at the root of the repo
   await gitRepo();
   // Create package.json in repository root
-  await writeJson('./package.json', pkg);
+  await outputJson('./package.json', pkg);
 
   const {options} = await t.context.getConfig();
 
@@ -78,7 +80,7 @@ test.serial('Read options from package.json', async t => {
   // Create a git repository, set the current working directory at the root of the repo
   await gitRepo();
   // Create package.json in repository root
-  await writeJson('./package.json', {release});
+  await outputJson('./package.json', {release});
 
   const {options} = await t.context.getConfig();
 
@@ -118,7 +120,7 @@ test.serial('Read options from .releaserc.json', async t => {
   // Create a git repository, set the current working directory at the root of the repo
   await gitRepo();
   // Create package.json in repository root
-  await writeJson('.releaserc.json', release);
+  await outputJson('.releaserc.json', release);
 
   const {options} = await t.context.getConfig();
 
@@ -168,7 +170,7 @@ test.serial('Read options from release.config.js', async t => {
   t.deepEqual(t.context.plugins.args[0][0], release);
 });
 
-test.serial('Prioritise cli parameters over file configuration and git repo', async t => {
+test.serial('Prioritise CLI/API parameters over file configuration and git repo', async t => {
   const release = {
     getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_pkg'},
     branch: 'branch_pkg',
@@ -185,12 +187,266 @@ test.serial('Prioritise cli parameters over file configuration and git repo', as
   // Create a clone
   await gitShallowClone(repo);
   // Create package.json in repository root
-  await writeJson('./package.json', pkg);
+  await outputJson('./package.json', pkg);
 
   const result = await t.context.getConfig(options);
 
-  // Verify the options contains the plugin config from cli
+  // Verify the options contains the plugin config from CLI/API
   t.deepEqual(result.options, options);
-  // Verify the plugins module is called with the plugin options from cli
+  // Verify the plugins module is called with the plugin options from CLI/API
   t.deepEqual(t.context.plugins.args[0][0], options);
 });
+
+test.serial('Read configuration from file path in "extends"', async t => {
+  const release = {extends: './shareable.json'};
+  const shareable = {
+    analyzeCommits: 'analyzeCommits',
+    generateNotes: 'generateNotes',
+    getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
+    branch: 'test_branch',
+    repositoryUrl: 'git+https://hostname.com/owner/module.git',
+  };
+
+  // Create a git repository, set the current working directory at the root of the repo
+  await gitRepo();
+  // Create package.json and shareable.json in repository root
+  await outputJson('./package.json', {release});
+  await outputJson('./shareable.json', shareable);
+
+  const {options} = await t.context.getConfig();
+
+  // Verify the options contains the plugin config from shareable.json
+  t.deepEqual(options, shareable);
+  // Verify the plugins module is called with the plugin options from shareable.json
+  t.deepEqual(t.context.plugins.args[0][0], shareable);
+  t.deepEqual(t.context.plugins.args[0][1], {
+    analyzeCommits: './shareable.json',
+    generateNotes: './shareable.json',
+    getLastRelease: './shareable.json',
+  });
+});
+
+test.serial('Read configuration from module path in "extends"', async t => {
+  const release = {extends: 'shareable'};
+  const shareable = {
+    analyzeCommits: 'analyzeCommits',
+    generateNotes: 'generateNotes',
+    getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
+    branch: 'test_branch',
+    repositoryUrl: 'git+https://hostname.com/owner/module.git',
+  };
+
+  // Create a git repository, set the current working directory at the root of the repo
+  await gitRepo();
+  // Create package.json and shareable.json in repository root
+  await outputJson('./package.json', {release});
+  await outputJson('./node_modules/shareable/index.json', shareable);
+
+  const {options} = await t.context.getConfig();
+
+  // Verify the options contains the plugin config from shareable.json
+  t.deepEqual(options, shareable);
+  // Verify the plugins module is called with the plugin options from shareable.json
+  t.deepEqual(t.context.plugins.args[0][0], shareable);
+  t.deepEqual(t.context.plugins.args[0][1], {
+    analyzeCommits: 'shareable',
+    generateNotes: 'shareable',
+    getLastRelease: 'shareable',
+  });
+});
+
+test.serial('Read configuration from an array of paths in "extends"', async t => {
+  const release = {extends: ['./shareable1.json', './shareable2.json']};
+  const shareable1 = {
+    analyzeCommits: 'analyzeCommits1',
+    getLastRelease: {path: 'getLastRelease1', param: 'getLastRelease_param1'},
+    branch: 'test_branch',
+    repositoryUrl: 'git+https://hostname.com/owner/module.git',
+  };
+
+  const shareable2 = {
+    analyzeCommits: 'analyzeCommits2',
+    generateNotes: 'generateNotes2',
+    getLastRelease: {path: 'getLastRelease2', param: 'getLastRelease_param2'},
+    branch: 'test_branch',
+  };
+
+  // Create a git repository, set the current working directory at the root of the repo
+  await gitRepo();
+  // Create package.json and shareable.json in repository root
+  await outputJson('./package.json', {release});
+  await outputJson('./shareable1.json', shareable1);
+  await outputJson('./shareable2.json', shareable2);
+
+  const {options} = await t.context.getConfig();
+
+  // Verify the options contains the plugin config from shareable1.json and shareable2.json
+  t.deepEqual(options, {...shareable1, ...shareable2});
+  // Verify the plugins module is called with the plugin options from shareable1.json and shareable2.json
+  t.deepEqual(t.context.plugins.args[0][0], {...shareable1, ...shareable2});
+  t.deepEqual(t.context.plugins.args[0][1], {
+    analyzeCommits1: './shareable1.json',
+    analyzeCommits2: './shareable2.json',
+    generateNotes2: './shareable2.json',
+    getLastRelease1: './shareable1.json',
+    getLastRelease2: './shareable2.json',
+  });
+});
+
+test.serial('Prioritize configuration from config file over "extends"', async t => {
+  const release = {
+    extends: './shareable.json',
+    branch: 'test_pkg',
+    generateNotes: 'generateNotes',
+    publish: [{path: 'publishPkg', param: 'publishPkg_param'}],
+  };
+  const shareable = {
+    analyzeCommits: 'analyzeCommits',
+    generateNotes: 'generateNotesShareable',
+    publish: [{path: 'publishShareable', param: 'publishShareable_param'}],
+    branch: 'test_branch',
+    repositoryUrl: 'git+https://hostname.com/owner/module.git',
+  };
+
+  // Create a git repository, set the current working directory at the root of the repo
+  await gitRepo();
+  // Create package.json and shareable.json in repository root
+  await outputJson('./package.json', {release});
+  await outputJson('./shareable.json', shareable);
+
+  const {options} = await t.context.getConfig();
+
+  // Verify the options contains the plugin config from package.json and shareable.json
+  t.deepEqual(options, omit({...shareable, ...release}, 'extends'));
+  // Verify the plugins module is called with the plugin options from package.json and shareable.json
+  t.deepEqual(t.context.plugins.args[0][0], omit({...shareable, ...release}, 'extends'));
+  t.deepEqual(t.context.plugins.args[0][1], {
+    analyzeCommits: './shareable.json',
+    generateNotesShareable: './shareable.json',
+    publishShareable: './shareable.json',
+  });
+});
+
+test.serial('Prioritize configuration from cli/API options over "extends"', async t => {
+  const opts = {
+    extends: './shareable2.json',
+    branch: 'branch_opts',
+    publish: [{path: 'publishOpts', param: 'publishOpts_param'}],
+    repositoryUrl: 'git+https://hostname.com/owner/module.git',
+  };
+  const release = {
+    extends: './shareable1.json',
+    branch: 'branch_pkg',
+    generateNotes: 'generateNotes',
+    publish: [{path: 'publishPkg', param: 'publishPkg_param'}],
+  };
+  const shareable1 = {
+    analyzeCommits: 'analyzeCommits1',
+    generateNotes: 'generateNotesShareable1',
+    publish: [{path: 'publishShareable', param: 'publishShareable_param1'}],
+    branch: 'test_branch1',
+    repositoryUrl: 'git+https://hostname.com/owner/module.git',
+  };
+  const shareable2 = {
+    analyzeCommits: 'analyzeCommits2',
+    publish: [{path: 'publishShareable', param: 'publishShareable_param2'}],
+    branch: 'test_branch2',
+  };
+
+  // Create a git repository, set the current working directory at the root of the repo
+  await gitRepo();
+  // Create package.json, shareable1.json and shareable2.json in repository root
+  await outputJson('./package.json', {release});
+  await outputJson('./shareable1.json', shareable1);
+  await outputJson('./shareable2.json', shareable2);
+
+  const {options} = await t.context.getConfig(opts);
+
+  // Verify the options contains the plugin config from package.json and shareable2.json
+  t.deepEqual(options, omit({...shareable2, ...release, ...opts}, 'extends'));
+  // Verify the plugins module is called with the plugin options from package.json and shareable2.json
+  t.deepEqual(t.context.plugins.args[0][0], omit({...shareable2, ...release, ...opts}, 'extends'));
+});
+
+test.serial('Allow to unset properties defined in shareable config with "null"', async t => {
+  const release = {
+    extends: './shareable.json',
+    getLastRelease: null,
+    branch: 'test_branch',
+    repositoryUrl: 'git+https://hostname.com/owner/module.git',
+  };
+  const shareable = {
+    generateNotes: 'generateNotes',
+    getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
+  };
+
+  // Create a git repository, set the current working directory at the root of the repo
+  await gitRepo();
+  // Create package.json and shareable.json in repository root
+  await outputJson('./package.json', {release});
+  await outputJson('./shareable.json', shareable);
+
+  const {options} = await t.context.getConfig();
+
+  // Verify the options contains the plugin config from shareable.json
+  t.deepEqual(options, {...omit(shareable, 'getLastRelease'), ...omit(release, ['extends', 'getLastRelease'])});
+  // Verify the plugins module is called with the plugin options from shareable.json
+  t.deepEqual(t.context.plugins.args[0][0], {
+    ...omit(shareable, 'getLastRelease'),
+    ...omit(release, ['extends', 'getLastRelease']),
+  });
+  t.deepEqual(t.context.plugins.args[0][1], {
+    generateNotes: './shareable.json',
+    getLastRelease: './shareable.json',
+  });
+});
+
+test.serial('Allow to unset properties defined in shareable config with "undefined"', async t => {
+  const release = {
+    extends: './shareable.json',
+    getLastRelease: undefined,
+    branch: 'test_branch',
+    repositoryUrl: 'git+https://hostname.com/owner/module.git',
+  };
+  const shareable = {
+    generateNotes: 'generateNotes',
+    getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
+  };
+
+  // Create a git repository, set the current working directory at the root of the repo
+  await gitRepo();
+  // Create package.json and release.config.js in repository root
+  // await outputJson('./package.json', {release});
+  await writeFile('release.config.js', `module.exports = ${format(release)}`);
+  await outputJson('./shareable.json', shareable);
+
+  const {options} = await t.context.getConfig();
+
+  // Verify the options contains the plugin config from shareable.json
+  t.deepEqual(options, {...omit(shareable, 'getLastRelease'), ...omit(release, ['extends', 'getLastRelease'])});
+  // Verify the plugins module is called with the plugin options from shareable.json
+  t.deepEqual(t.context.plugins.args[0][0], {
+    ...omit(shareable, 'getLastRelease'),
+    ...omit(release, ['extends', 'getLastRelease']),
+  });
+  t.deepEqual(t.context.plugins.args[0][1], {
+    generateNotes: './shareable.json',
+    getLastRelease: './shareable.json',
+  });
+});
+
+test.serial('Throw an Error if one of the shareable config cannot be found', async t => {
+  const release = {extends: ['./shareable1.json', 'non-existing-path']};
+  const shareable = {analyzeCommits: 'analyzeCommits'};
+
+  // Create a git repository, set the current working directory at the root of the repo
+  await gitRepo();
+  // Create package.json and shareable.json in repository root
+  await outputJson('./package.json', {release});
+  await outputJson('./shareable1.json', shareable);
+
+  const error = await t.throws(t.context.getConfig(), Error);
+
+  t.is(error.message, "Cannot find module 'non-existing-path'");
+  t.is(error.code, 'MODULE_NOT_FOUND');
+});
diff --git a/test/git.test.js b/test/git.test.js
index bc24011..88e70b3 100644
--- a/test/git.test.js
+++ b/test/git.test.js
@@ -120,9 +120,9 @@ test.serial('Return git remote repository url set while cloning', async t => {
   t.is(await repoUrl(), fileUrl(repo));
 });
 
-test.serial('Return "null" if git repository url is not set', async t => {
+test.serial('Return "undefined" if git repository url is not set', async t => {
   // Create a git repository, set the current working directory at the root of the repo
   await gitRepo();
 
-  t.is(await repoUrl(), null);
+  t.is(await repoUrl(), undefined);
 });
diff --git a/test/index.test.js b/test/index.test.js
index 6f961d6..ac769c4 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -50,9 +50,9 @@ test.serial('Plugins are called with expected values', async t => {
   const generateNotes = stub().resolves(notes);
   const publish = stub().resolves();
 
+  const config = {branch: 'master', repositoryUrl: 'git@hostname.com:owner/module.git', globalOpt: 'global'};
   const options = {
-    branch: 'master',
-    repositoryUrl: 'git@hostname.com:owner/module.git',
+    ...config,
     verifyConditions: [verifyConditions1, verifyConditions2],
     getLastRelease,
     analyzeCommits,
@@ -64,14 +64,17 @@ test.serial('Plugins are called with expected values', async t => {
   await t.context.semanticRelease(options);
 
   t.is(verifyConditions1.callCount, 1);
+  t.deepEqual(verifyConditions1.args[0][0], config);
   t.deepEqual(verifyConditions1.args[0][1], {options, logger: t.context.logger});
   t.is(verifyConditions2.callCount, 1);
   t.deepEqual(verifyConditions2.args[0][1], {options, logger: t.context.logger});
 
   t.is(getLastRelease.callCount, 1);
+  t.deepEqual(getLastRelease.args[0][0], config);
   t.deepEqual(getLastRelease.args[0][1], {options, logger: t.context.logger});
 
   t.is(analyzeCommits.callCount, 1);
+  t.deepEqual(analyzeCommits.args[0][0], config);
   t.deepEqual(analyzeCommits.args[0][1].options, options);
   t.deepEqual(analyzeCommits.args[0][1].logger, t.context.logger);
   t.deepEqual(analyzeCommits.args[0][1].lastRelease, lastRelease);
@@ -79,6 +82,7 @@ test.serial('Plugins are called with expected values', async t => {
   t.deepEqual(analyzeCommits.args[0][1].commits[0].message, commits[0].message);
 
   t.is(verifyRelease.callCount, 1);
+  t.deepEqual(verifyRelease.args[0][0], config);
   t.deepEqual(verifyRelease.args[0][1].options, options);
   t.deepEqual(verifyRelease.args[0][1].logger, t.context.logger);
   t.deepEqual(verifyRelease.args[0][1].lastRelease, lastRelease);
@@ -87,6 +91,7 @@ test.serial('Plugins are called with expected values', async t => {
   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);
@@ -95,6 +100,7 @@ test.serial('Plugins are called with expected values', async t => {
   t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease);
 
   t.is(publish.callCount, 1);
+  t.deepEqual(publish.args[0][0], config);
   t.deepEqual(publish.args[0][1].options, options);
   t.deepEqual(publish.args[0][1].logger, t.context.logger);
   t.deepEqual(publish.args[0][1].lastRelease, lastRelease);
diff --git a/test/integration.test.js b/test/integration.test.js
index 22f7204..e11d736 100644
--- a/test/integration.test.js
+++ b/test/integration.test.js
@@ -402,6 +402,45 @@ test.serial('Exit with 1 if a plugin is not found', async t => {
   t.regex(stderr, /Cannot find module/);
 });
 
+test.serial('Exit with 1 if a shareable config is not found', async t => {
+  const packageName = 'test-config-not-found';
+  const owner = 'test-repo';
+  // Create a git repository, set the current working directory at the root of the repo
+  t.log('Create git repository');
+  await gitRepo();
+  await writeJson('./package.json', {
+    name: packageName,
+    version: '0.0.0-dev',
+    repository: {url: `git+https://github.com/${owner}/${packageName}`},
+    release: {extends: 'non-existing-path'},
+  });
+
+  const {code, stderr} = await t.throws(execa(cli, [], {env}));
+  t.is(code, 1);
+  t.regex(stderr, /Cannot find module/);
+});
+
+test.serial('Exit with 1 if a shareable config reference a not found plugin', async t => {
+  const packageName = 'test-config-ref-not-found';
+  const owner = 'test-repo';
+  const shareable = {getLastRelease: 'non-existing-path'};
+
+  // Create a git repository, set the current working directory at the root of the repo
+  t.log('Create git repository');
+  await gitRepo();
+  await writeJson('./package.json', {
+    name: packageName,
+    version: '0.0.0-dev',
+    repository: {url: `git+https://github.com/${owner}/${packageName}`},
+    release: {extends: './shareable.json'},
+  });
+  await writeJson('./shareable.json', shareable);
+
+  const {code, stderr} = await t.throws(execa(cli, [], {env}));
+  t.is(code, 1);
+  t.regex(stderr, /Cannot find module/);
+});
+
 test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', async t => {
   const packageName = 'test-recovery';
   const owner = 'test-repo';
diff --git a/test/plugins/normalize.test.js b/test/plugins/normalize.test.js
index d85ef46..5f1900c 100644
--- a/test/plugins/normalize.test.js
+++ b/test/plugins/normalize.test.js
@@ -10,27 +10,45 @@ test.beforeEach(t => {
 });
 
 test('Normalize and load plugin from string', t => {
-  const plugin = normalize('verifyConditions', './test/fixtures/plugin-noop', t.context.logger);
+  const plugin = normalize('verifyConditions', {}, {}, './test/fixtures/plugin-noop', t.context.logger);
 
   t.is(typeof plugin, 'function');
   t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'verifyConditions', './test/fixtures/plugin-noop']);
 });
 
 test('Normalize and load plugin from object', t => {
-  const plugin = normalize('publish', {path: './test/fixtures/plugin-noop'}, t.context.logger);
+  const plugin = normalize('publish', {}, {}, {path: './test/fixtures/plugin-noop'}, t.context.logger);
 
   t.is(typeof plugin, 'function');
   t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'publish', './test/fixtures/plugin-noop']);
 });
 
+test('Normalize and load plugin from a base file path', t => {
+  const plugin = normalize(
+    'verifyConditions',
+    {'./plugin-noop': './test/fixtures'},
+    {},
+    './plugin-noop',
+    t.context.logger
+  );
+
+  t.is(typeof plugin, 'function');
+  t.deepEqual(t.context.log.args[0], [
+    'Load plugin %s from %s in shareable config %s',
+    'verifyConditions',
+    './plugin-noop',
+    './test/fixtures',
+  ]);
+});
+
 test('Normalize and load plugin from function', t => {
-  const plugin = normalize('', () => {}, t.context.logger);
+  const plugin = normalize('', {}, {}, () => {}, t.context.logger);
 
   t.is(typeof plugin, 'function');
 });
 
 test('Normalize and load plugin that retuns multiple functions', t => {
-  const plugin = normalize('verifyConditions', './test/fixtures/multi-plugin', t.context.logger);
+  const plugin = normalize('verifyConditions', {}, {}, './test/fixtures/multi-plugin', t.context.logger);
 
   t.is(typeof plugin, 'function');
   t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'verifyConditions', './test/fixtures/multi-plugin']);
@@ -38,7 +56,7 @@ test('Normalize and load plugin that retuns multiple functions', t => {
 
 test('Wrap plugin in a function that validate the output of the plugin', async t => {
   const pluginFunction = stub().resolves(1);
-  const plugin = normalize('', pluginFunction, t.context.logger, {
+  const plugin = normalize('', {}, {}, pluginFunction, t.context.logger, {
     validator: output => output === 1,
     message: 'The output must be 1.',
   });
@@ -50,13 +68,14 @@ test('Wrap plugin in a function that validate the output of the plugin', async t
   t.is(error.message, 'The output must be 1. Received: 2');
 });
 
-test('Plugin is called with "pluginConfig" (omitting "path") and input', async t => {
+test('Plugin is called with "pluginConfig" (omitting "path", adding global config) and input', async t => {
   const pluginFunction = stub().resolves();
   const conf = {path: pluginFunction, conf: 'confValue'};
-  const plugin = normalize('', conf, t.context.logger);
+  const globalConf = {global: 'globalValue'};
+  const plugin = normalize('', {}, globalConf, conf, t.context.logger);
   await plugin('param');
 
-  t.true(pluginFunction.calledWith({conf: 'confValue'}, 'param'));
+  t.true(pluginFunction.calledWith({conf: 'confValue', global: 'globalValue'}, 'param'));
 });
 
 test('Prevent plugins to modify "pluginConfig"', async t => {
@@ -64,10 +83,12 @@ test('Prevent plugins to modify "pluginConfig"', async t => {
     pluginConfig.conf.subConf = 'otherConf';
   });
   const conf = {path: pluginFunction, conf: {subConf: 'originalConf'}};
-  const plugin = normalize('', conf, t.context.logger);
+  const globalConf = {globalConf: {globalSubConf: 'originalGlobalConf'}};
+  const plugin = normalize('', {}, globalConf, conf, t.context.logger);
   await plugin();
 
   t.is(conf.conf.subConf, 'originalConf');
+  t.is(globalConf.globalConf.globalSubConf, 'originalGlobalConf');
 });
 
 test('Prevent plugins to modify its input', async t => {
@@ -75,7 +96,7 @@ test('Prevent plugins to modify its input', async t => {
     options.param.subParam = 'otherParam';
   });
   const input = {param: {subParam: 'originalSubParam'}};
-  const plugin = normalize('', pluginFunction, t.context.logger);
+  const plugin = normalize('', {}, {}, pluginFunction, t.context.logger);
   await plugin(input);
 
   t.is(input.param.subParam, 'originalSubParam');
@@ -89,7 +110,7 @@ test('Return noop if the plugin is not defined', t => {
 
 test('Always pass a defined "pluginConfig" for plugin defined with string', async t => {
   // Call the normalize function with the path of a plugin that returns its config
-  const plugin = normalize('', './test/fixtures/plugin-result-config', t.context.logger);
+  const plugin = normalize('', {}, {}, './test/fixtures/plugin-result-config', t.context.logger);
   const pluginResult = await plugin();
 
   t.deepEqual(pluginResult.pluginConfig, {});
@@ -97,17 +118,26 @@ test('Always pass a defined "pluginConfig" for plugin defined with string', asyn
 
 test('Always pass a defined "pluginConfig" for plugin defined with path', async t => {
   // Call the normalize function with the path of a plugin that returns its config
-  const plugin = normalize('', {path: './test/fixtures/plugin-result-config'}, t.context.logger);
+  const plugin = normalize('', {}, {}, {path: './test/fixtures/plugin-result-config'}, t.context.logger);
   const pluginResult = await plugin();
 
   t.deepEqual(pluginResult.pluginConfig, {});
 });
 
 test('Throws an error if the plugin return an object without the expected plugin function', t => {
-  const error = t.throws(() => normalize('inexistantPlugin', './test/fixtures/multi-plugin', t.context.logger), Error);
+  const error = t.throws(() => normalize('inexistantPlugin', {}, {}, './test/fixtures/multi-plugin', t.context.logger));
 
+  t.is(error.code, 'EPLUGINCONF');
+  t.is(error.name, 'SemanticReleaseError');
   t.is(
     error.message,
     'The inexistantPlugin plugin must be a function, or an object with a function in the property inexistantPlugin.'
   );
 });
+
+test('Throws an error if the plugin is not found', t => {
+  const error = t.throws(() => normalize('inexistantPlugin', {}, {}, 'non-existing-path', t.context.logger), Error);
+
+  t.is(error.message, "Cannot find module 'non-existing-path'");
+  t.is(error.code, 'MODULE_NOT_FOUND');
+});
diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js
index edea9c9..e1c7411 100644
--- a/test/plugins/plugins.test.js
+++ b/test/plugins/plugins.test.js
@@ -1,15 +1,26 @@
+import path from 'path';
 import test from 'ava';
+import {copy, outputFile} from 'fs-extra';
 import {stub} from 'sinon';
+import tempy from 'tempy';
 import getPlugins from '../../lib/plugins';
 
+// Save the current working diretory
+const cwd = process.cwd();
+
 test.beforeEach(t => {
   // Stub the logger functions
   t.context.log = stub();
   t.context.logger = {log: t.context.log};
 });
 
+test.afterEach.always(() => {
+  // Restore the current working directory
+  process.chdir(cwd);
+});
+
 test('Export default plugins', t => {
-  const plugins = getPlugins({}, t.context.logger);
+  const plugins = getPlugins({}, {}, t.context.logger);
 
   // Verify the module returns a function for each plugin
   t.is(typeof plugins.verifyConditions, 'function');
@@ -28,6 +39,62 @@ test('Export plugins based on config', t => {
       analyzeCommits: {path: './test/fixtures/plugin-noop'},
       verifyRelease: () => {},
     },
+    {},
+    t.context.logger
+  );
+
+  // Verify the module returns a function for each plugin
+  t.is(typeof plugins.verifyConditions, 'function');
+  t.is(typeof plugins.getLastRelease, 'function');
+  t.is(typeof plugins.analyzeCommits, 'function');
+  t.is(typeof plugins.verifyRelease, 'function');
+  t.is(typeof plugins.generateNotes, 'function');
+  t.is(typeof plugins.publish, 'function');
+});
+
+test.serial('Export plugins loaded from the dependency of a shareable config module', async t => {
+  const temp = tempy.directory();
+  await copy(
+    './test/fixtures/plugin-noop.js',
+    path.join(temp, 'node_modules/shareable-config/node_modules/custom-plugin/index.js')
+  );
+  await outputFile(path.join(temp, 'node_modules/shareable-config/index.js'), '');
+  process.chdir(temp);
+
+  const plugins = getPlugins(
+    {
+      verifyConditions: ['custom-plugin', {path: 'custom-plugin'}],
+      getLastRelease: 'custom-plugin',
+      analyzeCommits: {path: 'custom-plugin'},
+      verifyRelease: () => {},
+    },
+    {'custom-plugin': 'shareable-config'},
+    t.context.logger
+  );
+
+  // Verify the module returns a function for each plugin
+  t.is(typeof plugins.verifyConditions, 'function');
+  t.is(typeof plugins.getLastRelease, 'function');
+  t.is(typeof plugins.analyzeCommits, 'function');
+  t.is(typeof plugins.verifyRelease, 'function');
+  t.is(typeof plugins.generateNotes, 'function');
+  t.is(typeof plugins.publish, 'function');
+});
+
+test.serial('Export plugins loaded from the dependency of a shareable config file', async t => {
+  const temp = tempy.directory();
+  await copy('./test/fixtures/plugin-noop.js', path.join(temp, 'plugin/plugin-noop.js'));
+  await outputFile(path.join(temp, 'shareable-config.js'), '');
+  process.chdir(temp);
+
+  const plugins = getPlugins(
+    {
+      verifyConditions: ['./plugin/plugin-noop', {path: './plugin/plugin-noop'}],
+      getLastRelease: './plugin/plugin-noop',
+      analyzeCommits: {path: './plugin/plugin-noop'},
+      verifyRelease: () => {},
+    },
+    {'./plugin/plugin-noop': './shareable-config.js'},
     t.context.logger
   );
 
@@ -41,16 +108,34 @@ test('Export plugins based on config', t => {
 });
 
 test('Use default when only options are passed for a single plugin', t => {
-  const plugins = getPlugins({getLastRelease: {}, analyzeCommits: {}}, t.context.logger);
+  const plugins = getPlugins({getLastRelease: {}, analyzeCommits: {}}, {}, t.context.logger);
 
   // Verify the module returns a function for each plugin
   t.is(typeof plugins.getLastRelease, 'function');
   t.is(typeof plugins.analyzeCommits, 'function');
 });
 
+test('Merge global options with plugin options', async t => {
+  const plugins = getPlugins(
+    {
+      globalOpt: 'global',
+      otherOpt: 'globally-defined',
+      getLastRelease: {path: './test/fixtures/plugin-result-config', localOpt: 'local', otherOpt: 'locally-defined'},
+    },
+    {},
+    t.context.logger
+  );
+
+  const result = await plugins.getLastRelease();
+
+  t.deepEqual(result.pluginConfig, {localOpt: 'local', globalOpt: 'global', otherOpt: 'locally-defined'});
+});
+
 test('Throw an error if plugin configuration is missing a path for plugin pipeline', t => {
-  const error = t.throws(() => getPlugins({verifyConditions: {}}, t.context.logger), Error);
+  const error = t.throws(() => getPlugins({verifyConditions: {}}, {}, t.context.logger));
 
+  t.is(error.name, 'SemanticReleaseError');
+  t.is(error.code, 'EPLUGINCONF');
   t.is(
     error.message,
     'The "verifyConditions" 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.'
@@ -58,11 +143,12 @@ test('Throw an error if plugin configuration is missing a path for plugin pipeli
 });
 
 test('Throw an error if an array of plugin configuration is missing a path for plugin pipeline', t => {
-  const error = t.throws(
-    () => getPlugins({verifyConditions: [{path: '@semantic-release/npm'}, {}]}, t.context.logger),
-    Error
+  const error = t.throws(() =>
+    getPlugins({verifyConditions: [{path: '@semantic-release/npm'}, {}]}, {}, t.context.logger)
   );
 
+  t.is(error.name, 'SemanticReleaseError');
+  t.is(error.code, 'EPLUGINCONF');
   t.is(
     error.message,
     'The "verifyConditions" 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.'