feat: support sharable configuration
Adds the options `extends`, which can be defined via configuration file or CLI arguments to a single path or an array of paths of shareable configuration. A shareable configuration is a file or a module that can be loaded with `require`. Options is defined by merging in the following order of priority: - CLI/API - Configuration file - Shareable configuration (from right to left) Options set in a shareable configuration can be unset by setting it to `null` or `undefined` in the main configuration file. If a default value applies to this property it will be used.
This commit is contained in:
parent
2fc538b607
commit
754b420fd6
@ -148,7 +148,9 @@ _[This is what happens under the hood.](https://github.com/semantic-release/cli#
|
||||
|
||||
## Options
|
||||
|
||||
You can pass options either via command line (in [kebab-case](https://lodash.com/docs#kebabCase)) or in the `release` field of your `package.json` (in [camelCase](https://lodash.com/docs#camelCase)). The following two examples are the same, but CLI arguments take precedence.
|
||||
You can pass options either via command line (in [kebab-case](https://lodash.com/docs#kebabCase)) or in the `release` field of your `package.json` (in [camelCase](https://lodash.com/docs#camelCase)). Alternatively the configuration can also be defined in `.releaserc.yml`, `.releaserc.js`, `.releaserc.js` or `release.config.js`.
|
||||
|
||||
The following two examples are the same, but CLI arguments take precedence.
|
||||
|
||||
##### CLI
|
||||
```bash
|
||||
@ -169,6 +171,7 @@ These options are currently available:
|
||||
- `branch`: The branch on which releases should happen. Default: `'master'`
|
||||
- `repositoryUrl`: The git repository URL. Default: `repository` property in `package.json` or git origin url. Any valid git url format is supported (See [Git protocols](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols)). If the [Github plugin](https://github.com/semantic-release/github) is used the URL must be a valid Github URL that include the `owner`, the `repository` name and the `host`. The Github shorthand URL is not supported.
|
||||
- `dry-run`: Dry-run mode, skipping verifyConditions, publishing and release, printing next version and release notes
|
||||
- `extends`: Array of module or files path containing a shareable configuration. Options defined via CLI or in the `release` property will take precedence over the one defined in a shareable configuration.
|
||||
- `debug`: Output debugging information
|
||||
|
||||
_A few notes on `npm` config_:
|
||||
@ -198,6 +201,8 @@ There are numerous steps where you can customize `semantic-release`’s behavior
|
||||
semantic-release --analyze-commits="npm-module-name"
|
||||
```
|
||||
|
||||
**Note**: The plugin CLI arguments can be only used to override the plugins to use. Plugins options cannot be defined via CLI arguments and must be defined in the main configuration file or in a shareable config.
|
||||
|
||||
A plugin itself is an async function that always receives three arguments.
|
||||
|
||||
```js
|
||||
|
5
cli.js
5
cli.js
@ -1,4 +1,5 @@
|
||||
const program = require('commander');
|
||||
const {pickBy, isUndefined} = require('lodash');
|
||||
const logger = require('./lib/logger');
|
||||
|
||||
function list(values) {
|
||||
@ -11,6 +12,7 @@ module.exports = async () => {
|
||||
.description('Run automated package publishing')
|
||||
.option('-b, --branch <branch>', 'Branch to release from')
|
||||
.option('-r, --repositoryUrl <repositoryUrl>', 'Git repository URL')
|
||||
.option('-e, --extends <paths>', 'Comma separated list of shareable config paths or packages name', list)
|
||||
.option(
|
||||
'--verify-conditions <paths>',
|
||||
'Comma separated list of paths or packages name for the verifyConditions plugin(s)',
|
||||
@ -42,7 +44,8 @@ module.exports = async () => {
|
||||
program.outputHelp();
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
await require('.')(program.opts());
|
||||
// Remove option with undefined values, as commander.js sets non defined options as `undefined`
|
||||
await require('.')(pickBy(program.opts(), value => !isUndefined(value)));
|
||||
}
|
||||
} catch (err) {
|
||||
// If error is a SemanticReleaseError then it's an expected exception case (no release to be done, running on a PR etc..) and the cli will return with 0
|
||||
|
4
index.js
4
index.js
@ -15,10 +15,6 @@ module.exports = async opts => {
|
||||
const config = await getConfig(opts, logger);
|
||||
const {plugins, options} = config;
|
||||
|
||||
if (!options.repositoryUrl) {
|
||||
throw new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL');
|
||||
}
|
||||
|
||||
logger.log('Run automated release from branch %s', options.branch);
|
||||
|
||||
if (!options.dryRun) {
|
||||
|
@ -1,14 +1,56 @@
|
||||
const readPkgUp = require('read-pkg-up');
|
||||
const {defaults} = require('lodash');
|
||||
const {castArray, pickBy, isUndefined, isNull, isString, isPlainObject} = require('lodash');
|
||||
const cosmiconfig = require('cosmiconfig');
|
||||
const resolveFrom = require('resolve-from');
|
||||
const SemanticReleaseError = require('@semantic-release/error');
|
||||
const debug = require('debug')('semantic-release:config');
|
||||
const {repoUrl} = require('./git');
|
||||
const PLUGINS_DEFINITION = require('./plugins/definitions');
|
||||
const plugins = require('./plugins');
|
||||
|
||||
module.exports = async (opts, logger) => {
|
||||
const {config} = (await cosmiconfig('release', {rcExtensions: true}).load(process.cwd())) || {};
|
||||
const options = defaults(opts, config, {branch: 'master', repositoryUrl: (await pkgRepoUrl()) || (await repoUrl())});
|
||||
// Merge config file options and CLI/API options
|
||||
let options = {...config, ...opts};
|
||||
const pluginsPath = {};
|
||||
let extendPaths;
|
||||
({extends: extendPaths, ...options} = options);
|
||||
if (extendPaths) {
|
||||
// If `extends` is defined, load and merge each shareable config with `options`
|
||||
options = {
|
||||
...castArray(extendPaths).reduce((result, extendPath) => {
|
||||
const extendsOpts = require(resolveFrom.silent(__dirname, extendPath) ||
|
||||
resolveFrom(process.cwd(), extendPath));
|
||||
|
||||
// For each plugin defined in a shareable config, save in `pluginsPath` the extendable config path,
|
||||
// so those plugin will be loaded relatively to the config file
|
||||
Object.keys(extendsOpts).reduce((pluginsPath, option) => {
|
||||
if (PLUGINS_DEFINITION[option]) {
|
||||
castArray(extendsOpts[option])
|
||||
.filter(plugin => isString(plugin) || (isPlainObject(plugin) && isString(plugin.path)))
|
||||
.map(plugin => (isString(plugin) ? plugin : plugin.path))
|
||||
.forEach(plugin => {
|
||||
pluginsPath[plugin] = extendPath;
|
||||
});
|
||||
}
|
||||
return pluginsPath;
|
||||
}, pluginsPath);
|
||||
|
||||
return {...result, ...extendsOpts};
|
||||
}, {}),
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
// Set default options values if not defined yet
|
||||
options = {
|
||||
branch: 'master',
|
||||
repositoryUrl: (await pkgRepoUrl()) || (await repoUrl()),
|
||||
// Remove `null` and `undefined` options so they can be replaced with default ones
|
||||
...pickBy(options, option => !isUndefined(option) && !isNull(option)),
|
||||
};
|
||||
|
||||
debug('options values: %O', Object.keys(options));
|
||||
debug('name: %O', options.name);
|
||||
debug('branch: %O', options.branch);
|
||||
debug('repositoryUrl: %O', options.repositoryUrl);
|
||||
@ -18,10 +60,14 @@ module.exports = async (opts, logger) => {
|
||||
debug('verifyRelease: %O', options.verifyRelease);
|
||||
debug('publish: %O', options.publish);
|
||||
|
||||
return {options, plugins: await plugins(options, logger)};
|
||||
if (!options.repositoryUrl) {
|
||||
throw new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL');
|
||||
}
|
||||
|
||||
return {options, plugins: await plugins(options, pluginsPath, logger)};
|
||||
};
|
||||
|
||||
async function pkgRepoUrl() {
|
||||
const {pkg} = await readPkgUp();
|
||||
return pkg && pkg.repository ? pkg.repository.url : null;
|
||||
return pkg && pkg.repository ? pkg.repository.url : undefined;
|
||||
}
|
||||
|
@ -73,10 +73,10 @@ async function gitHead() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string|null} The value of the remote git URL.
|
||||
* @return {string|undefined} The value of the remote git URL.
|
||||
*/
|
||||
async function repoUrl() {
|
||||
return (await execa.stdout('git', ['remote', 'get-url', 'origin'], {reject: false})) || null;
|
||||
return (await execa.stdout('git', ['remote', 'get-url', 'origin'], {reject: false})) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,11 +1,12 @@
|
||||
const {isArray, isObject, omit} = require('lodash');
|
||||
const DEFINITIONS = require('./definitions');
|
||||
const SemanticReleaseError = require('@semantic-release/error');
|
||||
const PLUGINS_DEFINITION = require('./definitions');
|
||||
const pipeline = require('./pipeline');
|
||||
const normalize = require('./normalize');
|
||||
|
||||
module.exports = (options, logger) =>
|
||||
Object.keys(DEFINITIONS).reduce((plugins, pluginType) => {
|
||||
const {config, output, default: def} = DEFINITIONS[pluginType];
|
||||
module.exports = (options, pluginsPath, logger) =>
|
||||
Object.keys(PLUGINS_DEFINITION).reduce((plugins, pluginType) => {
|
||||
const {config, output, default: def} = PLUGINS_DEFINITION[pluginType];
|
||||
let pluginConfs;
|
||||
if (options[pluginType]) {
|
||||
// If an object is passed and the path is missing, set the default one for single plugins
|
||||
@ -13,18 +14,18 @@ module.exports = (options, logger) =>
|
||||
options[pluginType].path = def;
|
||||
}
|
||||
if (config && !config.validator(options[pluginType])) {
|
||||
throw new Error(config.message);
|
||||
throw new SemanticReleaseError(config.message, 'EPLUGINCONF');
|
||||
}
|
||||
pluginConfs = options[pluginType];
|
||||
} else {
|
||||
pluginConfs = def;
|
||||
}
|
||||
|
||||
const globalOpts = omit(options, Object.keys(DEFINITIONS));
|
||||
const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITION));
|
||||
|
||||
plugins[pluginType] = isArray(pluginConfs)
|
||||
? pipeline(pluginConfs.map(conf => normalize(pluginType, globalOpts, conf, logger, output)))
|
||||
: normalize(pluginType, globalOpts, pluginConfs, logger, output);
|
||||
? pipeline(pluginConfs.map(conf => normalize(pluginType, pluginsPath, globalOpts, conf, logger, output)))
|
||||
: normalize(pluginType, pluginsPath, globalOpts, pluginConfs, logger, output);
|
||||
|
||||
return plugins;
|
||||
}, {});
|
||||
|
@ -1,17 +1,29 @@
|
||||
const {dirname} = require('path');
|
||||
const {inspect} = require('util');
|
||||
const SemanticReleaseError = require('@semantic-release/error');
|
||||
const {isString, isObject, isFunction, noop, cloneDeep} = require('lodash');
|
||||
const importFrom = require('import-from');
|
||||
const resolveFrom = require('resolve-from');
|
||||
|
||||
module.exports = (pluginType, globalOpts, pluginOpts, logger, validator) => {
|
||||
module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger, validator) => {
|
||||
if (!pluginOpts) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
const {path, ...config} = isString(pluginOpts) || isFunction(pluginOpts) ? {path: pluginOpts} : pluginOpts;
|
||||
if (!isFunction(pluginOpts)) {
|
||||
logger.log('Load plugin %s from %s', pluginType, path);
|
||||
if (pluginsPath[path]) {
|
||||
logger.log('Load plugin %s from %s in shareable config %s', pluginType, path, pluginsPath[path]);
|
||||
} else {
|
||||
logger.log('Load plugin %s from %s', pluginType, path);
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = isFunction(path) ? path : importFrom.silent(__dirname, path) || importFrom(process.cwd(), path);
|
||||
const basePath = pluginsPath[path]
|
||||
? dirname(resolveFrom.silent(__dirname, pluginsPath[path]) || resolveFrom(process.cwd(), pluginsPath[path]))
|
||||
: __dirname;
|
||||
const plugin = isFunction(path)
|
||||
? path
|
||||
: require(resolveFrom.silent(basePath, path) || resolveFrom(process.cwd(), path));
|
||||
|
||||
let func;
|
||||
if (isFunction(plugin)) {
|
||||
@ -19,8 +31,9 @@ module.exports = (pluginType, globalOpts, pluginOpts, logger, validator) => {
|
||||
} else if (isObject(plugin) && plugin[pluginType] && isFunction(plugin[pluginType])) {
|
||||
func = plugin[pluginType].bind(null, cloneDeep({...globalOpts, ...config}));
|
||||
} else {
|
||||
throw new Error(
|
||||
`The ${pluginType} plugin must be a function, or an object with a function in the property ${pluginType}.`
|
||||
throw new SemanticReleaseError(
|
||||
`The ${pluginType} plugin must be a function, or an object with a function in the property ${pluginType}.`,
|
||||
'EPLUGINCONF'
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -28,12 +28,12 @@
|
||||
"execa": "^0.8.0",
|
||||
"get-stream": "^3.0.0",
|
||||
"git-log-parser": "^1.2.0",
|
||||
"import-from": "^2.1.0",
|
||||
"lodash": "^4.0.0",
|
||||
"marked": "^0.3.6",
|
||||
"marked-terminal": "^2.0.0",
|
||||
"p-reduce": "^1.0.0",
|
||||
"read-pkg-up": "^3.0.0",
|
||||
"resolve-from": "^4.0.0",
|
||||
"semver": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
2
test/fixtures/index.js
vendored
Normal file
2
test/fixtures/index.js
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
module.exports = () => {};
|
||||
|
@ -1,5 +1,7 @@
|
||||
import {format} from 'util';
|
||||
import test from 'ava';
|
||||
import {writeFile, writeJson} from 'fs-extra';
|
||||
import {writeFile, outputJson} from 'fs-extra';
|
||||
import {omit} from 'lodash';
|
||||
import proxyquire from 'proxyquire';
|
||||
import {stub} from 'sinon';
|
||||
import yaml from 'js-yaml';
|
||||
@ -30,7 +32,7 @@ test.serial('Default values, reading repositoryUrl from package.json', async t =
|
||||
// Add remote.origin.url config
|
||||
await gitAddConfig('remote.origin.url', 'git@repo.com:owner/module.git');
|
||||
// Create package.json in repository root
|
||||
await writeJson('./package.json', pkg);
|
||||
await outputJson('./package.json', pkg);
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
@ -57,7 +59,7 @@ test.serial('Default values, reading repositoryUrl (http url) from package.json
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json in repository root
|
||||
await writeJson('./package.json', pkg);
|
||||
await outputJson('./package.json', pkg);
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
@ -78,7 +80,7 @@ test.serial('Read options from package.json', async t => {
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json in repository root
|
||||
await writeJson('./package.json', {release});
|
||||
await outputJson('./package.json', {release});
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
@ -118,7 +120,7 @@ test.serial('Read options from .releaserc.json', async t => {
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json in repository root
|
||||
await writeJson('.releaserc.json', release);
|
||||
await outputJson('.releaserc.json', release);
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
@ -168,7 +170,7 @@ test.serial('Read options from release.config.js', async t => {
|
||||
t.deepEqual(t.context.plugins.args[0][0], release);
|
||||
});
|
||||
|
||||
test.serial('Prioritise cli parameters over file configuration and git repo', async t => {
|
||||
test.serial('Prioritise CLI/API parameters over file configuration and git repo', async t => {
|
||||
const release = {
|
||||
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_pkg'},
|
||||
branch: 'branch_pkg',
|
||||
@ -185,12 +187,266 @@ test.serial('Prioritise cli parameters over file configuration and git repo', as
|
||||
// Create a clone
|
||||
await gitShallowClone(repo);
|
||||
// Create package.json in repository root
|
||||
await writeJson('./package.json', pkg);
|
||||
await outputJson('./package.json', pkg);
|
||||
|
||||
const result = await t.context.getConfig(options);
|
||||
|
||||
// Verify the options contains the plugin config from cli
|
||||
// Verify the options contains the plugin config from CLI/API
|
||||
t.deepEqual(result.options, options);
|
||||
// Verify the plugins module is called with the plugin options from cli
|
||||
// Verify the plugins module is called with the plugin options from CLI/API
|
||||
t.deepEqual(t.context.plugins.args[0][0], options);
|
||||
});
|
||||
|
||||
test.serial('Read configuration from file path in "extends"', async t => {
|
||||
const release = {extends: './shareable.json'};
|
||||
const shareable = {
|
||||
analyzeCommits: 'analyzeCommits',
|
||||
generateNotes: 'generateNotes',
|
||||
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
|
||||
branch: 'test_branch',
|
||||
repositoryUrl: 'git+https://hostname.com/owner/module.git',
|
||||
};
|
||||
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json and shareable.json in repository root
|
||||
await outputJson('./package.json', {release});
|
||||
await outputJson('./shareable.json', shareable);
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
// Verify the options contains the plugin config from shareable.json
|
||||
t.deepEqual(options, shareable);
|
||||
// Verify the plugins module is called with the plugin options from shareable.json
|
||||
t.deepEqual(t.context.plugins.args[0][0], shareable);
|
||||
t.deepEqual(t.context.plugins.args[0][1], {
|
||||
analyzeCommits: './shareable.json',
|
||||
generateNotes: './shareable.json',
|
||||
getLastRelease: './shareable.json',
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Read configuration from module path in "extends"', async t => {
|
||||
const release = {extends: 'shareable'};
|
||||
const shareable = {
|
||||
analyzeCommits: 'analyzeCommits',
|
||||
generateNotes: 'generateNotes',
|
||||
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
|
||||
branch: 'test_branch',
|
||||
repositoryUrl: 'git+https://hostname.com/owner/module.git',
|
||||
};
|
||||
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json and shareable.json in repository root
|
||||
await outputJson('./package.json', {release});
|
||||
await outputJson('./node_modules/shareable/index.json', shareable);
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
// Verify the options contains the plugin config from shareable.json
|
||||
t.deepEqual(options, shareable);
|
||||
// Verify the plugins module is called with the plugin options from shareable.json
|
||||
t.deepEqual(t.context.plugins.args[0][0], shareable);
|
||||
t.deepEqual(t.context.plugins.args[0][1], {
|
||||
analyzeCommits: 'shareable',
|
||||
generateNotes: 'shareable',
|
||||
getLastRelease: 'shareable',
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Read configuration from an array of paths in "extends"', async t => {
|
||||
const release = {extends: ['./shareable1.json', './shareable2.json']};
|
||||
const shareable1 = {
|
||||
analyzeCommits: 'analyzeCommits1',
|
||||
getLastRelease: {path: 'getLastRelease1', param: 'getLastRelease_param1'},
|
||||
branch: 'test_branch',
|
||||
repositoryUrl: 'git+https://hostname.com/owner/module.git',
|
||||
};
|
||||
|
||||
const shareable2 = {
|
||||
analyzeCommits: 'analyzeCommits2',
|
||||
generateNotes: 'generateNotes2',
|
||||
getLastRelease: {path: 'getLastRelease2', param: 'getLastRelease_param2'},
|
||||
branch: 'test_branch',
|
||||
};
|
||||
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json and shareable.json in repository root
|
||||
await outputJson('./package.json', {release});
|
||||
await outputJson('./shareable1.json', shareable1);
|
||||
await outputJson('./shareable2.json', shareable2);
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
// Verify the options contains the plugin config from shareable1.json and shareable2.json
|
||||
t.deepEqual(options, {...shareable1, ...shareable2});
|
||||
// Verify the plugins module is called with the plugin options from shareable1.json and shareable2.json
|
||||
t.deepEqual(t.context.plugins.args[0][0], {...shareable1, ...shareable2});
|
||||
t.deepEqual(t.context.plugins.args[0][1], {
|
||||
analyzeCommits1: './shareable1.json',
|
||||
analyzeCommits2: './shareable2.json',
|
||||
generateNotes2: './shareable2.json',
|
||||
getLastRelease1: './shareable1.json',
|
||||
getLastRelease2: './shareable2.json',
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Prioritize configuration from config file over "extends"', async t => {
|
||||
const release = {
|
||||
extends: './shareable.json',
|
||||
branch: 'test_pkg',
|
||||
generateNotes: 'generateNotes',
|
||||
publish: [{path: 'publishPkg', param: 'publishPkg_param'}],
|
||||
};
|
||||
const shareable = {
|
||||
analyzeCommits: 'analyzeCommits',
|
||||
generateNotes: 'generateNotesShareable',
|
||||
publish: [{path: 'publishShareable', param: 'publishShareable_param'}],
|
||||
branch: 'test_branch',
|
||||
repositoryUrl: 'git+https://hostname.com/owner/module.git',
|
||||
};
|
||||
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json and shareable.json in repository root
|
||||
await outputJson('./package.json', {release});
|
||||
await outputJson('./shareable.json', shareable);
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
// Verify the options contains the plugin config from package.json and shareable.json
|
||||
t.deepEqual(options, omit({...shareable, ...release}, 'extends'));
|
||||
// Verify the plugins module is called with the plugin options from package.json and shareable.json
|
||||
t.deepEqual(t.context.plugins.args[0][0], omit({...shareable, ...release}, 'extends'));
|
||||
t.deepEqual(t.context.plugins.args[0][1], {
|
||||
analyzeCommits: './shareable.json',
|
||||
generateNotesShareable: './shareable.json',
|
||||
publishShareable: './shareable.json',
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Prioritize configuration from cli/API options over "extends"', async t => {
|
||||
const opts = {
|
||||
extends: './shareable2.json',
|
||||
branch: 'branch_opts',
|
||||
publish: [{path: 'publishOpts', param: 'publishOpts_param'}],
|
||||
repositoryUrl: 'git+https://hostname.com/owner/module.git',
|
||||
};
|
||||
const release = {
|
||||
extends: './shareable1.json',
|
||||
branch: 'branch_pkg',
|
||||
generateNotes: 'generateNotes',
|
||||
publish: [{path: 'publishPkg', param: 'publishPkg_param'}],
|
||||
};
|
||||
const shareable1 = {
|
||||
analyzeCommits: 'analyzeCommits1',
|
||||
generateNotes: 'generateNotesShareable1',
|
||||
publish: [{path: 'publishShareable', param: 'publishShareable_param1'}],
|
||||
branch: 'test_branch1',
|
||||
repositoryUrl: 'git+https://hostname.com/owner/module.git',
|
||||
};
|
||||
const shareable2 = {
|
||||
analyzeCommits: 'analyzeCommits2',
|
||||
publish: [{path: 'publishShareable', param: 'publishShareable_param2'}],
|
||||
branch: 'test_branch2',
|
||||
};
|
||||
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json, shareable1.json and shareable2.json in repository root
|
||||
await outputJson('./package.json', {release});
|
||||
await outputJson('./shareable1.json', shareable1);
|
||||
await outputJson('./shareable2.json', shareable2);
|
||||
|
||||
const {options} = await t.context.getConfig(opts);
|
||||
|
||||
// Verify the options contains the plugin config from package.json and shareable2.json
|
||||
t.deepEqual(options, omit({...shareable2, ...release, ...opts}, 'extends'));
|
||||
// Verify the plugins module is called with the plugin options from package.json and shareable2.json
|
||||
t.deepEqual(t.context.plugins.args[0][0], omit({...shareable2, ...release, ...opts}, 'extends'));
|
||||
});
|
||||
|
||||
test.serial('Allow to unset properties defined in shareable config with "null"', async t => {
|
||||
const release = {
|
||||
extends: './shareable.json',
|
||||
getLastRelease: null,
|
||||
branch: 'test_branch',
|
||||
repositoryUrl: 'git+https://hostname.com/owner/module.git',
|
||||
};
|
||||
const shareable = {
|
||||
generateNotes: 'generateNotes',
|
||||
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
|
||||
};
|
||||
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json and shareable.json in repository root
|
||||
await outputJson('./package.json', {release});
|
||||
await outputJson('./shareable.json', shareable);
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
// Verify the options contains the plugin config from shareable.json
|
||||
t.deepEqual(options, {...omit(shareable, 'getLastRelease'), ...omit(release, ['extends', 'getLastRelease'])});
|
||||
// Verify the plugins module is called with the plugin options from shareable.json
|
||||
t.deepEqual(t.context.plugins.args[0][0], {
|
||||
...omit(shareable, 'getLastRelease'),
|
||||
...omit(release, ['extends', 'getLastRelease']),
|
||||
});
|
||||
t.deepEqual(t.context.plugins.args[0][1], {
|
||||
generateNotes: './shareable.json',
|
||||
getLastRelease: './shareable.json',
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Allow to unset properties defined in shareable config with "undefined"', async t => {
|
||||
const release = {
|
||||
extends: './shareable.json',
|
||||
getLastRelease: undefined,
|
||||
branch: 'test_branch',
|
||||
repositoryUrl: 'git+https://hostname.com/owner/module.git',
|
||||
};
|
||||
const shareable = {
|
||||
generateNotes: 'generateNotes',
|
||||
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
|
||||
};
|
||||
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json and release.config.js in repository root
|
||||
// await outputJson('./package.json', {release});
|
||||
await writeFile('release.config.js', `module.exports = ${format(release)}`);
|
||||
await outputJson('./shareable.json', shareable);
|
||||
|
||||
const {options} = await t.context.getConfig();
|
||||
|
||||
// Verify the options contains the plugin config from shareable.json
|
||||
t.deepEqual(options, {...omit(shareable, 'getLastRelease'), ...omit(release, ['extends', 'getLastRelease'])});
|
||||
// Verify the plugins module is called with the plugin options from shareable.json
|
||||
t.deepEqual(t.context.plugins.args[0][0], {
|
||||
...omit(shareable, 'getLastRelease'),
|
||||
...omit(release, ['extends', 'getLastRelease']),
|
||||
});
|
||||
t.deepEqual(t.context.plugins.args[0][1], {
|
||||
generateNotes: './shareable.json',
|
||||
getLastRelease: './shareable.json',
|
||||
});
|
||||
});
|
||||
|
||||
test.serial('Throw an Error if one of the shareable config cannot be found', async t => {
|
||||
const release = {extends: ['./shareable1.json', 'non-existing-path']};
|
||||
const shareable = {analyzeCommits: 'analyzeCommits'};
|
||||
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
// Create package.json and shareable.json in repository root
|
||||
await outputJson('./package.json', {release});
|
||||
await outputJson('./shareable1.json', shareable);
|
||||
|
||||
const error = await t.throws(t.context.getConfig(), Error);
|
||||
|
||||
t.is(error.message, "Cannot find module 'non-existing-path'");
|
||||
t.is(error.code, 'MODULE_NOT_FOUND');
|
||||
});
|
||||
|
@ -120,9 +120,9 @@ test.serial('Return git remote repository url set while cloning', async t => {
|
||||
t.is(await repoUrl(), fileUrl(repo));
|
||||
});
|
||||
|
||||
test.serial('Return "null" if git repository url is not set', async t => {
|
||||
test.serial('Return "undefined" if git repository url is not set', async t => {
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
await gitRepo();
|
||||
|
||||
t.is(await repoUrl(), null);
|
||||
t.is(await repoUrl(), undefined);
|
||||
});
|
||||
|
@ -402,6 +402,45 @@ test.serial('Exit with 1 if a plugin is not found', async t => {
|
||||
t.regex(stderr, /Cannot find module/);
|
||||
});
|
||||
|
||||
test.serial('Exit with 1 if a shareable config is not found', async t => {
|
||||
const packageName = 'test-config-not-found';
|
||||
const owner = 'test-repo';
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
t.log('Create git repository');
|
||||
await gitRepo();
|
||||
await writeJson('./package.json', {
|
||||
name: packageName,
|
||||
version: '0.0.0-dev',
|
||||
repository: {url: `git+https://github.com/${owner}/${packageName}`},
|
||||
release: {extends: 'non-existing-path'},
|
||||
});
|
||||
|
||||
const {code, stderr} = await t.throws(execa(cli, [], {env}));
|
||||
t.is(code, 1);
|
||||
t.regex(stderr, /Cannot find module/);
|
||||
});
|
||||
|
||||
test.serial('Exit with 1 if a shareable config reference a not found plugin', async t => {
|
||||
const packageName = 'test-config-ref-not-found';
|
||||
const owner = 'test-repo';
|
||||
const shareable = {getLastRelease: 'non-existing-path'};
|
||||
|
||||
// Create a git repository, set the current working directory at the root of the repo
|
||||
t.log('Create git repository');
|
||||
await gitRepo();
|
||||
await writeJson('./package.json', {
|
||||
name: packageName,
|
||||
version: '0.0.0-dev',
|
||||
repository: {url: `git+https://github.com/${owner}/${packageName}`},
|
||||
release: {extends: './shareable.json'},
|
||||
});
|
||||
await writeJson('./shareable.json', shareable);
|
||||
|
||||
const {code, stderr} = await t.throws(execa(cli, [], {env}));
|
||||
t.is(code, 1);
|
||||
t.regex(stderr, /Cannot find module/);
|
||||
});
|
||||
|
||||
test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', async t => {
|
||||
const packageName = 'test-recovery';
|
||||
const owner = 'test-repo';
|
||||
|
@ -10,27 +10,45 @@ test.beforeEach(t => {
|
||||
});
|
||||
|
||||
test('Normalize and load plugin from string', t => {
|
||||
const plugin = normalize('verifyConditions', {}, './test/fixtures/plugin-noop', t.context.logger);
|
||||
const plugin = normalize('verifyConditions', {}, {}, './test/fixtures/plugin-noop', t.context.logger);
|
||||
|
||||
t.is(typeof plugin, 'function');
|
||||
t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'verifyConditions', './test/fixtures/plugin-noop']);
|
||||
});
|
||||
|
||||
test('Normalize and load plugin from object', t => {
|
||||
const plugin = normalize('publish', {}, {path: './test/fixtures/plugin-noop'}, t.context.logger);
|
||||
const plugin = normalize('publish', {}, {}, {path: './test/fixtures/plugin-noop'}, t.context.logger);
|
||||
|
||||
t.is(typeof plugin, 'function');
|
||||
t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'publish', './test/fixtures/plugin-noop']);
|
||||
});
|
||||
|
||||
test('Normalize and load plugin from a base file path', t => {
|
||||
const plugin = normalize(
|
||||
'verifyConditions',
|
||||
{'./plugin-noop': './test/fixtures'},
|
||||
{},
|
||||
'./plugin-noop',
|
||||
t.context.logger
|
||||
);
|
||||
|
||||
t.is(typeof plugin, 'function');
|
||||
t.deepEqual(t.context.log.args[0], [
|
||||
'Load plugin %s from %s in shareable config %s',
|
||||
'verifyConditions',
|
||||
'./plugin-noop',
|
||||
'./test/fixtures',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Normalize and load plugin from function', t => {
|
||||
const plugin = normalize('', {}, () => {}, t.context.logger);
|
||||
const plugin = normalize('', {}, {}, () => {}, t.context.logger);
|
||||
|
||||
t.is(typeof plugin, 'function');
|
||||
});
|
||||
|
||||
test('Normalize and load plugin that retuns multiple functions', t => {
|
||||
const plugin = normalize('verifyConditions', {}, './test/fixtures/multi-plugin', t.context.logger);
|
||||
const plugin = normalize('verifyConditions', {}, {}, './test/fixtures/multi-plugin', t.context.logger);
|
||||
|
||||
t.is(typeof plugin, 'function');
|
||||
t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'verifyConditions', './test/fixtures/multi-plugin']);
|
||||
@ -38,7 +56,7 @@ test('Normalize and load plugin that retuns multiple functions', t => {
|
||||
|
||||
test('Wrap plugin in a function that validate the output of the plugin', async t => {
|
||||
const pluginFunction = stub().resolves(1);
|
||||
const plugin = normalize('', {}, pluginFunction, t.context.logger, {
|
||||
const plugin = normalize('', {}, {}, pluginFunction, t.context.logger, {
|
||||
validator: output => output === 1,
|
||||
message: 'The output must be 1.',
|
||||
});
|
||||
@ -54,7 +72,7 @@ test('Plugin is called with "pluginConfig" (omitting "path", adding global confi
|
||||
const pluginFunction = stub().resolves();
|
||||
const conf = {path: pluginFunction, conf: 'confValue'};
|
||||
const globalConf = {global: 'globalValue'};
|
||||
const plugin = normalize('', globalConf, conf, t.context.logger);
|
||||
const plugin = normalize('', {}, globalConf, conf, t.context.logger);
|
||||
await plugin('param');
|
||||
|
||||
t.true(pluginFunction.calledWith({conf: 'confValue', global: 'globalValue'}, 'param'));
|
||||
@ -66,7 +84,7 @@ test('Prevent plugins to modify "pluginConfig"', async t => {
|
||||
});
|
||||
const conf = {path: pluginFunction, conf: {subConf: 'originalConf'}};
|
||||
const globalConf = {globalConf: {globalSubConf: 'originalGlobalConf'}};
|
||||
const plugin = normalize('', globalConf, conf, t.context.logger);
|
||||
const plugin = normalize('', {}, globalConf, conf, t.context.logger);
|
||||
await plugin();
|
||||
|
||||
t.is(conf.conf.subConf, 'originalConf');
|
||||
@ -78,7 +96,7 @@ test('Prevent plugins to modify its input', async t => {
|
||||
options.param.subParam = 'otherParam';
|
||||
});
|
||||
const input = {param: {subParam: 'originalSubParam'}};
|
||||
const plugin = normalize('', {}, pluginFunction, t.context.logger);
|
||||
const plugin = normalize('', {}, {}, pluginFunction, t.context.logger);
|
||||
await plugin(input);
|
||||
|
||||
t.is(input.param.subParam, 'originalSubParam');
|
||||
@ -92,7 +110,7 @@ test('Return noop if the plugin is not defined', t => {
|
||||
|
||||
test('Always pass a defined "pluginConfig" for plugin defined with string', async t => {
|
||||
// Call the normalize function with the path of a plugin that returns its config
|
||||
const plugin = normalize('', {}, './test/fixtures/plugin-result-config', t.context.logger);
|
||||
const plugin = normalize('', {}, {}, './test/fixtures/plugin-result-config', t.context.logger);
|
||||
const pluginResult = await plugin();
|
||||
|
||||
t.deepEqual(pluginResult.pluginConfig, {});
|
||||
@ -100,18 +118,17 @@ test('Always pass a defined "pluginConfig" for plugin defined with string', asyn
|
||||
|
||||
test('Always pass a defined "pluginConfig" for plugin defined with path', async t => {
|
||||
// Call the normalize function with the path of a plugin that returns its config
|
||||
const plugin = normalize('', {}, {path: './test/fixtures/plugin-result-config'}, t.context.logger);
|
||||
const plugin = normalize('', {}, {}, {path: './test/fixtures/plugin-result-config'}, t.context.logger);
|
||||
const pluginResult = await plugin();
|
||||
|
||||
t.deepEqual(pluginResult.pluginConfig, {});
|
||||
});
|
||||
|
||||
test('Throws an error if the plugin return an object without the expected plugin function', t => {
|
||||
const error = t.throws(
|
||||
() => normalize('inexistantPlugin', {}, './test/fixtures/multi-plugin', t.context.logger),
|
||||
Error
|
||||
);
|
||||
const error = t.throws(() => normalize('inexistantPlugin', {}, {}, './test/fixtures/multi-plugin', t.context.logger));
|
||||
|
||||
t.is(error.code, 'EPLUGINCONF');
|
||||
t.is(error.name, 'SemanticReleaseError');
|
||||
t.is(
|
||||
error.message,
|
||||
'The inexistantPlugin plugin must be a function, or an object with a function in the property inexistantPlugin.'
|
||||
@ -119,7 +136,7 @@ test('Throws an error if the plugin return an object without the expected plugin
|
||||
});
|
||||
|
||||
test('Throws an error if the plugin is not found', t => {
|
||||
const error = t.throws(() => normalize('inexistantPlugin', {}, 'non-existing-path', t.context.logger), Error);
|
||||
const error = t.throws(() => normalize('inexistantPlugin', {}, {}, 'non-existing-path', t.context.logger), Error);
|
||||
|
||||
t.is(error.message, "Cannot find module 'non-existing-path'");
|
||||
t.is(error.code, 'MODULE_NOT_FOUND');
|
||||
|
@ -1,15 +1,26 @@
|
||||
import path from 'path';
|
||||
import test from 'ava';
|
||||
import {copy, outputFile} from 'fs-extra';
|
||||
import {stub} from 'sinon';
|
||||
import tempy from 'tempy';
|
||||
import getPlugins from '../../lib/plugins';
|
||||
|
||||
// Save the current working diretory
|
||||
const cwd = process.cwd();
|
||||
|
||||
test.beforeEach(t => {
|
||||
// Stub the logger functions
|
||||
t.context.log = stub();
|
||||
t.context.logger = {log: t.context.log};
|
||||
});
|
||||
|
||||
test.afterEach.always(() => {
|
||||
// Restore the current working directory
|
||||
process.chdir(cwd);
|
||||
});
|
||||
|
||||
test('Export default plugins', t => {
|
||||
const plugins = getPlugins({}, t.context.logger);
|
||||
const plugins = getPlugins({}, {}, t.context.logger);
|
||||
|
||||
// Verify the module returns a function for each plugin
|
||||
t.is(typeof plugins.verifyConditions, 'function');
|
||||
@ -28,6 +39,62 @@ test('Export plugins based on config', t => {
|
||||
analyzeCommits: {path: './test/fixtures/plugin-noop'},
|
||||
verifyRelease: () => {},
|
||||
},
|
||||
{},
|
||||
t.context.logger
|
||||
);
|
||||
|
||||
// Verify the module returns a function for each plugin
|
||||
t.is(typeof plugins.verifyConditions, 'function');
|
||||
t.is(typeof plugins.getLastRelease, 'function');
|
||||
t.is(typeof plugins.analyzeCommits, 'function');
|
||||
t.is(typeof plugins.verifyRelease, 'function');
|
||||
t.is(typeof plugins.generateNotes, 'function');
|
||||
t.is(typeof plugins.publish, 'function');
|
||||
});
|
||||
|
||||
test.serial('Export plugins loaded from the dependency of a shareable config module', async t => {
|
||||
const temp = tempy.directory();
|
||||
await copy(
|
||||
'./test/fixtures/plugin-noop.js',
|
||||
path.join(temp, 'node_modules/shareable-config/node_modules/custom-plugin/index.js')
|
||||
);
|
||||
await outputFile(path.join(temp, 'node_modules/shareable-config/index.js'), '');
|
||||
process.chdir(temp);
|
||||
|
||||
const plugins = getPlugins(
|
||||
{
|
||||
verifyConditions: ['custom-plugin', {path: 'custom-plugin'}],
|
||||
getLastRelease: 'custom-plugin',
|
||||
analyzeCommits: {path: 'custom-plugin'},
|
||||
verifyRelease: () => {},
|
||||
},
|
||||
{'custom-plugin': 'shareable-config'},
|
||||
t.context.logger
|
||||
);
|
||||
|
||||
// Verify the module returns a function for each plugin
|
||||
t.is(typeof plugins.verifyConditions, 'function');
|
||||
t.is(typeof plugins.getLastRelease, 'function');
|
||||
t.is(typeof plugins.analyzeCommits, 'function');
|
||||
t.is(typeof plugins.verifyRelease, 'function');
|
||||
t.is(typeof plugins.generateNotes, 'function');
|
||||
t.is(typeof plugins.publish, 'function');
|
||||
});
|
||||
|
||||
test.serial('Export plugins loaded from the dependency of a shareable config file', async t => {
|
||||
const temp = tempy.directory();
|
||||
await copy('./test/fixtures/plugin-noop.js', path.join(temp, 'plugin/plugin-noop.js'));
|
||||
await outputFile(path.join(temp, 'shareable-config.js'), '');
|
||||
process.chdir(temp);
|
||||
|
||||
const plugins = getPlugins(
|
||||
{
|
||||
verifyConditions: ['./plugin/plugin-noop', {path: './plugin/plugin-noop'}],
|
||||
getLastRelease: './plugin/plugin-noop',
|
||||
analyzeCommits: {path: './plugin/plugin-noop'},
|
||||
verifyRelease: () => {},
|
||||
},
|
||||
{'./plugin/plugin-noop': './shareable-config.js'},
|
||||
t.context.logger
|
||||
);
|
||||
|
||||
@ -41,7 +108,7 @@ test('Export plugins based on config', t => {
|
||||
});
|
||||
|
||||
test('Use default when only options are passed for a single plugin', t => {
|
||||
const plugins = getPlugins({getLastRelease: {}, analyzeCommits: {}}, t.context.logger);
|
||||
const plugins = getPlugins({getLastRelease: {}, analyzeCommits: {}}, {}, t.context.logger);
|
||||
|
||||
// Verify the module returns a function for each plugin
|
||||
t.is(typeof plugins.getLastRelease, 'function');
|
||||
@ -55,6 +122,7 @@ test('Merge global options with plugin options', async t => {
|
||||
otherOpt: 'globally-defined',
|
||||
getLastRelease: {path: './test/fixtures/plugin-result-config', localOpt: 'local', otherOpt: 'locally-defined'},
|
||||
},
|
||||
{},
|
||||
t.context.logger
|
||||
);
|
||||
|
||||
@ -64,8 +132,10 @@ test('Merge global options with plugin options', async t => {
|
||||
});
|
||||
|
||||
test('Throw an error if plugin configuration is missing a path for plugin pipeline', t => {
|
||||
const error = t.throws(() => getPlugins({verifyConditions: {}}, t.context.logger), Error);
|
||||
const error = t.throws(() => getPlugins({verifyConditions: {}}, {}, t.context.logger));
|
||||
|
||||
t.is(error.name, 'SemanticReleaseError');
|
||||
t.is(error.code, 'EPLUGINCONF');
|
||||
t.is(
|
||||
error.message,
|
||||
'The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.'
|
||||
@ -73,11 +143,12 @@ test('Throw an error if plugin configuration is missing a path for plugin pipeli
|
||||
});
|
||||
|
||||
test('Throw an error if an array of plugin configuration is missing a path for plugin pipeline', t => {
|
||||
const error = t.throws(
|
||||
() => getPlugins({verifyConditions: [{path: '@semantic-release/npm'}, {}]}, t.context.logger),
|
||||
Error
|
||||
const error = t.throws(() =>
|
||||
getPlugins({verifyConditions: [{path: '@semantic-release/npm'}, {}]}, {}, t.context.logger)
|
||||
);
|
||||
|
||||
t.is(error.name, 'SemanticReleaseError');
|
||||
t.is(error.code, 'EPLUGINCONF');
|
||||
t.is(
|
||||
error.message,
|
||||
'The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.'
|
||||
|
Loading…
x
Reference in New Issue
Block a user