feat: add new plugins option

This commit is contained in:
Pierre Vanduynslager 2018-09-25 00:21:42 -04:00
parent 9930dac69e
commit 5ba5010c80
18 changed files with 717 additions and 213 deletions

View File

@ -81,19 +81,19 @@ If you need more control over the timing of releases you have a couple of option
### Release steps ### Release steps
After running the tests the command `semantic-release` will execute the following steps: After running the tests, the command `semantic-release` will execute the following steps:
| Step | Description | | Step | Description |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-------------------|---------------------------------------------------------------------------------------------------------------------------------|
| Verify Conditions | Verify all the conditions to proceed with the release with the [verify conditions plugins](docs/usage/plugins.md#verifyconditions-plugin). | | Verify Conditions | Verify all the conditions to proceed with the release. |
| Get last release | Obtain the commit corresponding to the last release by analyzing [Git tags](https://git-scm.com/book/en/v2/Git-Basics-Tagging). | | Get last release | Obtain the commit corresponding to the last release by analyzing [Git tags](https://git-scm.com/book/en/v2/Git-Basics-Tagging). |
| Analyze commits | Determine the type of release with the [analyze commits plugin](docs/usage/plugins.md#analyzecommits-plugin) based on the commits added since the last release. | | Analyze commits | Determine the type of release based on the commits added since the last release. |
| Verify release | Verify the release conformity with the [verify release plugins](docs/usage/plugins.md#verifyrelease-plugin). | | Verify release | Verify the release conformity. |
| Generate notes | Generate release notes with the [generate notes plugin](docs/usage/plugins.md#generatenotes-plugin) for the commits added since the last release. | | Generate notes | Generate release notes for the commits added since the last release. |
| Create Git tag | Create a Git tag corresponding to the new release version | | Create Git tag | Create a Git tag corresponding to the new release version. |
| Prepare | Prepare the release with the [prepare plugins](docs/usage/plugins.md#prepare-plugin). | | Prepare | Prepare the release. |
| Publish | Publish the release with the [publish plugins](docs/usage/plugins.md#publish-plugin). | | Publish | Publish the release. |
| Notify | Notify of new releases or errors with the [success](docs/usage/plugins.md#success-plugin) and [fail](docs/usage/plugins.md#fail-plugin) plugins. | | Notify | Notify of new releases or errors. |
## Documentation ## Documentation

1
cli.js
View File

@ -22,6 +22,7 @@ Usage:
.option('b', {alias: 'branch', describe: 'Git branch to release from', type: 'string', group: 'Options'}) .option('b', {alias: 'branch', describe: 'Git branch to release from', type: 'string', group: 'Options'})
.option('r', {alias: 'repository-url', describe: 'Git repository URL', type: 'string', group: 'Options'}) .option('r', {alias: 'repository-url', describe: 'Git repository URL', type: 'string', group: 'Options'})
.option('t', {alias: 'tag-format', describe: 'Git tag format', type: 'string', group: 'Options'}) .option('t', {alias: 'tag-format', describe: 'Git tag format', type: 'string', group: 'Options'})
.option('p', {alias: 'plugins', describe: 'Plugins', ...stringList, group: 'Options'})
.option('e', {alias: 'extends', describe: 'Shareable configurations', ...stringList, group: 'Options'}) .option('e', {alias: 'extends', describe: 'Shareable configurations', ...stringList, group: 'Options'})
.option('ci', {describe: 'Toggle CI verifications', type: 'boolean', group: 'Options'}) .option('ci', {describe: 'Toggle CI verifications', type: 'boolean', group: 'Options'})
.option('verify-conditions', {...stringList, group: 'Plugins'}) .option('verify-conditions', {...stringList, group: 'Plugins'})

View File

@ -92,6 +92,18 @@ The [Git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) format used by
**Note**: The `tagFormat` must contain the `version` variable exactly once and compile to a [valid Git reference](https://git-scm.com/docs/git-check-ref-format#_description). **Note**: The `tagFormat` must contain the `version` variable exactly once and compile to a [valid Git reference](https://git-scm.com/docs/git-check-ref-format#_description).
### plugins
Type: `Array`<br>
Default: `['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator', '@semantic-release/npm', '@semantic-release/github']`<br>
CLI arguments: `-p`, `--plugins`
Define the list of plugins to use. Plugins will run in series, in the order defined, for each [steps](../../README.md#release-steps) if they implement it.
Plugins configuration can defined by wrapping the name and an options object in an array.
See [Plugins configuration](plugins.md#configuration) for more details.
### dryRun ### dryRun
Type: `Boolean`<br> Type: `Boolean`<br>

View File

@ -1,114 +1,78 @@
# Plugins # Plugins
Each [release step](../../README.md#release-steps) is implemented within a plugin or a list of plugins that can be configured. This allows for support of different [commit message formats](../../README.md#commit-message-format), release note generators and publishing platforms. Each [release step](../../README.md#release-steps) is implemented by configurable plugins. This allows for support of different [commit message formats](../../README.md#commit-message-format), release note generators and publishing platforms.
See [plugins list](../extending/plugins-list.md). A plugin is a npm module that can implement one or more of the following steps:
## Plugin types | 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. |
### verifyConditions plugin See [available plugins](../extending/plugins-list.md).
Responsible for verifying conditions necessary to proceed with the release: configuration is correct, authentication token are valid, etc... ## Plugins configuration
Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#verifyconditions) and [@semantic-release/github](https://github.com/semantic-release/github#verifyconditions).<br> Each plugin must be installed and configured with the [`plugins` options](./configuration.md#plugins) by specifying the list of plugins by npm module name.
Optional.<br>
Accept multiple plugins.
### analyzeCommits plugin ```bash
$ npm install @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/npm -D
```
Responsible for determining the type of the next release (`major`, `minor` or `patch`).
Default implementation: [@semantic-release/commit-analyzer](https://github.com/semantic-release/commit-analyzer).<br>
Required.<br>
Accept only one plugin.
### verifyRelease plugin
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.<br>
Optional.<br>
Accept multiple plugins.
### generateNotes plugin
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).<br>
Optional.<br>
Accept multiple plugins.
### prepare plugin
Responsible for preparing the release, including:
- Creating or updating files such as `package.json`, `CHANGELOG.md`, documentation or compiled assets.
- Create and push commits
Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#prepare).<br>
Optional.<br>
Accept multiple plugins.
### publish plugin
Responsible for publishing the release.
Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#publish) and [@semantic-release/github](https://github.com/semantic-release/github#publish).<br>
Optional.<br>
Accept multiple plugins.
### success plugin
Responsible for notifying of a new release.
Default implementation: [@semantic-release/github](https://github.com/semantic-release/github#success).<br>
Optional.<br>
Accept multiple plugins.
### fail plugin
Responsible for notifying of a failed release.
Default implementation: [@semantic-release/github](https://github.com/semantic-release/github#fail).<br>
Optional.<br>
Accept multiple plugins.
## Configuration
Plugin can be configured by specifying the plugin's module name or file path directly as a `String` or within the `path` key of an `Object`.
Plugins specific options can be set similarly to the other **semantic-release** [options](configuration.md#options) or within the plugin `Object`. Plugins options defined along with the other **semantic-release** [options](configuration.md#options) will apply to all plugins. Options defined within the plugin `Object` will apply to that specific plugin.
For example:
```json ```json
{ {
"release": { "plugins": ["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/npm"]
"verifyConditions": [ }
{ ```
"path": "@semantic-release/exec",
"cmd": "verify-conditions.sh" ## Plugin ordering
},
"@semantic-release/npm", For each [release step](../../README.md#release-steps) the plugins that implement that step will be executed in the order in which the are defined.
"@semantic-release/github"
], ```json
"analyzeCommits": "custom-plugin", {
"verifyRelease": [ "plugins": [
{ "@semantic-release/commit-analyzer",
"path": "@semantic-release/exec", "@semantic-release/release-notes-generator",
"cmd": "verify-release.sh" "@semantic-release/npm",
}, "@semantic-release/git"
], ]
"generateNotes": "./build/my-plugin.js", }
"githubUrl": "https://my-ghe.com", ```
"githubApiPathPrefix": "/api-prefix"
} With this configuration **semantic-release** will:
- execute the `verifyConditions` implementation of `@semantic-release/npm` then `@semantic-release/git`
- execute the `analyzeCommits` implementation of `@semantic-release/commit-analyzer`
- execute the `prepare` implementation of `@semantic-release/npm` then `@semantic-release/git`
- execute the `generateNotes` implementation of `@semantic-release/release-notes-generator`
- execute the `publish` implementation of `@semantic-release/npm`
## Plugin options
A plugin options can specified by wrapping the name and an options object in an array. Options configured this way will be passed only to that specific plugin.
Global plugin options can defined at the root of the **semantic-release** configuration object. Options configured this way will be passed to all plugins.
```json
{
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
["@semantic-release/github", {
"assets": ["dist/**"]
}],
"@semantic-release/git"
],
"preset": "angular"
} }
``` ```
With this configuration: With this configuration:
- the `custom-plugin` npm module will be used to [analyze commits](#analyzecommits-plugin) - All plugins will receive the `preset` option, which will be used by both `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator` (and ignored by `@semantic-release/github` and `@semantic-release/git`)
- the `./build/my-plugin.js` script will be used to [generate release notes](#generatenotes-plugin) - The `@semantic-release/github` plugin will receive the `assets` options (`@semantic-release/git` will not receive it and therefore will use it's default value for that option)
- the [`@semantic-release/exec`](https://github.com/semantic-release/exec), [`@semantic-release/npm`](https://github.com/semantic-release/npm) and [`@semantic-release/github`](https://github.com/semantic-release/github) plugins will be used to [verify conditions](#verifyconditions-plugin)
- the [`@semantic-release/exec`](https://github.com/semantic-release/exec) plugin will be used to [verify the release](#verifyrelease-plugin)
- the `cmd` option will be set to `verify-conditions.sh` only for the [`@semantic-release/exec`](https://github.com/semantic-release/exec) plugin used to [verify conditions](#verifyconditions-plugin)
- the `cmd` option will be set to `verify-release.sh` only for the [`@semantic-release/exec`](https://github.com/semantic-release/exec) plugin used to [verify the release](#verifyrelease-plugin)
- the `githubUrl` and `githubApiPathPrefix` options will be set to respectively `https://my-ghe.com` and `/api-prefix` for all plugins

View File

@ -61,9 +61,17 @@ Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`
required ? 'is required and ' : '' required ? 'is required and ' : ''
}must be ${ }must be ${
multiple ? 'a single or an array of plugins' : 'a single plugin' multiple ? 'a single or an array of plugins' : 'a single plugin'
} definition. A plugin definition is either a string or an object with a \`path\` property. } 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)}\`.`, Your configuration for the \`${type}\` plugin is \`${stringify(pluginConf)}\`.`,
}),
EPLUGINSCONF: ({plugin}) => ({
message: 'The `plugins` configuration is invalid.',
details: `The [plugins](${linkify(
'docs/usage/configuration.md#plugins'
)}) option must be an array of plugin definions. A plugin definition is an npm module name, optionnaly wrapped in an array with an object.
The invalid configuration is \`${stringify(plugin)}\`.`,
}), }),
EPLUGIN: ({pluginName, type}) => ({ EPLUGIN: ({pluginName, type}) => ({
message: `A plugin configured in the step ${type} is not a valid semantic-release plugin.`, message: `A plugin configured in the step ${type} is not a valid semantic-release plugin.`,

View File

@ -6,13 +6,12 @@ const {RELEASE_TYPE, RELEASE_NOTES_SEPARATOR} = require('./constants');
module.exports = { module.exports = {
verifyConditions: { verifyConditions: {
default: ['@semantic-release/npm', '@semantic-release/github'],
multiple: true, multiple: true,
required: false, required: false,
pipelineConfig: () => ({settleAll: true}), pipelineConfig: () => ({settleAll: true}),
}, },
analyzeCommits: { analyzeCommits: {
default: '@semantic-release/commit-analyzer', default: ['@semantic-release/commit-analyzer'],
multiple: false, multiple: false,
required: true, required: true,
outputValidator: output => !output || RELEASE_TYPE.includes(output), outputValidator: output => !output || RELEASE_TYPE.includes(output),
@ -23,13 +22,11 @@ module.exports = {
postprocess: ([result]) => result, postprocess: ([result]) => result,
}, },
verifyRelease: { verifyRelease: {
default: false,
multiple: true, multiple: true,
required: false, required: false,
pipelineConfig: () => ({settleAll: true}), pipelineConfig: () => ({settleAll: true}),
}, },
generateNotes: { generateNotes: {
default: ['@semantic-release/release-notes-generator'],
multiple: true, multiple: true,
required: false, required: false,
outputValidator: output => !output || isString(output), outputValidator: output => !output || isString(output),
@ -45,7 +42,6 @@ module.exports = {
postprocess: (results, {env}) => hideSensitive(env)(results.filter(Boolean).join(RELEASE_NOTES_SEPARATOR)), postprocess: (results, {env}) => hideSensitive(env)(results.filter(Boolean).join(RELEASE_NOTES_SEPARATOR)),
}, },
prepare: { prepare: {
default: ['@semantic-release/npm'],
multiple: true, multiple: true,
required: false, required: false,
pipelineConfig: ({generateNotes}, logger) => ({ pipelineConfig: ({generateNotes}, logger) => ({
@ -64,7 +60,6 @@ module.exports = {
}), }),
}, },
publish: { publish: {
default: ['@semantic-release/npm', '@semantic-release/github'],
multiple: true, multiple: true,
required: false, required: false,
outputValidator: output => !output || isPlainObject(output), outputValidator: output => !output || isPlainObject(output),
@ -78,14 +73,12 @@ module.exports = {
}), }),
}, },
success: { success: {
default: ['@semantic-release/github'],
multiple: true, multiple: true,
required: false, required: false,
pipelineConfig: () => ({settleAll: true}), pipelineConfig: () => ({settleAll: true}),
preprocess: ({releases, env, ...inputs}) => ({...inputs, env, releases: hideSensitiveValues(env, releases)}), preprocess: ({releases, env, ...inputs}) => ({...inputs, env, releases: hideSensitiveValues(env, releases)}),
}, },
fail: { fail: {
default: ['@semantic-release/github'],
multiple: true, multiple: true,
required: false, required: false,
pipelineConfig: () => ({settleAll: true}), pipelineConfig: () => ({settleAll: true}),

View File

@ -1,4 +1,4 @@
const {castArray, pickBy, isUndefined, isNull, isString, isPlainObject} = require('lodash'); const {castArray, pickBy, isNil, isString, isPlainObject} = require('lodash');
const readPkgUp = require('read-pkg-up'); const readPkgUp = require('read-pkg-up');
const cosmiconfig = require('cosmiconfig'); const cosmiconfig = require('cosmiconfig');
const resolveFrom = require('resolve-from'); const resolveFrom = require('resolve-from');
@ -35,7 +35,7 @@ module.exports = async (context, opts) => {
// For each plugin defined in a shareable config, save in `pluginsPath` the extendable config path, // 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 // so those plugin will be loaded relatively to the config file
Object.entries(extendsOpts).reduce((pluginsPath, [option, value]) => { Object.entries(extendsOpts).reduce((pluginsPath, [option, value]) => {
if (PLUGINS_DEFINITIONS[option]) { if (PLUGINS_DEFINITIONS[option] || option === 'plugins') {
castArray(value) castArray(value)
.filter(plugin => isString(plugin) || (isPlainObject(plugin) && isString(plugin.path))) .filter(plugin => isString(plugin) || (isPlainObject(plugin) && isString(plugin.path)))
.map(plugin => (isString(plugin) ? plugin : plugin.path)) .map(plugin => (isString(plugin) ? plugin : plugin.path))
@ -57,8 +57,14 @@ module.exports = async (context, opts) => {
branch: 'master', branch: 'master',
repositoryUrl: (await pkgRepoUrl({normalize: false, cwd})) || (await repoUrl({cwd, env})), repositoryUrl: (await pkgRepoUrl({normalize: false, cwd})) || (await repoUrl({cwd, env})),
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
'@semantic-release/npm',
'@semantic-release/github',
],
// Remove `null` and `undefined` options so they can be replaced with default ones // Remove `null` and `undefined` options so they can be replaced with default ones
...pickBy(options, option => !isUndefined(option) && !isNull(option)), ...pickBy(options, option => !isNil(option)),
}; };
debug('options values: %O', options); debug('options values: %O', options);

View File

@ -1,5 +1,5 @@
const {parse, format} = require('url'); const {parse, format} = require('url');
const {isUndefined} = require('lodash'); const {isNil} = require('lodash');
const gitUrlParse = require('git-url-parse'); const gitUrlParse = require('git-url-parse');
const hostedGitInfo = require('hosted-git-info'); const hostedGitInfo = require('hosted-git-info');
const {verifyAuth} = require('./git'); const {verifyAuth} = require('./git');
@ -44,7 +44,7 @@ module.exports = async ({cwd, env, options: {repositoryUrl, branch}}) => {
try { try {
await verifyAuth(repositoryUrl, branch, {cwd, env}); await verifyAuth(repositoryUrl, branch, {cwd, env});
} catch (error) { } catch (error) {
const envVar = Object.keys(GIT_TOKENS).find(envVar => !isUndefined(env[envVar])); const envVar = Object.keys(GIT_TOKENS).find(envVar => !isNil(env[envVar]));
const gitCredentials = `${GIT_TOKENS[envVar] || ''}${env[envVar] || ''}`; const gitCredentials = `${GIT_TOKENS[envVar] || ''}${env[envVar] || ''}`;
const {protocols, ...parsed} = gitUrlParse(repositoryUrl); const {protocols, ...parsed} = gitUrlParse(repositoryUrl);
const protocol = protocols.includes('https') ? 'https' : protocols.includes('http') ? 'http' : 'https'; const protocol = protocols.includes('https') ? 'https' : protocols.includes('http') ? 'http' : 'https';

View File

@ -1,52 +1,89 @@
const {identity, isPlainObject, omit, castArray, isUndefined} = require('lodash'); const {identity, isPlainObject, omit, castArray, isNil, isString} = require('lodash');
const AggregateError = require('aggregate-error'); const AggregateError = require('aggregate-error');
const getError = require('../get-error'); const getError = require('../get-error');
const PLUGINS_DEFINITIONS = require('../definitions/plugins'); const PLUGINS_DEFINITIONS = require('../definitions/plugins');
const {validateConfig} = require('./utils'); const {validatePlugin, validateStep, loadPlugin, parseConfig} = require('./utils');
const pipeline = require('./pipeline'); const pipeline = require('./pipeline');
const normalize = require('./normalize'); const normalize = require('./normalize');
module.exports = (context, pluginsPath) => { module.exports = (context, pluginsPath) => {
const {options, logger} = context; let {options, logger} = context;
const errors = []; const errors = [];
const plugins = Object.entries(PLUGINS_DEFINITIONS).reduce(
const plugins = options.plugins
? castArray(options.plugins).reduce((plugins, plugin) => {
if (validatePlugin(plugin)) {
const [name, config] = parseConfig(plugin);
plugin = isString(name) ? loadPlugin(context, name, pluginsPath) : name;
if (isPlainObject(plugin)) {
Object.entries(plugin).forEach(([type, func]) => {
if (PLUGINS_DEFINITIONS[type]) {
plugins[type] = [...(plugins[type] || []), [func, config]];
}
});
} else {
errors.push(getError('EPLUGINSCONF', {plugin}));
}
} else {
errors.push(getError('EPLUGINSCONF', {plugin}));
}
return plugins;
}, {})
: [];
if (errors.length > 0) {
throw new AggregateError(errors);
}
options = {...plugins, ...options};
const pluginsConf = Object.entries(PLUGINS_DEFINITIONS).reduce(
( (
plugins, pluginsConf,
[type, {multiple, required, default: def, pipelineConfig, postprocess = identity, preprocess = identity}] [type, {multiple, required, default: def, pipelineConfig, postprocess = identity, preprocess = identity}]
) => { ) => {
let pluginOpts; let pluginOpts;
if (isUndefined(options[type])) { if (isNil(options[type]) && def) {
pluginOpts = def; pluginOpts = def;
} else { } else {
const defaultPaths = castArray(def); // If an object is passed and the path is missing, merge it with step options
// If an object is passed and the path is missing, set the default one for single plugins if (isPlainObject(options[type]) && !options[type].path) {
if (isPlainObject(options[type]) && !options[type].path && defaultPaths.length === 1) { options[type] = castArray(plugins[type]).map(
[options[type].path] = defaultPaths; plugin => (plugin ? [plugin[0], Object.assign(plugin[1], options[type])] : plugin)
);
} }
if (!validateConfig({multiple, required}, options[type])) { if (!validateStep({multiple, required}, options[type])) {
errors.push(getError('EPLUGINCONF', {type, multiple, required, pluginConf: options[type]})); errors.push(getError('EPLUGINCONF', {type, multiple, required, pluginConf: options[type]}));
return plugins; return pluginsConf;
} }
pluginOpts = options[type]; pluginOpts = options[type];
} }
const steps = castArray(pluginOpts).map(pluginOpt => const steps = castArray(pluginOpts).map(pluginOpt =>
normalize({...context, options: omit(options, Object.keys(PLUGINS_DEFINITIONS))}, type, pluginOpt, pluginsPath) normalize(
{...context, options: omit(options, Object.keys(PLUGINS_DEFINITIONS), 'plugins')},
type,
pluginOpt,
pluginsPath
)
); );
plugins[type] = async input => pluginsConf[type] = async input =>
postprocess( postprocess(
await pipeline(steps, pipelineConfig && pipelineConfig(plugins, logger))(await preprocess(input)), await pipeline(steps, pipelineConfig && pipelineConfig(pluginsConf, logger))(await preprocess(input)),
input input
); );
return plugins; return pluginsConf;
}, },
{} plugins
); );
if (errors.length > 0) { if (errors.length > 0) {
throw new AggregateError(errors); throw new AggregateError(errors);
} }
return plugins;
return pluginsConf;
}; };

View File

@ -1,22 +1,18 @@
const {dirname} = require('path'); const {isPlainObject, isFunction, noop, cloneDeep, omit} = require('lodash');
const {isString, isPlainObject, isFunction, noop, cloneDeep, omit} = require('lodash');
const resolveFrom = require('resolve-from');
const getError = require('../get-error'); const getError = require('../get-error');
const {extractErrors} = require('../utils'); const {extractErrors} = require('../utils');
const PLUGINS_DEFINITIONS = require('../definitions/plugins'); const PLUGINS_DEFINITIONS = require('../definitions/plugins');
const {loadPlugin, parseConfig} = require('./utils');
module.exports = ({cwd, stdout, stderr, options, logger}, type, pluginOpt, pluginsPath) => { module.exports = (context, type, pluginOpt, pluginsPath) => {
const {stdout, stderr, options, logger} = context;
if (!pluginOpt) { if (!pluginOpt) {
return noop; return noop;
} }
const {path, ...config} = isString(pluginOpt) || isFunction(pluginOpt) ? {path: pluginOpt} : pluginOpt; const [path, config] = parseConfig(pluginOpt);
const pluginName = isFunction(path) ? `[Function: ${path.name}]` : path; const pluginName = isFunction(path) ? `[Function: ${path.name}]` : path;
const plugin = loadPlugin(context, path, pluginsPath);
const basePath = pluginsPath[path]
? dirname(resolveFrom.silent(__dirname, pluginsPath[path]) || resolveFrom(cwd, pluginsPath[path]))
: __dirname;
const plugin = isFunction(path) ? path : require(resolveFrom.silent(basePath, path) || resolveFrom(cwd, path));
let func; let func;
if (isFunction(plugin)) { if (isFunction(plugin)) {

View File

@ -1,18 +1,68 @@
const {isString, isFunction, castArray} = require('lodash'); const {dirname} = require('path');
const {isString, isFunction, castArray, isArray, isPlainObject, isNil} = require('lodash');
const resolveFrom = require('resolve-from');
const validateSingleConfig = conf => { 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); conf = castArray(conf);
return conf.length === 1 && (isString(conf[0]) || isString(conf[0].path) || isFunction(conf[0]));
if (conf.length !== 1) {
return false;
}
const [path, config] = parseConfig(conf[0]);
return (isString(path) || isFunction(path)) && isPlainObject(config);
}; };
const validateMultipleConfig = conf => castArray(conf).every(conf => validateSingleConfig(conf)); const validateMultipleStep = conf => {
return conf.every(conf => validateSingleStep(conf));
};
const validateConfig = ({multiple, required}, conf) => { function validatePlugin(conf) {
return (
isString(conf) ||
(isArray(conf) &&
(conf.length === 1 || conf.length === 2) &&
(isString(conf[0]) || isPlainObject(conf[0])) &&
(isNil(conf[1]) || isPlainObject(conf[1]))) ||
(isPlainObject(conf) && (isNil(conf.path) || isString(conf.path) || isPlainObject(conf.path)))
);
}
function validateStep({multiple, required}, conf) {
conf = castArray(conf).filter(Boolean); conf = castArray(conf).filter(Boolean);
if (required) { if (required) {
return Boolean(conf) && conf.length >= 1 && (multiple ? validateMultipleConfig : validateSingleConfig)(conf); return conf.length >= 1 && (multiple ? validateMultipleStep : validateSingleStep)(conf);
} }
return conf.length === 0 || (multiple ? validateMultipleConfig : validateSingleConfig)(conf); return conf.length === 0 || (multiple ? validateMultipleStep : validateSingleStep)(conf);
}; }
module.exports = {validateConfig}; function loadPlugin({cwd}, path, pluginsPath) {
const basePath = pluginsPath[path]
? dirname(resolveFrom.silent(__dirname, pluginsPath[path]) || resolveFrom(cwd, pluginsPath[path]))
: __dirname;
return isFunction(path) ? path : require(resolveFrom.silent(basePath, path) || resolveFrom(cwd, path));
}
function parseConfig(plugin) {
let path;
let config;
if (isArray(plugin)) {
[path, config] = plugin;
} else if (isPlainObject(plugin) && !isNil(plugin.path)) {
({path, ...config} = plugin);
} else {
path = plugin;
}
return [path, config || {}];
}
module.exports = {validatePlugin, validateStep, loadPlugin, parseConfig};

View File

@ -19,11 +19,11 @@
"Pierre Vanduynslager (https://twitter.com/@pvdlg_)" "Pierre Vanduynslager (https://twitter.com/@pvdlg_)"
], ],
"dependencies": { "dependencies": {
"@semantic-release/commit-analyzer": "^6.0.0", "@semantic-release/commit-analyzer": "^6.1.0",
"@semantic-release/error": "^2.2.0", "@semantic-release/error": "^2.2.0",
"@semantic-release/github": "^5.0.0", "@semantic-release/github": "^5.1.0",
"@semantic-release/npm": "^5.0.1", "@semantic-release/npm": "^5.0.5",
"@semantic-release/release-notes-generator": "^7.0.0", "@semantic-release/release-notes-generator": "^7.1.0",
"aggregate-error": "^1.0.0", "aggregate-error": "^1.0.0",
"cosmiconfig": "^5.0.1", "cosmiconfig": "^5.0.1",
"debug": "^4.0.0", "debug": "^4.0.0",

View File

@ -33,6 +33,9 @@ test.serial('Pass options to semantic-release API', async t => {
'https://github/com/owner/repo.git', 'https://github/com/owner/repo.git',
'-t', '-t',
`v\${version}`, `v\${version}`,
'-p',
'plugin1',
'plugin2',
'-e', '-e',
'config1', 'config1',
'config2', 'config2',
@ -68,6 +71,7 @@ test.serial('Pass options to semantic-release API', async t => {
t.is(run.args[0][0].branch, 'master'); t.is(run.args[0][0].branch, 'master');
t.is(run.args[0][0].repositoryUrl, 'https://github/com/owner/repo.git'); t.is(run.args[0][0].repositoryUrl, 'https://github/com/owner/repo.git');
t.is(run.args[0][0].tagFormat, `v\${version}`); t.is(run.args[0][0].tagFormat, `v\${version}`);
t.deepEqual(run.args[0][0].plugins, ['plugin1', 'plugin2']);
t.deepEqual(run.args[0][0].extends, ['config1', 'config2']); t.deepEqual(run.args[0][0].extends, ['config1', 'config2']);
t.deepEqual(run.args[0][0].verifyConditions, ['condition1', 'condition2']); t.deepEqual(run.args[0][0].verifyConditions, ['condition1', 'condition2']);
t.is(run.args[0][0].analyzeCommits, 'analyze'); t.is(run.args[0][0].analyzeCommits, 'analyze');
@ -94,6 +98,9 @@ test.serial('Pass options to semantic-release API with alias arguments', async t
'https://github/com/owner/repo.git', 'https://github/com/owner/repo.git',
'--tag-format', '--tag-format',
`v\${version}`, `v\${version}`,
'--plugins',
'plugin1',
'plugin2',
'--extends', '--extends',
'config1', 'config1',
'config2', 'config2',
@ -106,6 +113,7 @@ test.serial('Pass options to semantic-release API with alias arguments', async t
t.is(run.args[0][0].branch, 'master'); t.is(run.args[0][0].branch, 'master');
t.is(run.args[0][0].repositoryUrl, 'https://github/com/owner/repo.git'); t.is(run.args[0][0].repositoryUrl, 'https://github/com/owner/repo.git');
t.is(run.args[0][0].tagFormat, `v\${version}`); t.is(run.args[0][0].tagFormat, `v\${version}`);
t.deepEqual(run.args[0][0].plugins, ['plugin1', 'plugin2']);
t.deepEqual(run.args[0][0].extends, ['config1', 'config2']); t.deepEqual(run.args[0][0].extends, ['config1', 'config2']);
t.is(run.args[0][0].dryRun, true); t.is(run.args[0][0].dryRun, true);
@ -151,6 +159,8 @@ test.serial('Do not set properties in option for which arg is not in command lin
t.false('debug' in run.args[0][0]); t.false('debug' in run.args[0][0]);
t.false('r' in run.args[0][0]); t.false('r' in run.args[0][0]);
t.false('t' in run.args[0][0]); t.false('t' in run.args[0][0]);
t.false('p' in run.args[0][0]);
t.false('e' in run.args[0][0]);
}); });
test.serial('Set "noCi" options to "true" with "--no-ci"', async t => { test.serial('Set "noCi" options to "true" with "--no-ci"', async t => {

View File

@ -8,6 +8,13 @@ import {stub} from 'sinon';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import {gitRepo, gitCommits, gitShallowClone, gitAddConfig} from './helpers/git-utils'; import {gitRepo, gitCommits, gitShallowClone, gitAddConfig} from './helpers/git-utils';
const DEFAULT_PLUGINS = [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
'@semantic-release/npm',
'@semantic-release/github',
];
test.beforeEach(t => { test.beforeEach(t => {
t.context.plugins = stub().returns({}); t.context.plugins = stub().returns({});
t.context.getConfig = proxyquire('../lib/get-config', {'./plugins': t.context.plugins}); t.context.getConfig = proxyquire('../lib/get-config', {'./plugins': t.context.plugins});
@ -69,6 +76,7 @@ test('Read options from package.json', async t => {
branch: 'test_branch', branch: 'test_branch',
repositoryUrl: 'https://host.null/owner/module.git', repositoryUrl: 'https://host.null/owner/module.git',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json in repository root // Create package.json in repository root
await outputJson(path.resolve(cwd, 'package.json'), {release: options}); await outputJson(path.resolve(cwd, 'package.json'), {release: options});
@ -89,6 +97,7 @@ test('Read options from .releaserc.yml', async t => {
branch: 'test_branch', branch: 'test_branch',
repositoryUrl: 'https://host.null/owner/module.git', repositoryUrl: 'https://host.null/owner/module.git',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json in repository root // Create package.json in repository root
await writeFile(path.resolve(cwd, '.releaserc.yml'), yaml.safeDump(options)); await writeFile(path.resolve(cwd, '.releaserc.yml'), yaml.safeDump(options));
@ -109,6 +118,7 @@ test('Read options from .releaserc.json', async t => {
branch: 'test_branch', branch: 'test_branch',
repositoryUrl: 'https://host.null/owner/module.git', repositoryUrl: 'https://host.null/owner/module.git',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json in repository root // Create package.json in repository root
await outputJson(path.resolve(cwd, '.releaserc.json'), options); await outputJson(path.resolve(cwd, '.releaserc.json'), options);
@ -129,6 +139,7 @@ test('Read options from .releaserc.js', async t => {
branch: 'test_branch', branch: 'test_branch',
repositoryUrl: 'https://host.null/owner/module.git', repositoryUrl: 'https://host.null/owner/module.git',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json in repository root // Create package.json in repository root
await writeFile(path.resolve(cwd, '.releaserc.js'), `module.exports = ${JSON.stringify(options)}`); await writeFile(path.resolve(cwd, '.releaserc.js'), `module.exports = ${JSON.stringify(options)}`);
@ -149,6 +160,7 @@ test('Read options from release.config.js', async t => {
branch: 'test_branch', branch: 'test_branch',
repositoryUrl: 'https://host.null/owner/module.git', repositoryUrl: 'https://host.null/owner/module.git',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json in repository root // Create package.json in repository root
await writeFile(path.resolve(cwd, 'release.config.js'), `module.exports = ${JSON.stringify(options)}`); await writeFile(path.resolve(cwd, 'release.config.js'), `module.exports = ${JSON.stringify(options)}`);
@ -176,6 +188,7 @@ test('Prioritise CLI/API parameters over file configuration and git repo', async
branch: 'branch_cli', branch: 'branch_cli',
repositoryUrl: 'http://cli-url.com/owner/package', repositoryUrl: 'http://cli-url.com/owner/package',
tagFormat: `cli\${version}`, tagFormat: `cli\${version}`,
plugins: false,
}; };
const pkg = {release: pkgOptions, repository: 'git@host.null:owner/module.git'}; const pkg = {release: pkgOptions, repository: 'git@host.null:owner/module.git'};
// Create package.json in repository root // Create package.json in repository root
@ -199,6 +212,7 @@ test('Read configuration from file path in "extends"', async t => {
branch: 'test_branch', branch: 'test_branch',
repositoryUrl: 'https://host.null/owner/module.git', repositoryUrl: 'https://host.null/owner/module.git',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json and shareable.json in repository root // Create package.json and shareable.json in repository root
await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions});
@ -226,6 +240,7 @@ test('Read configuration from module path in "extends"', async t => {
branch: 'test_branch', branch: 'test_branch',
repositoryUrl: 'https://host.null/owner/module.git', repositoryUrl: 'https://host.null/owner/module.git',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json and shareable.json in repository root // Create package.json and shareable.json in repository root
await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions});
@ -259,6 +274,7 @@ test('Read configuration from an array of paths in "extends"', async t => {
analyzeCommits: {path: 'analyzeCommits2', param: 'analyzeCommits_param2'}, analyzeCommits: {path: 'analyzeCommits2', param: 'analyzeCommits_param2'},
branch: 'test_branch', branch: 'test_branch',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json and shareable.json in repository root // Create package.json and shareable.json in repository root
await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions});
@ -296,6 +312,7 @@ test('Prioritize configuration from config file over "extends"', async t => {
branch: 'test_branch', branch: 'test_branch',
repositoryUrl: 'https://host.null/owner/module.git', repositoryUrl: 'https://host.null/owner/module.git',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json and shareable.json in repository root // Create package.json and shareable.json in repository root
await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions});
@ -341,6 +358,7 @@ test('Prioritize configuration from cli/API options over "extends"', async t =>
publish: [{path: 'publishShareable', param: 'publishShareable_param2'}], publish: [{path: 'publishShareable', param: 'publishShareable_param2'}],
branch: 'test_branch2', branch: 'test_branch2',
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json, shareable1.json and shareable2.json in repository root // Create package.json, shareable1.json and shareable2.json in repository root
await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions});
@ -366,11 +384,13 @@ test('Allow to unset properties defined in shareable config with "null"', async
analyzeCommits: null, analyzeCommits: null,
branch: 'test_branch', branch: 'test_branch',
repositoryUrl: 'https://host.null/owner/module.git', repositoryUrl: 'https://host.null/owner/module.git',
plugins: null,
}; };
const options1 = { const options1 = {
generateNotes: 'generateNotes', generateNotes: 'generateNotes',
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: ['test-plugin'],
}; };
// Create package.json and shareable.json in repository root // Create package.json and shareable.json in repository root
await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions});
@ -378,19 +398,25 @@ test('Allow to unset properties defined in shareable config with "null"', async
const {options} = await t.context.getConfig({cwd}); const {options} = await t.context.getConfig({cwd});
// Verify the options contains the plugin config from shareable.json // Verify the options contains the plugin config from shareable.json and the default `plugins`
t.deepEqual(options, {...omit(options1, 'analyzeCommits'), ...omit(pkgOptions, ['extends', 'analyzeCommits'])}); t.deepEqual(options, {
// Verify the plugins module is called with the plugin options from shareable.json ...omit(options1, ['analyzeCommits']),
...omit(pkgOptions, ['extends', 'analyzeCommits']),
plugins: DEFAULT_PLUGINS,
});
// Verify the plugins module is called with the plugin options from shareable.json and the default `plugins`
t.deepEqual(t.context.plugins.args[0][0], { t.deepEqual(t.context.plugins.args[0][0], {
options: { options: {
...omit(options1, 'analyzeCommits'), ...omit(options1, 'analyzeCommits'),
...omit(pkgOptions, ['extends', 'analyzeCommits']), ...omit(pkgOptions, ['extends', 'analyzeCommits']),
plugins: DEFAULT_PLUGINS,
}, },
cwd, cwd,
}); });
t.deepEqual(t.context.plugins.args[0][1], { t.deepEqual(t.context.plugins.args[0][1], {
generateNotes: './shareable.json', generateNotes: './shareable.json',
analyzeCommits: './shareable.json', analyzeCommits: './shareable.json',
'test-plugin': './shareable.json',
}); });
}); });
@ -407,6 +433,7 @@ test('Allow to unset properties defined in shareable config with "undefined"', a
generateNotes: 'generateNotes', generateNotes: 'generateNotes',
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
tagFormat: `v\${version}`, tagFormat: `v\${version}`,
plugins: false,
}; };
// Create package.json and release.config.js in repository root // Create package.json and release.config.js in repository root
await writeFile(path.resolve(cwd, 'release.config.js'), `module.exports = ${format(pkgOptions)}`); await writeFile(path.resolve(cwd, 'release.config.js'), `module.exports = ${format(pkgOptions)}`);

View File

@ -64,6 +64,7 @@ test('Plugins are called with expected values', async t => {
const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `v\${version}`}; const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `v\${version}`};
const options = { const options = {
...config, ...config,
plugins: false,
verifyConditions: [verifyConditions1, verifyConditions2], verifyConditions: [verifyConditions1, verifyConditions2],
analyzeCommits, analyzeCommits,
verifyRelease, verifyRelease,
@ -359,6 +360,7 @@ test('Log all "verifyConditions" errors', async t => {
const config = {branch: 'master', repositoryUrl, tagFormat: `v\${version}`}; const config = {branch: 'master', repositoryUrl, tagFormat: `v\${version}`};
const options = { const options = {
...config, ...config,
plugins: false,
verifyConditions: [stub().rejects(new AggregateError([error1, error2])), stub().rejects(error3)], verifyConditions: [stub().rejects(new AggregateError([error1, error2])), stub().rejects(error3)],
fail, fail,
}; };

View File

@ -152,7 +152,7 @@ test('Wrap "publish" plugin in a function that validate the output of the plugin
t.regex(error.details, /2/); t.regex(error.details, /2/);
}); });
test('Plugin is called with "pluginConfig" (omitting "path", adding global config) and input', async t => { test('Plugin is called with "pluginConfig" (with object definition) and input', async t => {
const pluginFunction = stub().resolves(); const pluginFunction = stub().resolves();
const pluginConf = {path: pluginFunction, conf: 'confValue'}; const pluginConf = {path: pluginFunction, conf: 'confValue'};
const options = {global: 'globalValue'}; const options = {global: 'globalValue'};
@ -167,6 +167,21 @@ test('Plugin is called with "pluginConfig" (omitting "path", adding global confi
); );
}); });
test('Plugin is called with "pluginConfig" (with array definition) and input', async t => {
const pluginFunction = stub().resolves();
const pluginConf = [pluginFunction, {conf: 'confValue'}];
const options = {global: 'globalValue'};
const plugin = normalize({cwd, options, logger: t.context.logger}, '', pluginConf, {});
await plugin({param: 'param'});
t.true(
pluginFunction.calledWithMatch(
{conf: 'confValue', global: 'globalValue'},
{param: 'param', logger: t.context.logger}
)
);
});
test('Prevent plugins to modify "pluginConfig"', async t => { test('Prevent plugins to modify "pluginConfig"', async t => {
const pluginFunction = stub().callsFake(pluginConfig => { const pluginFunction = stub().callsFake(pluginConfig => {
pluginConfig.conf.subConf = 'otherConf'; pluginConfig.conf.subConf = 'otherConf';

View File

@ -29,7 +29,7 @@ test('Export default plugins', t => {
t.is(typeof plugins.fail, 'function'); t.is(typeof plugins.fail, 'function');
}); });
test('Export plugins based on config', t => { test('Export plugins based on steps config', t => {
const plugins = getPlugins( const plugins = getPlugins(
{ {
cwd, cwd,
@ -55,6 +55,88 @@ test('Export plugins based on config', t => {
t.is(typeof plugins.fail, 'function'); t.is(typeof plugins.fail, 'function');
}); });
test('Export plugins based on "plugins" config (array)', async t => {
const plugin1 = {verifyConditions: stub(), publish: stub()};
const plugin2 = {verifyConditions: stub(), verifyRelease: stub()};
const plugins = getPlugins(
{cwd, logger: t.context.logger, options: {plugins: [plugin1, plugin2], verifyRelease: () => {}}},
{}
);
await plugins.verifyConditions({});
t.true(plugin1.verifyConditions.calledOnce);
t.true(plugin2.verifyConditions.calledOnce);
await plugins.publish({});
t.true(plugin1.publish.calledOnce);
await plugins.verifyRelease({});
t.true(plugin2.verifyRelease.notCalled);
// 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('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({});
t.true(plugin1.verifyConditions.calledOnce);
await plugins.publish({});
t.true(plugin1.publish.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 sptep options', async t => {
const plugin1 = [{verifyConditions: stub(), publish: stub()}, {pluginOpt1: 'plugin1'}];
const plugin2 = [{verifyConditions: stub()}, {pluginOpt2: 'plugin2'}];
const plugin3 = [stub(), {pluginOpt3: 'plugin3'}];
const plugins = getPlugins(
{
cwd,
logger: t.context.logger,
options: {globalOpt: 'global', plugins: [plugin1, plugin2], verifyRelease: [plugin3]},
},
{}
);
await plugins.verifyConditions({});
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({});
t.deepEqual(plugin1[0].publish.args[0][0], {globalOpt: 'global', pluginOpt1: 'plugin1'});
await plugins.verifyRelease({});
t.deepEqual(plugin3[0].args[0][0], {globalOpt: 'global', pluginOpt3: 'plugin3'});
});
test('Unknown steps of plugins configured in "plugins" are ignored', t => {
const plugin1 = {verifyConditions: () => {}, unknown: () => {}};
const plugins = getPlugins({cwd, logger: t.context.logger, options: {plugins: [plugin1]}}, {});
t.is(typeof plugins.verifyConditions, 'function');
t.is(plugins.unknown, undefined);
});
test('Export plugins loaded from the dependency of a shareable config module', async t => { test('Export plugins loaded from the dependency of a shareable config module', async t => {
const cwd = tempy.directory(); const cwd = tempy.directory();
await copy( await copy(
@ -121,11 +203,23 @@ test('Export plugins loaded from the dependency of a shareable config file', asy
test('Use default when only options are passed for a single plugin', t => { test('Use default when only options are passed for a single plugin', t => {
const analyzeCommits = {}; const analyzeCommits = {};
const generateNotes = {}; const generateNotes = {};
const publish = {};
const success = () => {}; const success = () => {};
const fail = [() => {}]; const fail = [() => {}];
const plugins = getPlugins( const plugins = getPlugins(
{cwd, logger: t.context.logger, options: {analyzeCommits, generateNotes, success, fail}}, {
cwd,
logger: t.context.logger,
options: {
plugins: ['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator'],
analyzeCommits,
generateNotes,
publish,
success,
fail,
},
},
{} {}
); );
@ -159,14 +253,20 @@ test('Merge global options with plugin options', async t => {
t.deepEqual(result.pluginConfig, {localOpt: 'local', globalOpt: 'global', otherOpt: 'locally-defined'}); t.deepEqual(result.pluginConfig, {localOpt: 'local', globalOpt: 'global', otherOpt: 'locally-defined'});
}); });
test('Throw an error if plugins configuration are invalid', t => { test('Throw an error for each invalid plugin configuration', t => {
const errors = [ const errors = [
...t.throws(() => ...t.throws(() =>
getPlugins( getPlugins(
{ {
cwd, cwd,
logger: t.context.logger, logger: t.context.logger,
options: {verifyConditions: {}, analyzeCommits: [], verifyRelease: [{}], generateNotes: [{path: null}]}, options: {
plugins: ['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator'],
verifyConditions: 1,
analyzeCommits: [],
verifyRelease: [{}],
generateNotes: [{path: null}],
},
}, },
{} {}
) )
@ -182,3 +282,38 @@ test('Throw an error if plugins configuration are invalid', t => {
t.is(errors[3].name, 'SemanticReleaseError'); t.is(errors[3].name, 'SemanticReleaseError');
t.is(errors[3].code, 'EPLUGINCONF'); t.is(errors[3].code, 'EPLUGINCONF');
}); });
test('Throw EPLUGINSCONF error if the "plugins" option contains an old plugin definition (returns a function)', t => {
const errors = [
...t.throws(() =>
getPlugins(
{
cwd,
logger: t.context.logger,
options: {plugins: ['./test/fixtures/multi-plugin', './test/fixtures/plugin-noop', () => {}]},
},
{}
)
),
];
t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].code, 'EPLUGINSCONF');
t.is(errors[1].name, 'SemanticReleaseError');
t.is(errors[1].code, 'EPLUGINSCONF');
});
test('Throw EPLUGINSCONF error for each invalid definition if the "plugins" option', t => {
const errors = [
...t.throws(() =>
getPlugins({cwd, logger: t.context.logger, options: {plugins: [1, {path: 1}, [() => {}, {}, {}]]}}, {})
),
];
t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].code, 'EPLUGINSCONF');
t.is(errors[1].name, 'SemanticReleaseError');
t.is(errors[1].code, 'EPLUGINSCONF');
t.is(errors[2].name, 'SemanticReleaseError');
t.is(errors[2].code, 'EPLUGINSCONF');
});

View File

@ -1,58 +1,306 @@
import test from 'ava'; import test from 'ava';
import {validateConfig} from '../../lib/plugins/utils'; import {validatePlugin, validateStep, loadPlugin, parseConfig} from '../../lib/plugins/utils';
test('Validate multiple/optional plugin configuration', t => { test('validatePlugin', t => {
const path = 'plugin-module';
const options = {option1: 'value1', option2: 'value2'};
t.true(validatePlugin(path), 'String definition');
t.true(validatePlugin({publish: () => {}}), 'Object definition');
t.true(validatePlugin([path]), 'Array definition');
t.true(validatePlugin([path, options]), 'Array definition with options');
t.true(validatePlugin([{publish: () => {}}, options]), 'Array definition with options and path as object');
t.true(validatePlugin({path}), 'Object with path definition');
t.true(validatePlugin({path, ...options}), 'Object with path definition with options');
t.true(
validatePlugin({path: {publish: () => {}}, ...options}),
'Object with path definition with options and path as object'
);
t.false(validatePlugin(1), 'String definition, wrong path');
t.false(validatePlugin([]), 'Array definition, missing path');
t.false(validatePlugin([path, options, {}]), 'Array definition, additional parameter');
t.false(validatePlugin([1]), 'Array definition, wrong path');
t.false(validatePlugin([path, 1]), 'Array definition, wrong options');
t.false(validatePlugin({path: 1}), 'Object definition, wrong path');
});
test('validateStep: multiple/optional plugin configuration', t => {
const type = {multiple: true, required: false}; const type = {multiple: true, required: false};
t.false(validateConfig(type, {}));
t.false(validateConfig(type, {path: null}));
t.true(validateConfig(type, {path: 'plugin-path.js'})); // Empty config
t.true(validateConfig(type)); t.true(validateStep(type));
t.true(validateConfig(type, 'plugin-path.js')); t.true(validateStep(type, []));
t.true(validateConfig(type, ['plugin-path.js']));
t.true(validateConfig(type, () => {})); // Single value definition
t.true(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); 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 an Array of 2 definitions and not as one Array definition in case of a muliple plugin type
t.false(validateStep(type, [() => {}, {options: 'value'}]));
t.false(validateStep(type, ['plugin-path.js', {options: 'value'}]));
// Multiple definitions
t.true(
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'},
])
);
t.false(
validateStep(type, [
'plugin-path.js',
() => {},
['plugin-path.js'],
['plugin-path.js', 1],
[() => {}, {options: 'value'}],
{path: 'plugin-path.js'},
{path: 'plugin-path.js', options: 'value'},
{path: () => {}, options: 'value'},
])
);
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'},
])
);
t.false(
validateStep(type, [
'plugin-path.js',
() => {},
['plugin-path.js'],
['plugin-path.js', {options: 'value'}],
[() => {}, {options: 'value'}],
{path: null},
{path: 'plugin-path.js', options: 'value'},
{path: () => {}, options: 'value'},
])
);
}); });
test('Validate multiple/required plugin configuration', t => { test('validateStep: multiple/required plugin configuration', t => {
const type = {multiple: true, required: true}; const type = {multiple: true, required: true};
t.false(validateConfig(type, {}));
t.false(validateConfig(type, {path: null}));
t.false(validateConfig(type));
t.true(validateConfig(type, {path: 'plugin-path.js'})); // Empty config
t.true(validateConfig(type, 'plugin-path.js')); t.false(validateStep(type));
t.true(validateConfig(type, ['plugin-path.js'])); t.false(validateStep(type, []));
t.true(validateConfig(type, () => {}));
t.true(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); // 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 an Array of 2 definitions and not as one Array definition in the case of a muliple plugin type
t.false(validateStep(type, [() => {}, {options: 'value'}]));
t.false(validateStep(type, ['plugin-path.js', {options: 'value'}]));
// Multiple definitions
t.true(
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'},
])
);
t.false(
validateStep(type, [
'plugin-path.js',
() => {},
['plugin-path.js'],
['plugin-path.js', 1],
[() => {}, {options: 'value'}],
{path: 'plugin-path.js'},
{path: 'plugin-path.js', options: 'value'},
{path: () => {}, options: 'value'},
])
);
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'},
])
);
t.false(
validateStep(type, [
'plugin-path.js',
() => {},
['plugin-path.js'],
['plugin-path.js', {options: 'value'}],
[() => {}, {options: 'value'}],
{path: null},
{path: 'plugin-path.js', options: 'value'},
{path: () => {}, options: 'value'},
])
);
}); });
test('Validate single/required plugin configuration', t => { test('validateStep: single/required plugin configuration', t => {
const type = {multiple: false, required: true}; const type = {multiple: false, required: true};
t.false(validateConfig(type, {})); // Empty config
t.false(validateConfig(type, {path: null})); t.false(validateStep(type));
t.false(validateConfig(type, [])); t.false(validateStep(type, []));
t.false(validateConfig(type));
t.false(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
t.true(validateConfig(type, {path: 'plugin-path.js'})); // Single value definition
t.true(validateConfig(type, 'plugin-path.js')); t.true(validateStep(type, 'plugin-path.js'));
t.true(validateConfig(type, ['plugin-path.js'])); t.true(validateStep(type, () => {}));
t.true(validateConfig(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('Validate single/optional plugin configuration', t => { test('validateStep: single/optional plugin configuration', t => {
const type = {multiple: false, required: false}; const type = {multiple: false, required: false};
t.false(validateConfig(type, {})); // Empty config
t.false(validateConfig(type, {path: null})); t.true(validateStep(type));
t.false(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); t.true(validateStep(type, []));
t.true(validateConfig(type)); // Single value definition
t.true(validateConfig(type, [])); t.true(validateStep(type, 'plugin-path.js'));
t.true(validateConfig(type, {path: 'plugin-path.js'})); t.true(validateStep(type, () => {}));
t.true(validateConfig(type, 'plugin-path.js')); t.true(validateStep(type, ['plugin-path.js']));
t.true(validateConfig(type, ['plugin-path.js'])); t.true(validateStep(type, [() => {}]));
t.true(validateConfig(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 = () => {};
t.is(require('../fixtures/plugin-noop'), loadPlugin({cwd: './test/fixtures'}, './plugin-noop', {}), 'From cwd');
t.is(
require('../fixtures/plugin-noop'),
loadPlugin({cwd}, './plugin-noop', {'./plugin-noop': './test/fixtures'}),
'From a shareable config context'
);
t.is(func, loadPlugin({cwd}, func, {}), 'Defined as a function');
});
test('parseConfig', t => {
const path = 'plugin-module';
const options = {option1: 'value1', option2: 'value2'};
t.deepEqual(parseConfig(path), [path, {}], 'String definition');
t.deepEqual(parseConfig({path}), [path, {}], 'Object definition');
t.deepEqual(parseConfig({path, ...options}), [path, options], 'Object definition with options');
t.deepEqual(parseConfig([path]), [path, {}], 'Array definition');
t.deepEqual(parseConfig([path, options]), [path, options], 'Array definition with options');
}); });