feat: add success and fail notification plugins

- Allow `publish` plugins to return an `Object` with information related to the releases
- Add the `success` plugin hook, called when all `publish` are successful, receiving a list of release
- Add the `fail` plugin hook, called when an error happens at any point, receiving a list of errors
- Add detailed message for each error
This commit is contained in:
Pierre Vanduynslager 2018-02-02 15:24:57 -05:00
parent 9b2f6bfed2
commit 49f5e704ba
29 changed files with 917 additions and 408 deletions

View File

@ -37,6 +37,7 @@ This removes the immediate connection between human emotions and version numbers
- Fully automated release
- Enforce [Semantic Versioning](https://semver.org) specification
- New features and fixes are immediately available to users
- Notify maintainers and users of new releases
- Use formalized commit message convention to document changes in the codebase
- Integrate with your [continuous integration workflow](docs/recipes/README.md#ci-configurations)
- Avoid potential errors associated with manual releases
@ -86,6 +87,7 @@ After running the tests the command `semantic-release` will execute the followin
| Generate notes | Generate release notes with the [generate notes plugin](docs/usage/plugins.md#generatenotes-plugin) for the commits added since the last release. |
| Create Git tag | Create a Git tag corresponding the new release version |
| Publish | Publish the release with the [publish plugins](docs/usage/plugins.md#publish-plugin). |
| 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. |
## Documentation

9
cli.js
View File

@ -2,7 +2,10 @@ const program = require('commander');
const {pickBy, isUndefined} = require('lodash');
function list(values) {
return values.split(',').map(value => value.trim());
return values
.split(',')
.map(value => value.trim())
.filter(value => value && value !== 'false');
}
module.exports = async () => {
@ -26,6 +29,8 @@ module.exports = async () => {
)
.option('--generate-notes <path>', 'Path or package name for the generateNotes plugin')
.option('--publish <paths>', 'Comma separated list of paths or packages name for the publish plugin(s)', list)
.option('--success <paths>', 'Comma separated list of paths or packages name for the success plugin(s)', list)
.option('--fail <paths>', 'Comma separated list of paths or packages name for the fail plugin(s)', list)
.option(
'--no-ci',
'Skip Continuous Integration environment verifications, allowing to make releases from a local machine'
@ -48,7 +53,7 @@ module.exports = async () => {
process.exitCode = 1;
} else {
const opts = program.opts();
// Set the `noCi` options as commander.js sets the `ci` options instead (becasue args starts with `--no`)
// Set the `noCi` options as commander.js sets the `ci` options instead (because args starts with `--no`)
opts.noCi = opts.ci === false ? true : undefined;
// Remove option with undefined values, as commander.js sets non defined options as `undefined`
await require('.')(pickBy(opts, value => !isUndefined(value)));

View File

@ -5,6 +5,8 @@
- [@semantic-release/github](https://github.com/semantic-release/github)
- [verifyConditions](https://github.com/semantic-release/github#verifyconditions): Verify the presence and the validity of the GitHub authentication and release configuration
- [publish](https://github.com/semantic-release/github#publish): Publish a [GitHub release](https://help.github.com/articles/about-releases)
- [success](https://github.com/semantic-release/github#success): Add a comment to GitHub issues and pull requests resolved in the release
- [fail](https://github.com/semantic-release/github#fail): Open a GitHub issue when a release fails
- [@semantic-release/npm](https://github.com/semantic-release/npm)
- [verifyConditions](https://github.com/semantic-release/npm#verifyconditions): Verify the presence and the validity of the npm authentication and release configuration
- [publish](https://github.com/semantic-release/npm#publish): Publish the package on the npm registry
@ -25,6 +27,8 @@
- [analyzeCommits](https://github.com/semantic-release/exec#analyzecommits): Execute a shell command to determine the type of release
- [verifyRelease](https://github.com/semantic-release/exec#verifyrelease): Execute a shell command to verifying a release that was determined before and is about to be published.
- [generateNotes](https://github.com/semantic-release/exec#analyzecommits): Execute a shell command to generate the release note
- [publish](https://github.com/semantic-release/exec#publish): Execute a shell command to publish the release.
- [publish](https://github.com/semantic-release/exec#publish): Execute a shell command to publish the release
- [success](https://github.com/semantic-release/exec#success): Execute a shell command to notify of a new release
- [fail](https://github.com/semantic-release/exec#fail): Execute a shell command to notify of a failed release
## Community plugins

View File

@ -59,7 +59,7 @@ Default: `repository` property in `package.json` or [git origin url](https://git
CLI arguments: `-r`, `--repository-url`
The git repository URL
The git repository URL.
Any valid git url format is supported (See [Git protocols](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols)).
@ -75,7 +75,7 @@ CLI arguments: `-t`, `--tag-format`
The [Git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) format used by **semantic-release** to identify releases. The tag name is generated with [Lodash template](https://lodash.com/docs#template) and will be compiled with the `version` variable.
**Note**: The `tagFormat` must contain the `version` variable 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).
### dryRun

View File

@ -34,6 +34,18 @@ Plugin responsible for publishing the release.
Default implementation: [npm](https://github.com/semantic-release/npm#publish) and [github](https://github.com/semantic-release/github#publish).
### success plugin
Plugin responsible for notifying of a new release.
Default implementation: [github](https://github.com/semantic-release/github#success).
### fail plugin
Plugin responsible for notifying of a failed release.
Default implementation: [github](https://github.com/semantic-release/github#fail).
## 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`.

View File

@ -1,4 +1,4 @@
const {template, isFunction} = require('lodash');
const {template, isPlainObject, castArray} = require('lodash');
const marked = require('marked');
const TerminalRenderer = require('marked-terminal');
const envCi = require('env-ci');
@ -9,13 +9,14 @@ const verify = require('./lib/verify');
const getNextVersion = require('./lib/get-next-version');
const getCommits = require('./lib/get-commits');
const getLastRelease = require('./lib/get-last-release');
const {extractErrors} = require('./lib/utils');
const logger = require('./lib/logger');
const {unshallow, gitHead: getGitHead, tag, push, deleteTag} = require('./lib/git');
async function run(opts) {
marked.setOptions({renderer: new TerminalRenderer()});
async function run(options, plugins) {
const {isCi, branch, isPr} = envCi();
const config = await getConfig(opts, logger);
const {plugins, options} = config;
if (!isCi && !options.dryRun && !options.noCi) {
logger.log('This run was not triggered in a known CI environment, running in dry-run mode.');
@ -34,7 +35,7 @@ async function run(opts) {
logger.log('Run automated release from branch %s', options.branch);
logger.log('Call plugin %s', 'verify-conditions');
await plugins.verifyConditions({options, logger}, true);
await plugins.verifyConditions({options, logger}, {settleAll: true});
// Unshallow the repo in order to get all the tags
await unshallow();
@ -57,14 +58,13 @@ async function run(opts) {
const nextRelease = {type, version, gitHead: await getGitHead(), gitTag: template(options.tagFormat)({version})};
logger.log('Call plugin %s', 'verify-release');
await plugins.verifyRelease({options, logger, lastRelease, commits, nextRelease}, true);
await plugins.verifyRelease({options, logger, lastRelease, commits, nextRelease}, {settleAll: true});
const generateNotesParam = {options, logger, lastRelease, commits, nextRelease};
if (options.dryRun) {
logger.log('Call plugin %s', 'generate-notes');
const notes = await plugins.generateNotes(generateNotesParam);
marked.setOptions({renderer: new TerminalRenderer()});
logger.log('Release note for version %s:\n', nextRelease.version);
process.stdout.write(`${marked(notes)}\n`);
} else {
@ -77,10 +77,13 @@ async function run(opts) {
await push(options.repositoryUrl, branch);
logger.log('Call plugin %s', 'publish');
await plugins.publish({options, logger, lastRelease, commits, nextRelease}, false, async prevInput => {
const releases = await plugins.publish(
{options, logger, lastRelease, commits, nextRelease},
{
getNextInput: async lastResult => {
const newGitHead = await getGitHead();
// If previous publish plugin has created a commit (gitHead changed)
if (prevInput.nextRelease.gitHead !== newGitHead) {
if (lastResult.nextRelease.gitHead !== newGitHead) {
// Delete the previously created tag
await deleteTag(options.repositoryUrl, nextRelease.gitTag);
// Recreate the tag, referencing the new gitHead
@ -95,27 +98,64 @@ async function run(opts) {
}
// Call the next publish plugin with the updated `nextRelease`
return {options, logger, lastRelease, commits, nextRelease};
});
},
// Add nextRelease and plugin properties to published release
transform: (release, step) => ({...(isPlainObject(release) ? release : {}), ...nextRelease, ...step}),
}
);
await plugins.success(
{options, logger, lastRelease, commits, nextRelease, releases: castArray(releases)},
{settleAll: true}
);
logger.log('Published release: %s', nextRelease.version);
}
return true;
}
module.exports = async opts => {
const unhook = hookStd({silent: false}, hideSensitive);
try {
const result = await run(opts);
unhook();
return result;
} catch (err) {
const errors = err && isFunction(err[Symbol.iterator]) ? [...err].sort(error => !error.semanticRelease) : [err];
function logErrors(err) {
const errors = extractErrors(err).sort(error => (error.semanticRelease ? -1 : 0));
for (const error of errors) {
if (error.semanticRelease) {
logger.log(`%s ${error.message}`, error.code);
if (error.details) {
process.stdout.write(`${marked(error.details)}\n`);
}
} else {
logger.error('An error occurred while running semantic-release: %O', error);
}
}
}
async function callFail(plugins, options, error) {
const errors = extractErrors(error).filter(error => error.semanticRelease);
if (errors.length > 0) {
try {
await plugins.fail({options, logger, errors}, {settleAll: true});
} catch (err) {
logErrors(err);
}
}
}
module.exports = async opts => {
const unhook = hookStd({silent: false}, hideSensitive);
try {
const config = await getConfig(opts, logger);
const {plugins, options} = config;
try {
const result = await run(options, plugins);
unhook();
return result;
} catch (err) {
if (!options.dryRun) {
await callFail(plugins, options, err);
}
throw err;
}
} catch (err) {
logErrors(err);
unhook();
throw err;
}

118
lib/definitions/errors.js Normal file
View File

@ -0,0 +1,118 @@
const url = require('url');
const {inspect} = require('util');
const {toLower, isString} = require('lodash');
const pkg = require('../../package.json');
const RELEASE_TYPE = require('./release-types');
const homepage = url.format({...url.parse(pkg.homepage), ...{hash: null}});
const stringify = obj => (isString(obj) ? obj : inspect(obj, {breakLength: Infinity, depth: 2, maxArrayLength: 5}));
const linkify = file => `${homepage}/blob/caribou/${file}`;
module.exports = {
ENOGITREPO: () => ({
message: 'Not running from a git repository.',
details: `The \`semantic-release\` command must be executed from a Git repository.
The current working directory is \`${process.cwd()}\`.
Please verify your CI configuration to make sure the \`semantic-release\` command is executed from the root of the cloned repository.`,
}),
ENOREPOURL: () => ({
message: 'The `repositoryUrl` option is required.',
details: `The [repositoryUrl option](${linkify(
'docs/usage/configuration.md#repositoryurl'
)}) cannot be determined from the semantic-release configuration, the \`package.json\` nor the [git origin url](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes).
Please make sure to add the \`repositoryUrl\` to the [semantic-release configuration] (${linkify(
'docs/usage/configuration.md'
)}).`,
}),
EGITNOPERMISSION: ({options}) => ({
message: 'The push permission to the Git repository is required.',
details: `**semantic-release** cannot push the version tag to the branch \`${
options.branch
}\` on remote Git repository.
Please refer to the [authentication configuration documentation](${linkify(
'docs/usage/ci-configuration.md#authentication'
)}) to configure the Git credentials on your CI environment.`,
}),
EINVALIDTAGFORMAT: ({tagFormat}) => ({
message: 'Invalid `tagFormat` option.',
details: `The [tagFormat](${linkify(
'docs/usage/configuration.md#tagformat'
)}) must compile to a [valid Git reference](https://git-scm.com/docs/git-check-ref-format#_description).
Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`,
}),
ETAGNOVERSION: ({tagFormat}) => ({
message: 'Invalid `tagFormat` option.',
details: `The [tagFormat](${linkify(
'docs/usage/configuration.md#tagformat'
)}) option must contain the variable \`version\` exactly once.
Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`,
}),
EPLUGINCONF: ({pluginName, pluginConf}) => ({
message: `The \`${pluginName}\` plugin configuration is invalid.`,
details: `The [${pluginName} plugin configuration](${linkify(
`docs/usage/plugins.md#${toLower(pluginName)}-plugin`
)}) if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a \`path\` property.
Your configuration for the \`${pluginName}\` plugin is \`${stringify(pluginConf)}\`.`,
}),
EPLUGIN: ({pluginName, pluginType}) => ({
message: `A plugin configured in the step ${pluginType} is not a valid semantic-release plugin.`,
details: `A valid \`${pluginType}\` **semantic-release** plugin must be a function or an object with a function in the property \`${pluginType}\`.
The plugin \`${pluginName}\` doesn't have the property \`${pluginType}\` and cannot be used for the \`${pluginType}\` step.
Please refer to the \`${pluginName}\` and [semantic-release plugins configuration](${linkify(
'docs/usage/plugins.md'
)}) documentation for more details.`,
}),
EANALYZEOUTPUT: ({result, pluginName}) => ({
message: 'The `analyzeCommits` plugin returned an invalid value. It must return a valid semver release type.',
details: `The \`analyzeCommits\` plugin must return a valid [semver](https://semver.org) release type. The valid values are: ${RELEASE_TYPE.map(
type => `\`${type}\``
).join(', ')}.
The \`analyzeCommits\` function of the \`${pluginName}\` returned \`${stringify(result)}\` instead.
We recommend to report the issue to the \`${pluginName}\` authors, providing the following informations:
- The **semantic-release** version: \`${pkg.version}\`
- The **semantic-release** logs from your CI job
- The value returned by the plugin: \`${stringify(result)}\`
- A link to the **semantic-release** plugin developer guide: [${linkify('docs/developer-guide/plugin.md')}](${linkify(
'docs/developer-guide/plugin.md'
)})`,
}),
ERELEASENOTESOUTPUT: ({result, pluginName}) => ({
message: 'The `generateNotes` plugin returned an invalid value. It must return a `String`.',
details: `The \`generateNotes\` plugin must return a \`String\`.
The \`generateNotes\` function of the \`${pluginName}\` returned \`${stringify(result)}\` instead.
We recommend to report the issue to the \`${pluginName}\` authors, providing the following informations:
- The **semantic-release** version: \`${pkg.version}\`
- The **semantic-release** logs from your CI job
- The value returned by the plugin: \`${stringify(result)}\`
- A link to the **semantic-release** plugin developer guide: [${linkify('docs/developer-guide/plugin.md')}](${linkify(
'docs/developer-guide/plugin.md'
)})`,
}),
EPUBLISHOUTPUT: ({result, pluginName}) => ({
message: 'A `publish` plugin returned an invalid value. It must return an `Object`.',
details: `The \`publish\` plugins must return an \`Object\`.
The \`publish\` function of the \`${pluginName}\` returned \`${stringify(result)}\` instead.
We recommend to report the issue to the \`${pluginName}\` authors, providing the following informations:
- The **semantic-release** version: \`${pkg.version}\`
- The **semantic-release** logs from your CI job
- The value returned by the plugin: \`${stringify(result)}\`
- A link to the **semantic-release** plugin developer guide: [${linkify('docs/developer-guide/plugin.md')}](${linkify(
'docs/developer-guide/plugin.md'
)})`,
}),
};

View File

@ -0,0 +1,61 @@
const {isString, isFunction, isArray, isPlainObject} = require('lodash');
const RELEASE_TYPE = require('./release-types');
const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf);
module.exports = {
verifyConditions: {
default: ['@semantic-release/npm', '@semantic-release/github'],
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
},
analyzeCommits: {
default: '@semantic-release/commit-analyzer',
config: {
validator: conf => Boolean(conf) && validatePluginConfig(conf),
},
output: {
validator: output => !output || RELEASE_TYPE.includes(output),
error: 'EANALYZEOUTPUT',
},
},
verifyRelease: {
default: false,
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
},
generateNotes: {
default: '@semantic-release/release-notes-generator',
config: {
validator: conf => !conf || validatePluginConfig(conf),
},
output: {
validator: output => !output || isString(output),
error: 'ERELEASENOTESOUTPUT',
},
},
publish: {
default: ['@semantic-release/npm', '@semantic-release/github'],
config: {
validator: conf => Boolean(conf) && (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
output: {
validator: output => !output || isPlainObject(output),
error: 'EPUBLISHOUTPUT',
},
},
success: {
default: ['@semantic-release/github'],
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
},
fail: {
default: ['@semantic-release/github'],
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
},
};

View File

@ -0,0 +1 @@
module.exports = ['major', 'premajor', 'minor', 'preminor', 'patch', 'prepatch', 'prerelease'];

View File

@ -4,7 +4,7 @@ const cosmiconfig = require('cosmiconfig');
const resolveFrom = require('resolve-from');
const debug = require('debug')('semantic-release:config');
const {repoUrl} = require('./git');
const PLUGINS_DEFINITION = require('./plugins/definitions');
const PLUGINS_DEFINITIONS = require('./definitions/plugins');
const plugins = require('./plugins');
const getGitAuthUrl = require('./get-git-auth-url');
@ -25,7 +25,7 @@ module.exports = async (opts, logger) => {
// For each plugin defined in a shareable config, save in `pluginsPath` the extendable config path,
// so those plugin will be loaded relatively to the config file
Object.keys(extendsOpts).reduce((pluginsPath, option) => {
if (PLUGINS_DEFINITION[option]) {
if (PLUGINS_DEFINITIONS[option]) {
castArray(extendsOpts[option])
.filter(plugin => isString(plugin) || (isPlainObject(plugin) && isString(plugin.path)))
.map(plugin => (isString(plugin) ? plugin : plugin.path))

7
lib/get-error.js Normal file
View File

@ -0,0 +1,7 @@
const SemanticReleaseError = require('@semantic-release/error');
const ERROR_DEFINITIONS = require('./definitions/errors');
module.exports = (code, ctx = {}) => {
const {message, details} = ERROR_DEFINITIONS[code](ctx);
return new SemanticReleaseError(message, code, details);
};

View File

@ -120,7 +120,6 @@ async function push(origin, branch) {
*
* @param {String} origin The remote repository URL.
* @param {String} tagName The tag name to delete.
* @throws {SemanticReleaseError} if the remote tag exists and references a commit that is not the local head commit.
*/
async function deleteTag(origin, tagName) {
// Delete the local tag

View File

@ -1,55 +0,0 @@
const {isString, isFunction, isArray} = require('lodash');
const RELEASE_TYPE = ['major', 'premajor', 'minor', 'preminor', 'patch', 'prepatch', 'prerelease'];
const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf);
module.exports = {
verifyConditions: {
default: ['@semantic-release/npm', '@semantic-release/github'],
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
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.',
},
},
analyzeCommits: {
default: '@semantic-release/commit-analyzer',
config: {
validator: conf => Boolean(conf) && validatePluginConfig(conf),
message:
'The "analyzeCommits" plugin is mandatory, and must be a single plugin definition. A plugin definition is either a string or an object with a path property.',
},
output: {
validator: output => !output || RELEASE_TYPE.includes(output),
message: 'The "analyzeCommits" plugin output, if defined, must be a valid semver release type.',
},
},
verifyRelease: {
default: false,
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
message:
'The "verifyRelease" 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.',
},
},
generateNotes: {
default: '@semantic-release/release-notes-generator',
config: {
validator: conf => !conf || validatePluginConfig(conf),
message:
'The "generateNotes" plugin, if defined, must be a single plugin definition. A plugin definition is either a string or an object with a path property.',
},
output: {
validator: output => !output || isString(output),
message: 'The "generateNotes" plugin output, if defined, must be a string.',
},
},
publish: {
default: ['@semantic-release/npm', '@semantic-release/github'],
config: {
validator: conf => Boolean(conf) && (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
message:
'The "publish" plugin is mandatory, and must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.',
},
},
};

View File

@ -1,34 +1,35 @@
const {isArray, isObject, omit} = require('lodash');
const {isArray, isObject, omit, castArray, isUndefined} = require('lodash');
const AggregateError = require('aggregate-error');
const SemanticReleaseError = require('@semantic-release/error');
const PLUGINS_DEFINITION = require('./definitions');
const getError = require('../get-error');
const PLUGINS_DEFINITIONS = require('../definitions/plugins');
const pipeline = require('./pipeline');
const normalize = require('./normalize');
module.exports = (options, pluginsPath, logger) => {
const errors = [];
const plugins = Object.keys(PLUGINS_DEFINITION).reduce((plugins, pluginType) => {
const {config, output, default: def} = PLUGINS_DEFINITION[pluginType];
const plugins = Object.keys(PLUGINS_DEFINITIONS).reduce((plugins, pluginType) => {
const {config, default: def} = PLUGINS_DEFINITIONS[pluginType];
let pluginConfs;
if (options[pluginType]) {
if (isUndefined(options[pluginType])) {
pluginConfs = def;
} else {
// If an object is passed and the path is missing, set the default one for single plugins
if (isObject(options[pluginType]) && !options[pluginType].path && !isArray(def)) {
options[pluginType].path = def;
}
if (config && !config.validator(options[pluginType])) {
errors.push(new SemanticReleaseError(config.message, 'EPLUGINCONF'));
errors.push(getError('EPLUGINCONF', {pluginType, pluginConf: options[pluginType]}));
return plugins;
}
pluginConfs = options[pluginType];
} else {
pluginConfs = def;
}
const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITION));
const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITIONS));
plugins[pluginType] = isArray(pluginConfs)
? pipeline(pluginConfs.map(conf => normalize(pluginType, pluginsPath, globalOpts, conf, logger, output)))
: normalize(pluginType, pluginsPath, globalOpts, pluginConfs, logger, output);
plugins[pluginType] = pipeline(
castArray(pluginConfs).map(conf => normalize(pluginType, pluginsPath, globalOpts, conf, logger))
);
return plugins;
}, {});

View File

@ -1,15 +1,18 @@
const {dirname} = require('path');
const {inspect} = require('util');
const SemanticReleaseError = require('@semantic-release/error');
const {isString, isObject, isFunction, noop, cloneDeep} = require('lodash');
const {isString, isPlainObject, isFunction, noop, cloneDeep} = require('lodash');
const resolveFrom = require('resolve-from');
const getError = require('../get-error');
const {extractErrors} = require('../utils');
const PLUGINS_DEFINITIONS = require('../definitions/plugins');
module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger, validator) => {
module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger) => {
if (!pluginOpts) {
return noop;
}
const {path, ...config} = isString(pluginOpts) || isFunction(pluginOpts) ? {path: pluginOpts} : pluginOpts;
const pluginName = isFunction(path) ? `[Function: ${path.name}]` : path;
if (!isFunction(pluginOpts)) {
if (pluginsPath[path]) {
logger.log('Load plugin %s from %s in shareable config %s', pluginType, path, pluginsPath[path]);
@ -28,21 +31,27 @@ module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger, valid
let func;
if (isFunction(plugin)) {
func = plugin.bind(null, cloneDeep({...globalOpts, ...config}));
} else if (isObject(plugin) && plugin[pluginType] && isFunction(plugin[pluginType])) {
} else if (isPlainObject(plugin) && plugin[pluginType] && isFunction(plugin[pluginType])) {
func = plugin[pluginType].bind(null, cloneDeep({...globalOpts, ...config}));
} else {
throw new SemanticReleaseError(
`The ${pluginType} plugin must be a function, or an object with a function in the property ${pluginType}.`,
'EPLUGINCONF'
);
throw getError('EPLUGIN', {pluginType, pluginName});
}
return async input => {
return Object.defineProperty(
async input => {
const definition = PLUGINS_DEFINITIONS[pluginType];
try {
const result = await func(cloneDeep(input));
if (validator && !validator.validator(result)) {
throw new Error(`${validator.message} Received: ${inspect(result)}`);
if (definition && definition.output && !definition.output.validator(result)) {
throw getError(PLUGINS_DEFINITIONS[pluginType].output.error, {result, pluginName});
}
return result;
};
} catch (err) {
extractErrors(err).forEach(err => Object.assign(err, {pluginName}));
throw err;
}
},
'pluginName',
{value: pluginName, writable: false, enumerable: true}
);
};

View File

@ -1,37 +1,55 @@
const {identity, isFunction} = require('lodash');
const pReflect = require('p-reflect');
const {identity} = require('lodash');
const pReduce = require('p-reduce');
const AggregateError = require('aggregate-error');
const {extractErrors} = require('../utils');
module.exports = steps => async (input, settleAll = false, getNextInput = identity) => {
/**
* A Function that execute a list of function sequencially. If at least one Function ins the pipeline throw an Error or rejects, the pipeline function rejects as well.
*
* @typedef {Function} Pipeline
* @param {Any} input Argument to pass to the first step in the pipeline.
* @param {Object} options Pipeline options.
* @param {Boolean} [options.settleAll=false] If `true` all the steps in the pipeline are executed, even if one rejects, if `false` the execution stops after a steps rejects.
* @param {Function} [options.getNextInput=identity] Function called after each step is executed, with the last and current step results; the returned value will be used as the argument of the next step.
* @param {Function} [options.transform=identity] Function called after each step is executed, with the current step result and the step function; the returned value will be saved in the pipeline results.
*
* @return {Array<*>|*} An Array with the result of each step in the pipeline; if there is only 1 step in the pipeline, the result of this step is returned directly.
*
* @throws {AggregateError|Error} An AggregateError with the errors of each step in the pipeline that rejected; if there is only 1 step in the pipeline, the error of this step is thrown directly.
*/
/**
* Create a Pipeline with a list of Functions.
*
* @param {Array<Function>} steps The list of Function to execute.
* @return {Pipeline} A Function that execute the `steps` sequencially
*/
module.exports = steps => async (input, {settleAll = false, getNextInput = identity, transform = identity} = {}) => {
const results = [];
const errors = [];
await pReduce(
steps,
async (prevResult, nextStep) => {
async (lastResult, step) => {
let result;
// Call the next step with the input computed at the end of the previous iteration and save intermediary result
try {
// Call the step with the input computed at the end of the previous iteration and save intermediary result
result = await transform(await step(lastResult), step);
results.push(result);
} catch (err) {
if (settleAll) {
const {isFulfilled, value, reason} = await pReflect(nextStep(prevResult));
result = isFulfilled ? value : reason;
if (isFulfilled) {
results.push(result);
errors.push(...extractErrors(err));
result = err;
} else {
errors.push(...(result && isFunction(result[Symbol.iterator]) ? result : [result]));
throw err;
}
} else {
result = await nextStep(prevResult);
results.push(result);
}
// Prepare input for next step, passing the result of the previous iteration and the current one
return getNextInput(prevResult, result);
// Prepare input for the next step, passing the result of the last iteration (or initial parameter for the first iteration) and the current one
return getNextInput(lastResult, result);
},
input
);
if (errors.length > 0) {
throw new AggregateError(errors);
throw errors.length === 1 ? errors[0] : new AggregateError(errors);
}
return results;
return results.length <= 1 ? results[0] : results;
};

7
lib/utils.js Normal file
View File

@ -0,0 +1,7 @@
const {isFunction} = require('lodash');
function extractErrors(err) {
return err && isFunction(err[Symbol.iterator]) ? [...err] : [err];
}
module.exports = {extractErrors};

View File

@ -1,44 +1,29 @@
const {template} = require('lodash');
const SemanticReleaseError = require('@semantic-release/error');
const AggregateError = require('aggregate-error');
const {isGitRepo, verifyAuth, verifyTagName} = require('./git');
const getError = require('./get-error');
module.exports = async (options, branch, logger) => {
const errors = [];
if (!await isGitRepo()) {
logger.error('Semantic-release must run from a git repository.');
return false;
}
if (!options.repositoryUrl) {
errors.push(new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL'));
errors.push(getError('ENOGITREPO'));
} else if (!options.repositoryUrl) {
errors.push(getError('ENOREPOURL'));
} else if (!await verifyAuth(options.repositoryUrl, options.branch)) {
errors.push(
new SemanticReleaseError(
`The git credentials doesn't allow to push on the branch ${options.branch}.`,
'EGITNOPERMISSION'
)
);
errors.push(getError('EGITNOPERMISSION', {options}));
}
// Verify that compiling the `tagFormat` produce a valid Git tag
if (!await verifyTagName(template(options.tagFormat)({version: '0.0.0'}))) {
errors.push(
new SemanticReleaseError('The tagFormat template must compile to a valid Git tag format', 'EINVALIDTAGFORMAT')
);
errors.push(getError('EINVALIDTAGFORMAT', {tagFormat: options.tagFormat}));
}
// Verify the `tagFormat` contains the variable `version` by compiling the `tagFormat` template
// with a space as the `version` value and verify the result contains the space.
// The space is used as it's an invalid tag character, so it's guaranteed to no be present in the `tagFormat`.
if ((template(options.tagFormat)({version: ' '}).match(/ /g) || []).length !== 1) {
errors.push(
new SemanticReleaseError(
`The tagFormat template must contain the variable "\${version}" exactly once`,
'ETAGNOVERSION'
)
);
errors.push(getError('ETAGNOVERSION', {tagFormat: options.tagFormat}));
}
if (errors.length > 0) {

View File

@ -20,9 +20,9 @@
],
"dependencies": {
"@semantic-release/commit-analyzer": "^5.0.0",
"@semantic-release/error": "^2.1.0",
"@semantic-release/github": "^4.0.2",
"@semantic-release/npm": "^3.0.0",
"@semantic-release/error": "^2.2.0",
"@semantic-release/github": "^4.1.0",
"@semantic-release/npm": "^3.1.0",
"@semantic-release/release-notes-generator": "^6.0.0",
"aggregate-error": "^1.0.0",
"chalk": "^2.3.0",
@ -40,7 +40,6 @@
"marked-terminal": "^2.0.0",
"p-locate": "^2.0.0",
"p-reduce": "^1.0.0",
"p-reflect": "^1.0.0",
"read-pkg-up": "^3.0.0",
"resolve-from": "^4.0.0",
"semver": "^5.4.1"

View File

@ -0,0 +1,122 @@
import test from 'ava';
import plugins from '../../lib/definitions/plugins';
import errors from '../../lib/definitions/errors';
test('The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(plugins.verifyConditions.config.validator({}));
t.false(plugins.verifyConditions.config.validator({path: null}));
t.true(plugins.verifyConditions.config.validator({path: 'plugin-path.js'}));
t.true(plugins.verifyConditions.config.validator());
t.true(plugins.verifyConditions.config.validator('plugin-path.js'));
t.true(plugins.verifyConditions.config.validator(() => {}));
t.true(plugins.verifyConditions.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "analyzeCommits" plugin is mandatory, and must be a single plugin definition', t => {
t.false(plugins.analyzeCommits.config.validator({}));
t.false(plugins.analyzeCommits.config.validator({path: null}));
t.false(plugins.analyzeCommits.config.validator([]));
t.false(plugins.analyzeCommits.config.validator());
t.true(plugins.analyzeCommits.config.validator({path: 'plugin-path.js'}));
t.true(plugins.analyzeCommits.config.validator('plugin-path.js'));
t.true(plugins.analyzeCommits.config.validator(() => {}));
});
test('The "verifyRelease" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(plugins.verifyRelease.config.validator({}));
t.false(plugins.verifyRelease.config.validator({path: null}));
t.true(plugins.verifyRelease.config.validator({path: 'plugin-path.js'}));
t.true(plugins.verifyRelease.config.validator());
t.true(plugins.verifyRelease.config.validator('plugin-path.js'));
t.true(plugins.verifyRelease.config.validator(() => {}));
t.true(plugins.verifyRelease.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "generateNotes" plugin, if defined, must be a single plugin definition', t => {
t.false(plugins.generateNotes.config.validator({}));
t.false(plugins.generateNotes.config.validator({path: null}));
t.false(plugins.generateNotes.config.validator([]));
t.true(plugins.generateNotes.config.validator());
t.true(plugins.generateNotes.config.validator({path: 'plugin-path.js'}));
t.true(plugins.generateNotes.config.validator('plugin-path.js'));
t.true(plugins.generateNotes.config.validator(() => {}));
});
test('The "publish" plugin is mandatory, and must be a single or an array of plugins definition', t => {
t.false(plugins.publish.config.validator({}));
t.false(plugins.publish.config.validator({path: null}));
t.false(plugins.publish.config.validator());
t.true(plugins.publish.config.validator({path: 'plugin-path.js'}));
t.true(plugins.publish.config.validator('plugin-path.js'));
t.true(plugins.publish.config.validator(() => {}));
t.true(plugins.publish.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "success" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(plugins.success.config.validator({}));
t.false(plugins.success.config.validator({path: null}));
t.true(plugins.success.config.validator({path: 'plugin-path.js'}));
t.true(plugins.success.config.validator());
t.true(plugins.success.config.validator('plugin-path.js'));
t.true(plugins.success.config.validator(() => {}));
t.true(plugins.success.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "fail" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(plugins.fail.config.validator({}));
t.false(plugins.fail.config.validator({path: null}));
t.true(plugins.fail.config.validator({path: 'plugin-path.js'}));
t.true(plugins.fail.config.validator());
t.true(plugins.fail.config.validator('plugin-path.js'));
t.true(plugins.fail.config.validator(() => {}));
t.true(plugins.fail.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "analyzeCommits" plugin output must be either undefined or a valid semver release type', t => {
t.false(plugins.analyzeCommits.output.validator('invalid'));
t.false(plugins.analyzeCommits.output.validator(1));
t.false(plugins.analyzeCommits.output.validator({}));
t.true(plugins.analyzeCommits.output.validator());
t.true(plugins.analyzeCommits.output.validator(null));
t.true(plugins.analyzeCommits.output.validator('major'));
});
test('The "generateNotes" plugin output, if defined, must be a string', t => {
t.false(plugins.generateNotes.output.validator(1));
t.false(plugins.generateNotes.output.validator({}));
t.true(plugins.generateNotes.output.validator());
t.true(plugins.generateNotes.output.validator(null));
t.true(plugins.generateNotes.output.validator(''));
t.true(plugins.generateNotes.output.validator('string'));
});
test('The "publish" plugin output, if defined, must be an object', t => {
t.false(plugins.publish.output.validator(1));
t.false(plugins.publish.output.validator('string'));
t.true(plugins.publish.output.validator({}));
t.true(plugins.publish.output.validator());
t.true(plugins.publish.output.validator(null));
t.true(plugins.publish.output.validator(''));
});
test('The "analyzeCommits" plugin output definition return an existing error code', t => {
t.true(Object.keys(errors).includes(plugins.analyzeCommits.output.error));
});
test('The "generateNotes" plugin output definition return an existing error code', t => {
t.true(Object.keys(errors).includes(plugins.generateNotes.output.error));
});
test('The "publish" plugin output definition return an existing error code', t => {
t.true(Object.keys(errors).includes(plugins.publish.output.error));
});

5
test/fixtures/plugin-errors.js vendored Normal file
View File

@ -0,0 +1,5 @@
const AggregateError = require('aggregate-error');
module.exports = () => {
throw new AggregateError([new Error('a'), new Error('b')]);
};

1
test/fixtures/plugin-identity.js vendored Normal file
View File

@ -0,0 +1 @@
module.exports = (pluginConfig, options) => options;

View File

@ -1,11 +1,10 @@
import test from 'ava';
import proxyquire from 'proxyquire';
import {stub} from 'sinon';
import tempy from 'tempy';
import {spy, stub} from 'sinon';
import clearModule from 'clear-module';
import AggregateError from 'aggregate-error';
import SemanticReleaseError from '@semantic-release/error';
import DEFINITIONS from '../lib/plugins/definitions';
import DEFINITIONS from '../lib/definitions/plugins';
import {
gitHead as getGitHead,
gitTagHead,
@ -21,10 +20,10 @@ import {
const envBackup = Object.assign({}, process.env);
// Save the current working diretory
const cwd = process.cwd();
const pluginNoop = require.resolve('./fixtures/plugin-noop');
test.beforeEach(t => {
clearModule('../lib/hide-sensitive');
// Delete environment variables that could have been set on the machine running the tests
delete process.env.GIT_CREDENTIALS;
delete process.env.GH_TOKEN;
@ -32,8 +31,8 @@ test.beforeEach(t => {
delete process.env.GL_TOKEN;
delete process.env.GITLAB_TOKEN;
// Stub the logger functions
t.context.log = stub();
t.context.error = stub();
t.context.log = spy();
t.context.error = spy();
t.context.logger = {log: t.context.log, error: t.context.error};
t.context.stdout = stub(process.stdout, 'write');
t.context.stderr = stub(process.stderr, 'write');
@ -67,7 +66,9 @@ test.serial('Plugins are called with expected values', async t => {
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
const publish = stub().resolves();
const release1 = {name: 'Release 1', url: 'https://release1.com'};
const publish1 = stub().resolves(release1);
const success = stub().resolves();
const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `v\${version}`};
const options = {
@ -76,7 +77,8 @@ test.serial('Plugins are called with expected values', async t => {
analyzeCommits,
verifyRelease,
generateNotes,
publish,
publish: [publish1, pluginNoop],
success,
};
const semanticRelease = proxyquire('..', {
@ -117,14 +119,27 @@ test.serial('Plugins are called with expected values', async t => {
t.deepEqual(generateNotes.args[0][1].commits[0].message, commits[0].message);
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);
t.deepEqual(publish.args[0][1].commits[0].hash, commits[0].hash);
t.deepEqual(publish.args[0][1].commits[0].message, commits[0].message);
t.deepEqual(publish.args[0][1].nextRelease, Object.assign({}, nextRelease, {notes}));
t.is(publish1.callCount, 1);
t.deepEqual(publish1.args[0][0], config);
t.deepEqual(publish1.args[0][1].options, options);
t.deepEqual(publish1.args[0][1].logger, t.context.logger);
t.deepEqual(publish1.args[0][1].lastRelease, lastRelease);
t.deepEqual(publish1.args[0][1].commits[0].hash, commits[0].hash);
t.deepEqual(publish1.args[0][1].commits[0].message, commits[0].message);
t.deepEqual(publish1.args[0][1].nextRelease, {...nextRelease, ...{notes}});
t.is(success.callCount, 1);
t.deepEqual(success.args[0][0], config);
t.deepEqual(success.args[0][1].options, options);
t.deepEqual(success.args[0][1].logger, t.context.logger);
t.deepEqual(success.args[0][1].lastRelease, lastRelease);
t.deepEqual(success.args[0][1].commits[0].hash, commits[0].hash);
t.deepEqual(success.args[0][1].commits[0].message, commits[0].message);
t.deepEqual(success.args[0][1].nextRelease, {...nextRelease, ...{notes}});
t.deepEqual(success.args[0][1].releases, [
{...release1, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}},
{...nextRelease, ...{notes}, ...{pluginName: pluginNoop}},
]);
// Verify the tag has been created on the local and remote repo and reference the gitHead
t.is(await gitTagHead(nextRelease.gitTag), nextRelease.gitHead);
@ -139,20 +154,16 @@ test.serial('Use custom tag format', async t => {
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'test-2.0.0'};
const notes = 'Release notes';
const verifyConditions = stub().resolves();
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
const publish = stub().resolves();
const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `test-\${version}`};
const options = {
...config,
verifyConditions,
analyzeCommits,
verifyRelease,
generateNotes,
publish,
verifyConditions: stub().resolves(),
analyzeCommits: stub().resolves(nextRelease.type),
verifyRelease: stub().resolves(),
generateNotes: stub().resolves(notes),
publish: stub().resolves(),
success: stub().resolves(),
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {
@ -193,6 +204,8 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre
verifyRelease: stub().resolves(),
generateNotes,
publish: [publish1, publish2],
success: stub().resolves(),
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {
@ -205,19 +218,69 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre
t.is(generateNotes.callCount, 2);
t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease);
t.is(publish1.callCount, 1);
t.deepEqual(publish1.args[0][1].nextRelease, Object.assign({}, nextRelease, {notes}));
t.deepEqual(publish1.args[0][1].nextRelease, {...nextRelease, ...{notes}});
nextRelease.gitHead = await getGitHead();
t.deepEqual(generateNotes.secondCall.args[1].nextRelease, Object.assign({}, nextRelease, {notes}));
t.deepEqual(generateNotes.secondCall.args[1].nextRelease, {...nextRelease, ...{notes}});
t.is(publish2.callCount, 1);
t.deepEqual(publish2.args[0][1].nextRelease, Object.assign({}, nextRelease, {notes}));
t.deepEqual(publish2.args[0][1].nextRelease, {...nextRelease, ...{notes}});
// Verify the tag has been created on the local and remote repo and reference the last gitHead
t.is(await gitTagHead(nextRelease.gitTag), commits[0].hash);
t.is(await gitRemoteTagHead(repositoryUrl, nextRelease.gitTag), commits[0].hash);
});
test.serial('Call all "success" plugins even if one errors out', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
await gitCommits(['Second']);
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
const notes = 'Release notes';
const verifyConditions1 = stub().resolves();
const verifyConditions2 = stub().resolves();
const analyzeCommits = stub().resolves(nextRelease.type);
const generateNotes = stub().resolves(notes);
const release = {name: 'Release', url: 'https://release.com'};
const publish = stub().resolves(release);
const success1 = stub().rejects();
const success2 = stub().resolves();
const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `v\${version}`};
const options = {
...config,
verifyConditions: [verifyConditions1, verifyConditions2],
analyzeCommits,
generateNotes,
publish,
success: [success1, success2],
};
const semanticRelease = proxyquire('..', {
'./lib/logger': t.context.logger,
'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
});
await t.throws(semanticRelease(options));
t.is(success1.callCount, 1);
t.deepEqual(success1.args[0][0], config);
t.deepEqual(success1.args[0][1].releases, [
{...release, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}},
]);
t.is(success2.callCount, 1);
t.deepEqual(success2.args[0][1].releases, [
{...release, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}},
]);
});
test.serial('Log all "verifyConditions" errors', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repositoryUrl = await gitRepo(true);
@ -227,10 +290,12 @@ test.serial('Log all "verifyConditions" errors', async t => {
const error1 = new Error('error 1');
const error2 = new SemanticReleaseError('error 2', 'ERR2');
const error3 = new SemanticReleaseError('error 3', 'ERR3');
const fail = stub().resolves();
const config = {branch: 'master', repositoryUrl, tagFormat: `v\${version}`};
const options = {
branch: 'master',
repositoryUrl,
...config,
verifyConditions: [stub().rejects(new AggregateError([error1, error2])), stub().rejects(error3)],
fail,
};
const semanticRelease = proxyquire('..', {
@ -247,6 +312,11 @@ test.serial('Log all "verifyConditions" errors', async t => {
error1,
]);
t.true(t.context.error.calledAfter(t.context.log));
t.is(fail.callCount, 1);
t.deepEqual(fail.args[0][0], config);
t.deepEqual(fail.args[0][1].options, options);
t.deepEqual(fail.args[0][1].logger, t.context.logger);
t.deepEqual(fail.args[0][1].errors, [error2, error3]);
});
test.serial('Log all "verifyRelease" errors', async t => {
@ -261,12 +331,14 @@ test.serial('Log all "verifyRelease" errors', async t => {
const error1 = new SemanticReleaseError('error 1', 'ERR1');
const error2 = new SemanticReleaseError('error 2', 'ERR2');
const fail = stub().resolves();
const config = {branch: 'master', repositoryUrl, tagFormat: `v\${version}`};
const options = {
branch: 'master',
repositoryUrl,
...config,
verifyConditions: stub().resolves(),
analyzeCommits: stub().resolves('major'),
verifyRelease: [stub().rejects(error1), stub().rejects(error2)],
fail,
};
const semanticRelease = proxyquire('..', {
@ -278,9 +350,12 @@ test.serial('Log all "verifyRelease" errors', async t => {
t.deepEqual(Array.from(errors), [error1, error2]);
t.deepEqual(t.context.log.args[t.context.log.args.length - 2], ['%s error 1', 'ERR1']);
t.deepEqual(t.context.log.args[t.context.log.args.length - 1], ['%s error 2', 'ERR2']);
t.is(fail.callCount, 1);
t.deepEqual(fail.args[0][0], config);
t.deepEqual(fail.args[0][1].errors, [error1, error2]);
});
test.serial('Dry-run skips publish', async t => {
test.serial('Dry-run skips publish and success', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
@ -298,6 +373,7 @@ test.serial('Dry-run skips publish', async t => {
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
const publish = stub().resolves();
const success = stub().resolves();
const options = {
dryRun: true,
@ -308,6 +384,7 @@ test.serial('Dry-run skips publish', async t => {
verifyRelease,
generateNotes,
publish,
success,
};
const semanticRelease = proxyquire('..', {
@ -322,6 +399,41 @@ test.serial('Dry-run skips publish', async t => {
t.is(verifyRelease.callCount, 1);
t.is(generateNotes.callCount, 1);
t.is(publish.callCount, 0);
t.is(success.callCount, 0);
});
test.serial('Dry-run skips fail', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
await gitCommits(['Second']);
const error1 = new SemanticReleaseError('error 1', 'ERR1');
const error2 = new SemanticReleaseError('error 2', 'ERR2');
const fail = stub().resolves();
const options = {
dryRun: true,
branch: 'master',
repositoryUrl,
verifyConditions: [stub().rejects(error1), stub().rejects(error2)],
fail,
};
const semanticRelease = proxyquire('..', {
'./lib/logger': t.context.logger,
'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
});
const errors = await t.throws(semanticRelease(options));
t.deepEqual(Array.from(errors), [error1, error2]);
t.deepEqual(t.context.log.args[t.context.log.args.length - 2], ['%s error 1', 'ERR1']);
t.deepEqual(t.context.log.args[t.context.log.args.length - 1], ['%s error 2', 'ERR2']);
t.is(fail.callCount, 0);
});
test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', async t => {
@ -342,6 +454,7 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
const publish = stub().resolves();
const success = stub().resolves();
const options = {
dryRun: false,
@ -352,6 +465,8 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a
verifyRelease,
generateNotes,
publish,
success,
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {
@ -366,6 +481,7 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a
t.is(verifyRelease.callCount, 1);
t.is(generateNotes.callCount, 1);
t.is(publish.callCount, 0);
t.is(success.callCount, 0);
});
test.serial('Allow local releases with "noCi" option', async t => {
@ -386,6 +502,7 @@ test.serial('Allow local releases with "noCi" option', async t => {
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
const publish = stub().resolves();
const success = stub().resolves();
const options = {
noCi: true,
@ -396,6 +513,8 @@ test.serial('Allow local releases with "noCi" option', async t => {
verifyRelease,
generateNotes,
publish,
success,
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {
@ -414,6 +533,7 @@ test.serial('Allow local releases with "noCi" option', async t => {
t.is(verifyRelease.callCount, 1);
t.is(generateNotes.callCount, 1);
t.is(publish.callCount, 1);
t.is(success.callCount, 1);
});
test.serial('Accept "undefined" value returned by the "generateNotes" plugins', async t => {
@ -428,7 +548,6 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins',
const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'};
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
const verifyConditions = stub().resolves();
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves();
@ -437,11 +556,13 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins',
const options = {
branch: 'master',
repositoryUrl,
verifyConditions: [verifyConditions],
verifyConditions: stub().resolves(),
analyzeCommits,
verifyRelease,
generateNotes,
publish,
success: stub().resolves(),
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {
@ -464,18 +585,6 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins',
t.falsy(publish.args[0][1].nextRelease.notes);
});
test.serial('Returns falsy value if not running from a git repository', async t => {
// Set the current working directory to a temp directory
process.chdir(tempy.directory());
const semanticRelease = proxyquire('..', {
'./lib/logger': t.context.logger,
'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
});
t.falsy(await semanticRelease({repositoryUrl: 'git@hostname.com:owner/module.git'}));
t.is(t.context.error.args[0][0], 'Semantic-release must run from a git repository.');
});
test.serial('Returns falsy value if triggered by a PR', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repositoryUrl = await gitRepo(true);
@ -487,7 +596,7 @@ test.serial('Returns falsy value if triggered by a PR', async t => {
t.falsy(await semanticRelease({repositoryUrl}));
t.is(
t.context.log.args[6][0],
t.context.log.args[8][0],
"This run was triggered by a pull request and therefore a new version won't be published."
);
});
@ -495,21 +604,16 @@ test.serial('Returns falsy value if triggered by a PR', async t => {
test.serial('Returns falsy value if not running from the configured branch', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repositoryUrl = await gitRepo(true);
const verifyConditions = stub().resolves();
const analyzeCommits = stub().resolves();
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves();
const publish = stub().resolves();
const options = {
branch: 'master',
repositoryUrl,
verifyConditions: [verifyConditions],
analyzeCommits,
verifyRelease,
generateNotes,
publish,
verifyConditions: stub().resolves(),
analyzeCommits: stub().resolves(),
verifyRelease: stub().resolves(),
generateNotes: stub().resolves(),
publish: stub().resolves(),
success: stub().resolves(),
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {
@ -530,7 +634,6 @@ test.serial('Returns falsy value if there is no relevant changes', async t => {
// Add commits to the master branch
await gitCommits(['First']);
const verifyConditions = stub().resolves();
const analyzeCommits = stub().resolves();
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves();
@ -539,11 +642,13 @@ test.serial('Returns falsy value if there is no relevant changes', async t => {
const options = {
branch: 'master',
repositoryUrl,
verifyConditions: [verifyConditions],
verifyConditions: [stub().resolves()],
analyzeCommits,
verifyRelease,
generateNotes,
publish,
success: stub().resolves(),
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {
@ -573,22 +678,17 @@ test.serial('Exclude commits with [skip release] or [release skip] from analysis
'Test commit\n\n commit body\n[skip release]',
'Test commit\n\n commit body\n[release skip]',
]);
const verifyConditions1 = stub().resolves();
const verifyConditions2 = stub().resolves();
const analyzeCommits = stub().resolves();
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves();
const publish = stub().resolves();
const config = {branch: 'master', repositoryUrl, globalOpt: 'global'};
const options = {
...config,
verifyConditions: [verifyConditions1, verifyConditions2],
verifyConditions: [stub().resolves(), stub().resolves()],
analyzeCommits,
verifyRelease,
generateNotes,
publish,
verifyRelease: stub().resolves(),
generateNotes: stub().resolves(),
publish: stub().resolves(),
success: stub().resolves(),
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {
@ -623,12 +723,60 @@ test.serial('Hide sensitive environment variable values from the logs', async t
await t.throws(semanticRelease(options));
t.regex(t.context.stdout.args[6][0], /Console: The token \[secure\] is invalid/);
t.regex(t.context.stdout.args[7][0], /Log: The token \[secure\] is invalid/);
t.regex(t.context.stdout.args[8][0], /Console: The token \[secure\] is invalid/);
t.regex(t.context.stdout.args[9][0], /Log: The token \[secure\] is invalid/);
t.regex(t.context.stderr.args[0][0], /Error: The token \[secure\] is invalid/);
t.regex(t.context.stderr.args[1][0], /Invalid token \[secure\]/);
});
test.serial('Log both plugins errors and errors thrown by "fail" plugin', async t => {
process.env.MY_TOKEN = 'secret token';
const repositoryUrl = await gitRepo(true);
const pluginError = new SemanticReleaseError('Plugin error', 'ERR');
const failError1 = new Error('Fail error 1');
const failError2 = new Error('Fail error 2');
const options = {
branch: 'master',
repositoryUrl,
verifyConditions: stub().rejects(pluginError),
fail: [stub().rejects(failError1), stub().rejects(failError2)],
};
const semanticRelease = proxyquire('..', {
'./lib/logger': t.context.logger,
'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
});
await t.throws(semanticRelease(options));
t.is(t.context.error.args[t.context.error.args.length - 2][1], failError1);
t.is(t.context.error.args[t.context.error.args.length - 1][1], failError2);
t.deepEqual(t.context.log.args[t.context.log.args.length - 1], ['%s Plugin error', 'ERR']);
});
test.serial('Call "fail" only if a plugin returns a SemanticReleaseError', async t => {
process.env.MY_TOKEN = 'secret token';
const repositoryUrl = await gitRepo(true);
const pluginError = new Error('Plugin error');
const fail = stub().resolves();
const options = {
branch: 'master',
repositoryUrl,
verifyConditions: stub().rejects(pluginError),
fail,
};
const semanticRelease = proxyquire('..', {
'./lib/logger': t.context.logger,
'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
});
await t.throws(semanticRelease(options));
t.true(fail.notCalled);
t.is(t.context.error.args[t.context.error.args.length - 1][1], pluginError);
});
test.serial('Throw SemanticReleaseError if repositoryUrl is not set and cannot be found from repo config', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
@ -662,6 +810,8 @@ test.serial('Throw an Error if plugin returns an unexpected value', async t => {
repositoryUrl,
verifyConditions: [verifyConditions],
analyzeCommits,
success: stub().resolves(),
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {
@ -672,7 +822,7 @@ test.serial('Throw an Error if plugin returns an unexpected value', async t => {
// Verify error message
t.regex(error.message, new RegExp(DEFINITIONS.analyzeCommits.output.message));
t.regex(error.message, /Received: 'string'/);
t.regex(error.details, /string/);
});
test.serial('Get all commits including the ones not in the shallow clone', async t => {
@ -685,20 +835,18 @@ test.serial('Get all commits including the ones not in the shallow clone', async
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
const notes = 'Release notes';
const verifyConditions = stub().resolves();
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
const publish = stub().resolves();
const config = {branch: 'master', repositoryUrl, globalOpt: 'global'};
const options = {
...config,
verifyConditions,
verifyConditions: stub().resolves(),
analyzeCommits,
verifyRelease,
generateNotes,
publish,
verifyRelease: stub().resolves(),
generateNotes: stub().resolves(notes),
publish: stub().resolves(),
success: stub().resolves(),
fail: stub().resolves(),
};
const semanticRelease = proxyquire('..', {

View File

@ -101,6 +101,7 @@ test.serial('Release patch, minor and major versions', async t => {
version: '0.0.0-dev',
repository: {url: repositoryUrl},
publishConfig: {registry: npmRegistry.url},
release: {success: false, fail: false},
});
// Create a npm-shrinkwrap.json file
await execa('npm', ['shrinkwrap'], {env: testEnv});
@ -298,7 +299,7 @@ test.serial('Exit with 1 if a plugin is not found', async t => {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
release: {analyzeCommits: 'non-existing-path'},
release: {analyzeCommits: 'non-existing-path', success: false, fail: false},
});
const {code, stderr} = await t.throws(execa(cli, [], {env}));
@ -316,7 +317,7 @@ test.serial('Exit with 1 if a shareable config is not found', async t => {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
release: {extends: 'non-existing-path'},
release: {extends: 'non-existing-path', success: false, fail: false},
});
const {code, stderr} = await t.throws(execa(cli, [], {env}));
@ -336,7 +337,7 @@ test.serial('Exit with 1 if a shareable config reference a not found plugin', as
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
release: {extends: './shareable.json'},
release: {extends: './shareable.json', success: false, fail: false},
});
await writeJson('./shareable.json', shareable);
@ -357,6 +358,7 @@ test.serial('Dry-run', async t => {
version: '0.0.0-dev',
repository: {url: repositoryUrl},
publishConfig: {registry: npmRegistry.url},
release: {success: false, fail: false},
});
/* Initial release */
@ -394,6 +396,7 @@ test.serial('Allow local releases with "noCi" option', async t => {
version: '0.0.0-dev',
repository: {url: repositoryUrl},
publishConfig: {registry: npmRegistry.url},
release: {success: false, fail: false},
});
/* Initial release */
@ -459,7 +462,17 @@ test.serial('Pass options via CLI arguments', async t => {
t.log('$ semantic-release');
const {stdout, code} = await execa(
cli,
['--verify-conditions', '@semantic-release/npm', '--publish', '@semantic-release/npm', '--debug'],
[
'--verify-conditions',
'@semantic-release/npm',
'--publish',
'@semantic-release/npm',
`--success`,
false,
`--fail`,
false,
'--debug',
],
{env}
);
t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`));
@ -515,7 +528,7 @@ test.serial('Run via JS API', async t => {
t.log('Commit a feature');
await gitCommits(['feat: Initial commit']);
t.log('$ Call semantic-release via API');
await semanticRelease();
await semanticRelease({fail: false, success: false});
// Verify package.json and has been updated
t.is((await readJson('./package.json')).version, version);
@ -545,7 +558,7 @@ test.serial('Log unexpected errors from plugins and exit with 1', async t => {
name: packageName,
version: '0.0.0-dev',
repository: {url: repositoryUrl},
release: {verifyConditions: pluginError},
release: {verifyConditions: pluginError, fail: false, success: false},
});
/* Initial release */
@ -572,7 +585,7 @@ test.serial('Log errors inheriting SemanticReleaseError and exit with 1', async
name: packageName,
version: '0.0.0-dev',
repository: {url: repositoryUrl},
release: {verifyConditions: pluginInheritedError},
release: {verifyConditions: pluginInheritedError, fail: false, success: false},
});
/* Initial release */

View File

@ -1,77 +0,0 @@
import test from 'ava';
import definitions from '../../lib/plugins/definitions';
test('The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(definitions.verifyConditions.config.validator({}));
t.false(definitions.verifyConditions.config.validator({path: null}));
t.true(definitions.verifyConditions.config.validator({path: 'plugin-path.js'}));
t.true(definitions.verifyConditions.config.validator());
t.true(definitions.verifyConditions.config.validator('plugin-path.js'));
t.true(definitions.verifyConditions.config.validator(() => {}));
t.true(definitions.verifyConditions.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "analyzeCommits" plugin is mandatory, and must be a single plugin definition', t => {
t.false(definitions.analyzeCommits.config.validator({}));
t.false(definitions.analyzeCommits.config.validator({path: null}));
t.false(definitions.analyzeCommits.config.validator([]));
t.false(definitions.analyzeCommits.config.validator());
t.true(definitions.analyzeCommits.config.validator({path: 'plugin-path.js'}));
t.true(definitions.analyzeCommits.config.validator('plugin-path.js'));
t.true(definitions.analyzeCommits.config.validator(() => {}));
});
test('The "verifyRelease" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(definitions.verifyRelease.config.validator({}));
t.false(definitions.verifyRelease.config.validator({path: null}));
t.true(definitions.verifyRelease.config.validator({path: 'plugin-path.js'}));
t.true(definitions.verifyRelease.config.validator());
t.true(definitions.verifyRelease.config.validator('plugin-path.js'));
t.true(definitions.verifyRelease.config.validator(() => {}));
t.true(definitions.verifyRelease.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "generateNotes" plugin, if defined, must be a single plugin definition', t => {
t.false(definitions.generateNotes.config.validator({}));
t.false(definitions.generateNotes.config.validator({path: null}));
t.false(definitions.generateNotes.config.validator([]));
t.true(definitions.generateNotes.config.validator());
t.true(definitions.generateNotes.config.validator({path: 'plugin-path.js'}));
t.true(definitions.generateNotes.config.validator('plugin-path.js'));
t.true(definitions.generateNotes.config.validator(() => {}));
});
test('The "publish" plugin is mandatory, and must be a single or an array of plugins definition', t => {
t.false(definitions.publish.config.validator({}));
t.false(definitions.publish.config.validator({path: null}));
t.false(definitions.publish.config.validator());
t.true(definitions.publish.config.validator({path: 'plugin-path.js'}));
t.true(definitions.publish.config.validator('plugin-path.js'));
t.true(definitions.publish.config.validator(() => {}));
t.true(definitions.publish.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "analyzeCommits" plugin output must be either undefined or a valid semver release type', t => {
t.false(definitions.analyzeCommits.output.validator('invalid'));
t.false(definitions.analyzeCommits.output.validator(1));
t.false(definitions.analyzeCommits.output.validator({}));
t.true(definitions.analyzeCommits.output.validator());
t.true(definitions.analyzeCommits.output.validator(null));
t.true(definitions.analyzeCommits.output.validator('major'));
});
test('The "generateNotes" plugin output, if defined, must be a string', t => {
t.false(definitions.generateNotes.output.validator(1));
t.false(definitions.generateNotes.output.validator({}));
t.true(definitions.generateNotes.output.validator());
t.true(definitions.generateNotes.output.validator(null));
t.true(definitions.generateNotes.output.validator(''));
t.true(definitions.generateNotes.output.validator('string'));
});

View File

@ -12,6 +12,7 @@ test.beforeEach(t => {
test('Normalize and load plugin from string', t => {
const plugin = normalize('verifyConditions', {}, {}, './test/fixtures/plugin-noop', t.context.logger);
t.is(plugin.pluginName, './test/fixtures/plugin-noop');
t.is(typeof plugin, 'function');
t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'verifyConditions', './test/fixtures/plugin-noop']);
});
@ -19,6 +20,7 @@ test('Normalize and load plugin from string', t => {
test('Normalize and load plugin from object', t => {
const plugin = normalize('publish', {}, {}, {path: './test/fixtures/plugin-noop'}, t.context.logger);
t.is(plugin.pluginName, './test/fixtures/plugin-noop');
t.is(typeof plugin, 'function');
t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'publish', './test/fixtures/plugin-noop']);
});
@ -32,6 +34,7 @@ test('Normalize and load plugin from a base file path', t => {
t.context.logger
);
t.is(plugin.pluginName, './plugin-noop');
t.is(typeof plugin, 'function');
t.deepEqual(t.context.log.args[0], [
'Load plugin %s from %s in shareable config %s',
@ -41,9 +44,40 @@ test('Normalize and load plugin from a base file path', t => {
]);
});
test('Normalize and load plugin from function', t => {
const plugin = normalize('', {}, {}, () => {}, t.context.logger);
test('Wrap plugin in a function that add the "pluginName" to the error"', async t => {
const plugin = normalize(
'verifyConditions',
{'./plugin-error': './test/fixtures'},
{},
'./plugin-error',
t.context.logger
);
const error = await t.throws(plugin());
t.is(error.pluginName, './plugin-error');
});
test('Wrap plugin in a function that add the "pluginName" to multiple errors"', async t => {
const plugin = normalize(
'verifyConditions',
{'./plugin-errors': './test/fixtures'},
{},
'./plugin-errors',
t.context.logger
);
const errors = [...(await t.throws(plugin()))];
for (const error of errors) {
t.is(error.pluginName, './plugin-errors');
}
});
test('Normalize and load plugin from function', t => {
const pluginFunction = () => {};
const plugin = normalize('', {}, {}, pluginFunction, t.context.logger);
t.is(plugin.pluginName, '[Function: pluginFunction]');
t.is(typeof plugin, 'function');
});
@ -54,18 +88,42 @@ test('Normalize and load plugin that retuns multiple functions', t => {
t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'verifyConditions', './test/fixtures/multi-plugin']);
});
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, {
validator: output => output === 1,
message: 'The output must be 1.',
});
test('Wrap "analyzeCommits" plugin in a function that validate the output of the plugin', async t => {
const analyzeCommits = stub().resolves(2);
const plugin = normalize('analyzeCommits', {}, {}, analyzeCommits, t.context.logger);
await t.notThrows(plugin());
const error = await t.throws(plugin());
pluginFunction.resolves(2);
const error = await t.throws(plugin(), Error);
t.is(error.message, 'The output must be 1. Received: 2');
t.is(error.code, 'EANALYZEOUTPUT');
t.is(error.name, 'SemanticReleaseError');
t.regex(error.details, /2/);
});
test('Wrap "generateNotes" plugin in a function that validate the output of the plugin', async t => {
const generateNotes = stub().resolves(2);
const plugin = normalize('generateNotes', {}, {}, generateNotes, t.context.logger);
const error = await t.throws(plugin());
t.is(error.code, 'ERELEASENOTESOUTPUT');
t.is(error.name, 'SemanticReleaseError');
t.regex(error.details, /2/);
});
test('Wrap "publish" plugin in a function that validate the output of the plugin', async t => {
const plugin = normalize(
'publish',
{'./plugin-identity': './test/fixtures'},
{},
'./plugin-identity',
t.context.logger
);
const error = await t.throws(plugin(2));
t.is(error.code, 'EPUBLISHOUTPUT');
t.is(error.name, 'SemanticReleaseError');
t.regex(error.details, /2/);
});
test('Plugin is called with "pluginConfig" (omitting "path", adding global config) and input', async t => {
@ -127,12 +185,8 @@ test('Always pass a defined "pluginConfig" for plugin defined with path', async
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));
t.is(error.code, 'EPLUGINCONF');
t.is(error.code, 'EPLUGIN');
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 => {

View File

@ -18,13 +18,32 @@ test('Execute each function in series passing the same input', async t => {
t.true(step2.calledBefore(step3));
});
test('Execute each function in series passing a transformed input', async t => {
test('With one step, returns the step values rather than an Array ', async t => {
const step1 = stub().resolves(1);
const result = await pipeline([step1])(0);
t.deepEqual(result, 1);
t.true(step1.calledWith(0));
});
test('With one step, throws the error rather than an AggregateError ', async t => {
const error = new Error('test error 1');
const step1 = stub().rejects(error);
const thrown = await t.throws(pipeline([step1])(0));
t.is(error, thrown);
});
test('Execute each function in series passing a transformed input from "getNextInput"', async t => {
const step1 = stub().resolves(1);
const step2 = stub().resolves(2);
const step3 = stub().resolves(3);
const step4 = stub().resolves(4);
const getNextInput = (lastResult, result) => lastResult + result;
const result = await pipeline([step1, step2, step3, step4])(0, false, (prevResult, result) => prevResult + result);
const result = await pipeline([step1, step2, step3, step4])(0, {settleAll: false, getNextInput});
t.deepEqual(result, [1, 2, 3, 4]);
t.true(step1.calledWith(0));
@ -36,22 +55,45 @@ test('Execute each function in series passing a transformed input', async t => {
t.true(step3.calledBefore(step4));
});
test('Execute each function in series passing the result of the previous one', async t => {
test('Execute each function in series passing the "lastResult" and "result" to "getNextInput"', async t => {
const step1 = stub().resolves(1);
const step2 = stub().resolves(2);
const step3 = stub().resolves(3);
const step4 = stub().resolves(4);
const getNextInput = stub().returnsArg(0);
const result = await pipeline([step1, step2, step3, step4])(0, false, (prevResult, result) => result);
const result = await pipeline([step1, step2, step3, step4])(5, {settleAll: false, getNextInput});
t.deepEqual(result, [1, 2, 3, 4]);
t.true(step1.calledWith(0));
t.true(step2.calledWith(1));
t.true(step3.calledWith(2));
t.true(step4.calledWith(3));
t.true(step1.calledBefore(step2));
t.true(step2.calledBefore(step3));
t.true(step3.calledBefore(step4));
t.deepEqual(getNextInput.args, [[5, 1], [5, 2], [5, 3], [5, 4]]);
});
test('Execute each function in series calling "transform" to modify the results', async t => {
const step1 = stub().resolves(1);
const step2 = stub().resolves(2);
const step3 = stub().resolves(3);
const step4 = stub().resolves(4);
const getNextInput = stub().returnsArg(0);
const transform = stub().callsFake(result => result + 1);
const result = await pipeline([step1, step2, step3, step4])(5, {getNextInput, transform});
t.deepEqual(result, [1 + 1, 2 + 1, 3 + 1, 4 + 1]);
t.deepEqual(getNextInput.args, [[5, 1 + 1], [5, 2 + 1], [5, 3 + 1], [5, 4 + 1]]);
});
test('Execute each function in series calling "transform" to modify the results with "settleAll"', async t => {
const step1 = stub().resolves(1);
const step2 = stub().resolves(2);
const step3 = stub().resolves(3);
const step4 = stub().resolves(4);
const getNextInput = stub().returnsArg(0);
const transform = stub().callsFake(result => result + 1);
const result = await pipeline([step1, step2, step3, step4])(5, {settleAll: true, getNextInput, transform});
t.deepEqual(result, [1 + 1, 2 + 1, 3 + 1, 4 + 1]);
t.deepEqual(getNextInput.args, [[5, 1 + 1], [5, 2 + 1], [5, 3 + 1], [5, 4 + 1]]);
});
test('Stop execution and throw error is a step rejects', async t => {
@ -89,7 +131,7 @@ test('Execute all even if a Promise rejects', async t => {
const step2 = stub().rejects(error1);
const step3 = stub().rejects(error2);
const errors = await t.throws(pipeline([step1, step2, step3])(0, true));
const errors = await t.throws(pipeline([step1, step2, step3])(0, {settleAll: true}));
t.deepEqual(Array.from(errors), [error1, error2]);
t.true(step1.calledWith(0));
@ -105,7 +147,7 @@ test('Throw all errors from all steps throwing an AggregateError', async t => {
const step1 = stub().rejects(new AggregateError([error1, error2]));
const step2 = stub().rejects(new AggregateError([error3, error4]));
const errors = await t.throws(pipeline([step1, step2])(0, true));
const errors = await t.throws(pipeline([step1, step2])(0, {settleAll: true}));
t.deepEqual(Array.from(errors), [error1, error2, error3, error4]);
t.true(step1.calledWith(0));
@ -119,10 +161,9 @@ test('Execute each function in series passing a transformed input even if a step
const step2 = stub().rejects(error2);
const step3 = stub().rejects(error3);
const step4 = stub().resolves(4);
const getNextInput = (prevResult, result) => prevResult + result;
const errors = await t.throws(
pipeline([step1, step2, step3, step4])(0, true, (prevResult, result) => prevResult + result)
);
const errors = await t.throws(pipeline([step1, step2, step3, step4])(0, {settleAll: true, getNextInput}));
t.deepEqual(Array.from(errors), [error2, error3]);
t.true(step1.calledWith(0));

View File

@ -28,6 +28,8 @@ test('Export default plugins', t => {
t.is(typeof plugins.verifyRelease, 'function');
t.is(typeof plugins.generateNotes, 'function');
t.is(typeof plugins.publish, 'function');
t.is(typeof plugins.success, 'function');
t.is(typeof plugins.fail, 'function');
});
test('Export plugins based on config', t => {
@ -48,6 +50,8 @@ test('Export plugins based on config', t => {
t.is(typeof plugins.verifyRelease, 'function');
t.is(typeof plugins.generateNotes, 'function');
t.is(typeof plugins.publish, 'function');
t.is(typeof plugins.success, 'function');
t.is(typeof plugins.fail, 'function');
});
test.serial('Export plugins loaded from the dependency of a shareable config module', async t => {
@ -76,6 +80,8 @@ test.serial('Export plugins loaded from the dependency of a shareable config mod
t.is(typeof plugins.verifyRelease, 'function');
t.is(typeof plugins.generateNotes, 'function');
t.is(typeof plugins.publish, 'function');
t.is(typeof plugins.success, 'function');
t.is(typeof plugins.fail, 'function');
});
test.serial('Export plugins loaded from the dependency of a shareable config file', async t => {
@ -101,6 +107,8 @@ test.serial('Export plugins loaded from the dependency of a shareable config fil
t.is(typeof plugins.verifyRelease, 'function');
t.is(typeof plugins.generateNotes, 'function');
t.is(typeof plugins.publish, 'function');
t.is(typeof plugins.success, 'function');
t.is(typeof plugins.fail, 'function');
});
test('Use default when only options are passed for a single plugin', t => {
@ -128,22 +136,10 @@ test('Merge global options with plugin options', async t => {
});
test('Throw an error if plugins configuration are missing a path for plugin pipeline', t => {
const errors = Array.from(
t.throws(() => getPlugins({verifyConditions: {}, verifyRelease: {}}, {}, t.context.logger))
);
const errors = Array.from(t.throws(() => getPlugins({verifyConditions: {}}, {}, t.context.logger)));
t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].code, 'EPLUGINCONF');
t.is(
errors[0].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.'
);
t.is(errors[1].name, 'SemanticReleaseError');
t.is(errors[1].code, 'EPLUGINCONF');
t.is(
errors[1].message,
'The "verifyRelease" 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.'
);
});
test('Throw an error if an array of plugin configuration is missing a path for plugin pipeline', t => {
@ -153,8 +149,4 @@ test('Throw an error if an array of plugin configuration is missing a path for p
t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].code, 'EPLUGINCONF');
t.is(
errors[0].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.'
);
});

View File

@ -29,29 +29,29 @@ test.afterEach.always(() => {
process.chdir(cwd);
});
test.serial('Return "false" if does not run on a git repository', async t => {
const dir = tempy.directory();
process.chdir(dir);
t.false(await verify({}, 'master', t.context.logger));
});
test.serial('Throw a AggregateError', async t => {
await gitRepo();
const errors = Array.from(await t.throws(verify({}, 'master', t.context.logger)));
t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].message, 'The repositoryUrl option is required');
t.is(errors[0].code, 'ENOREPOURL');
t.is(errors[1].name, 'SemanticReleaseError');
t.is(errors[1].message, 'The tagFormat template must compile to a valid Git tag format');
t.is(errors[1].code, 'EINVALIDTAGFORMAT');
t.is(errors[2].name, 'SemanticReleaseError');
t.is(errors[2].message, `The tagFormat template must contain the variable "\${version}" exactly once`);
t.is(errors[2].code, 'ETAGNOVERSION');
});
test.serial('Throw a SemanticReleaseError if does not run on a git repository', async t => {
const dir = tempy.directory();
process.chdir(dir);
const errors = Array.from(await t.throws(verify({}, 'master', t.context.logger)));
t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].code, 'ENOGITREPO');
});
test.serial('Throw a SemanticReleaseError if the "tagFormat" is not valid', async t => {
const repositoryUrl = await gitRepo(true);
const options = {repositoryUrl, tagFormat: `?\${version}`};
@ -59,7 +59,6 @@ test.serial('Throw a SemanticReleaseError if the "tagFormat" is not valid', asyn
const errors = Array.from(await t.throws(verify(options, 'master', t.context.logger)));
t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].message, 'The tagFormat template must compile to a valid Git tag format');
t.is(errors[0].code, 'EINVALIDTAGFORMAT');
});
@ -70,7 +69,6 @@ test.serial('Throw a SemanticReleaseError if the "tagFormat" does not contains t
const errors = Array.from(await t.throws(verify(options, 'master', t.context.logger)));
t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].message, `The tagFormat template must contain the variable "\${version}" exactly once`);
t.is(errors[0].code, 'ETAGNOVERSION');
});
@ -81,7 +79,6 @@ test.serial('Throw a SemanticReleaseError if the "tagFormat" contains multiple "
const errors = Array.from(await t.throws(verify(options, 'master', t.context.logger)));
t.is(errors[0].name, 'SemanticReleaseError');
t.is(errors[0].message, `The tagFormat template must contain the variable "\${version}" exactly once`);
t.is(errors[0].code, 'ETAGNOVERSION');
});