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:
Pierre Vanduynslager
2017-12-22 14:22:30 -05:00
parent 2fc538b607
commit 754b420fd6
14 changed files with 508 additions and 59 deletions
+50 -4
View File
@@ -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;
}
+2 -2
View File
@@ -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;
}
/**
+9 -8
View File
@@ -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;
}, {});
+19 -6
View File
@@ -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'
);
}