feat: pass cwd and env context to plugins

- Allow to run semantic-release (via API) from anywhere passing the current working directory.
- Allows to simplify the tests and to run them in parallel in both the core and plugins.
This commit is contained in:
Pierre Vanduynslager
2018-07-17 00:42:04 -04:00
parent 12e4155cd3
commit a94e08de9a
32 changed files with 1361 additions and 1332 deletions
+3 -1
View File
@@ -8,4 +8,6 @@ const COMMIT_EMAIL = 'semantic-release-bot@martynus.net';
const RELEASE_NOTES_SEPARATOR = '\n\n';
module.exports = {RELEASE_TYPE, FIRST_RELEASE, COMMIT_NAME, COMMIT_EMAIL, RELEASE_NOTES_SEPARATOR};
const SECRET_REPLACEMENT = '[secure]';
module.exports = {RELEASE_TYPE, FIRST_RELEASE, COMMIT_NAME, COMMIT_EMAIL, RELEASE_NOTES_SEPARATOR, SECRET_REPLACEMENT};
+9 -9
View File
@@ -30,8 +30,8 @@ module.exports = {
configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
outputValidator: output => !output || isString(output),
pipelineConfig: () => ({
getNextInput: ({nextRelease, ...generateNotesParam}, notes) => ({
...generateNotesParam,
getNextInput: ({nextRelease, ...context}, notes) => ({
...context,
nextRelease: {
...nextRelease,
notes: `${nextRelease.notes ? `${nextRelease.notes}${RELEASE_NOTES_SEPARATOR}` : ''}${notes}`,
@@ -44,17 +44,17 @@ module.exports = {
default: ['@semantic-release/npm'],
configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
pipelineConfig: ({generateNotes}, logger) => ({
getNextInput: async ({nextRelease, ...prepareParam}) => {
const newGitHead = await gitHead();
getNextInput: async context => {
const newGitHead = await gitHead({cwd: context.cwd});
// If previous prepare plugin has created a commit (gitHead changed)
if (nextRelease.gitHead !== newGitHead) {
nextRelease.gitHead = newGitHead;
if (context.nextRelease.gitHead !== newGitHead) {
context.nextRelease.gitHead = newGitHead;
// Regenerate the release notes
logger.log('Call plugin %s', 'generateNotes');
nextRelease.notes = await generateNotes({nextRelease, ...prepareParam});
context.nextRelease.notes = await generateNotes(context);
}
// Call the next publish plugin with the updated `nextRelease`
return {...prepareParam, nextRelease};
// Call the next prepare plugin with the updated `nextRelease`
return context;
},
}),
},
+9 -11
View File
@@ -5,13 +5,11 @@ const debug = require('debug')('semantic-release:get-commits');
/**
* Retrieve the list of commits on the current branch since the commit sha associated with the last release, or all the commits of the current branch if there is no last released version.
*
* @param {String} gitHead The commit sha associated with the last release.
* @param {String} branch The branch to release from.
* @param {Object} logger Global logger.
* @param {Object} context semantic-release context.
*
* @return {Promise<Array<Object>>} The list of commits on the branch `branch` since the last release.
*/
module.exports = async (gitHead, branch, logger) => {
module.exports = async ({cwd, env, lastRelease: {gitHead}, logger}) => {
if (gitHead) {
debug('Use gitHead: %s', gitHead);
} else {
@@ -19,13 +17,13 @@ module.exports = async (gitHead, branch, logger) => {
}
Object.assign(gitLogParser.fields, {hash: 'H', message: 'B', gitTags: 'd', committerDate: {key: 'ci', type: Date}});
const commits = (await getStream.array(gitLogParser.parse({_: `${gitHead ? gitHead + '..' : ''}HEAD`}))).map(
commit => {
commit.message = commit.message.trim();
commit.gitTags = commit.gitTags.trim();
return commit;
}
);
const commits = (await getStream.array(
gitLogParser.parse({_: `${gitHead ? gitHead + '..' : ''}HEAD`}, {cwd, env: {...process.env, ...env}})
)).map(commit => {
commit.message = commit.message.trim();
commit.gitTags = commit.gitTags.trim();
return commit;
});
logger.log('Found %s commits since last release', commits.length);
debug('Parsed commits: %o', commits);
return commits;
+8 -8
View File
@@ -18,8 +18,9 @@ const CONFIG_FILES = [
`${CONFIG_NAME}.config.js`,
];
module.exports = async (opts, logger) => {
const {config} = (await cosmiconfig(CONFIG_NAME, {searchPlaces: CONFIG_FILES}).search()) || {};
module.exports = async (context, opts) => {
const {cwd, env} = context;
const {config} = (await cosmiconfig(CONFIG_NAME, {searchPlaces: CONFIG_FILES}).search(cwd)) || {};
// Merge config file options and CLI/API options
let options = {...config, ...opts};
const pluginsPath = {};
@@ -29,8 +30,7 @@ module.exports = async (opts, logger) => {
// 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));
const extendsOpts = require(resolveFrom.silent(__dirname, extendPath) || resolveFrom(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
@@ -55,7 +55,7 @@ module.exports = async (opts, logger) => {
// Set default options values if not defined yet
options = {
branch: 'master',
repositoryUrl: (await pkgRepoUrl()) || (await repoUrl()),
repositoryUrl: (await pkgRepoUrl({normalize: false, cwd})) || (await repoUrl({cwd, env})),
tagFormat: `v\${version}`,
// Remove `null` and `undefined` options so they can be replaced with default ones
...pickBy(options, option => !isUndefined(option) && !isNull(option)),
@@ -63,10 +63,10 @@ module.exports = async (opts, logger) => {
debug('options values: %O', options);
return {options, plugins: await plugins(options, pluginsPath, logger)};
return {options, plugins: await plugins({...context, options}, pluginsPath)};
};
async function pkgRepoUrl() {
const {pkg} = await readPkgUp({normalize: false});
async function pkgRepoUrl(opts) {
const {pkg} = await readPkgUp(opts);
return pkg && (isPlainObject(pkg.repository) ? pkg.repository.url : pkg.repository);
}
+6 -5
View File
@@ -21,10 +21,11 @@ const GIT_TOKENS = {
*
* In addition, expand shortcut URLs (`owner/repo` => `https://github.com/owner/repo.git`) and transform `git+https` / `git+http` URLs to `https` / `http`.
*
* @param {String} repositoryUrl The user provided Git repository URL.
* @param {Object} context semantic-release context.
*
* @return {String} The formatted Git repository URL.
*/
module.exports = async ({repositoryUrl, branch}) => {
module.exports = async ({cwd, env, options: {repositoryUrl, branch}}) => {
const info = hostedGitInfo.fromUrl(repositoryUrl, {noGitPlus: true});
if (info && info.getDefaultRepresentation() === 'shortcut') {
@@ -41,10 +42,10 @@ module.exports = async ({repositoryUrl, branch}) => {
// Test if push is allowed without transforming the URL (e.g. is ssh keys are set up)
try {
await verifyAuth(repositoryUrl, branch);
await verifyAuth(repositoryUrl, branch, {cwd, env});
} catch (err) {
const envVar = Object.keys(GIT_TOKENS).find(envVar => !isUndefined(process.env[envVar]));
const gitCredentials = `${GIT_TOKENS[envVar] || ''}${process.env[envVar] || ''}`;
const envVar = Object.keys(GIT_TOKENS).find(envVar => !isUndefined(env[envVar]));
const gitCredentials = `${GIT_TOKENS[envVar] || ''}${env[envVar] || ''}`;
const {protocols, ...parsed} = gitUrlParse(repositoryUrl);
const protocol = protocols.includes('https') ? 'https' : protocols.includes('http') ? 'http' : 'https';
+6 -7
View File
@@ -20,18 +20,17 @@ const {gitTags, isRefInHistory, gitTagHead} = require('./git');
* - Sort the versions
* - Retrive the highest version
*
* @param {String} tagFormat Git tag format.
* @param {Object} logger Global logger.
* @param {Object} context semantic-release context.
*
* @return {Promise<LastRelease>} The last tagged release or `undefined` if none is found.
*/
module.exports = async (tagFormat, logger) => {
module.exports = async ({cwd, env, options: {tagFormat}, logger}) => {
// Generate a regex to parse tags formatted with `tagFormat`
// by replacing the `version` variable in the template by `(.+)`.
// The `tagFormat` is compiled with space as the `version` as it's an invalid tag character,
// so it's guaranteed to no be present in the `tagFormat`.
const tagRegexp = `^${escapeRegExp(template(tagFormat)({version: ' '})).replace(' ', '(.+)')}`;
const tags = (await gitTags())
const tags = (await gitTags({cwd, env}))
.map(tag => ({gitTag: tag, version: (tag.match(tagRegexp) || new Array(2))[1]}))
.filter(
tag => tag.version && semver.valid(semver.clean(tag.version)) && !semver.prerelease(semver.clean(tag.version))
@@ -40,11 +39,11 @@ module.exports = async (tagFormat, logger) => {
debug('found tags: %o', tags);
const tag = await pLocate(tags, tag => isRefInHistory(tag.gitTag), {concurrency: 1, preserveOrder: true});
const tag = await pLocate(tags, tag => isRefInHistory(tag.gitTag, {cwd, env}), {preserveOrder: true});
if (tag) {
logger.log('Found git tag %s associated with version %s', tag.gitTag, tag.version);
return {gitHead: await gitTagHead(tag.gitTag), ...tag};
return {gitHead: await gitTagHead(tag.gitTag, {cwd, env}), ...tag};
}
logger.log('No git tag version found');
+1 -1
View File
@@ -1,7 +1,7 @@
const semver = require('semver');
const {FIRST_RELEASE} = require('./definitions/constants');
module.exports = (type, lastRelease, logger) => {
module.exports = ({nextRelease: {type}, lastRelease, logger}) => {
let version;
if (lastRelease.version) {
version = semver.inc(lastRelease.version, type);
+53 -25
View File
@@ -5,23 +5,28 @@ const debug = require('debug')('semantic-release:git');
* Get the commit sha for a given tag.
*
* @param {string} tagName Tag name for which to retrieve the commit sha.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {string} The commit sha of the tag in parameter or `null`.
*/
async function gitTagHead(tagName) {
async function gitTagHead(tagName, execaOpts) {
try {
return await execa.stdout('git', ['rev-list', '-1', tagName]);
return await execa.stdout('git', ['rev-list', '-1', tagName], execaOpts);
} catch (err) {
debug(err);
}
}
/**
* Get all the repository tags.
*
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Array<String>} List of git tags.
* @throws {Error} If the `git` command fails.
*/
async function gitTags() {
return (await execa.stdout('git', ['tag']))
async function gitTags(execaOpts) {
return (await execa.stdout('git', ['tag'], execaOpts))
.split('\n')
.map(tag => tag.trim())
.filter(tag => Boolean(tag));
@@ -31,12 +36,13 @@ async function gitTags() {
* Verify if the `ref` is in the direct history of the current branch.
*
* @param {string} ref The reference to look for.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {boolean} `true` if the reference is in the history of the current branch, falsy otherwise.
*/
async function isRefInHistory(ref) {
async function isRefInHistory(ref, execaOpts) {
try {
await execa('git', ['merge-base', '--is-ancestor', ref, 'HEAD']);
await execa('git', ['merge-base', '--is-ancestor', ref, 'HEAD'], execaOpts);
return true;
} catch (err) {
if (err.code === 1) {
@@ -52,39 +58,52 @@ async function isRefInHistory(ref) {
* Unshallow the git repository if necessary and fetch all the tags.
*
* @param {String} repositoryUrl The remote repository URL.
* @param {Object} [execaOpts] Options to pass to `execa`.
*/
async function fetch(repositoryUrl) {
async function fetch(repositoryUrl, execaOpts) {
try {
await execa('git', ['fetch', '--unshallow', '--tags', repositoryUrl]);
await execa('git', ['fetch', '--unshallow', '--tags', repositoryUrl], execaOpts);
} catch (err) {
await execa('git', ['fetch', '--tags', repositoryUrl]);
await execa('git', ['fetch', '--tags', repositoryUrl], execaOpts);
}
}
/**
* Get the HEAD sha.
*
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {string} the sha of the HEAD commit.
*/
async function gitHead() {
return execa.stdout('git', ['rev-parse', 'HEAD']);
async function gitHead(execaOpts) {
return execa.stdout('git', ['rev-parse', 'HEAD'], execaOpts);
}
/**
* Get the repository remote URL.
*
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {string} The value of the remote git URL.
*/
async function repoUrl() {
async function repoUrl(execaOpts) {
try {
return await execa.stdout('git', ['config', '--get', 'remote.origin.url']);
return await execa.stdout('git', ['config', '--get', 'remote.origin.url'], execaOpts);
} catch (err) {
debug(err);
}
}
/**
* Test if the current working directory is a Git repository.
*
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Boolean} `true` if the current working directory is in a git repository, falsy otherwise.
*/
async function isGitRepo() {
async function isGitRepo(execaOpts) {
try {
return (await execa('git', ['rev-parse', '--git-dir'])).code === 0;
return (await execa('git', ['rev-parse', '--git-dir'], execaOpts)).code === 0;
} catch (err) {
debug(err);
}
@@ -95,12 +114,13 @@ async function isGitRepo() {
*
* @param {String} repositoryUrl The remote repository URL.
* @param {String} branch The repositoru branch for which to verify write access.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @throws {Error} if not authorized to push.
*/
async function verifyAuth(repositoryUrl, branch) {
async function verifyAuth(repositoryUrl, branch, execaOpts) {
try {
await execa('git', ['push', '--dry-run', repositoryUrl, `HEAD:${branch}`]);
await execa('git', ['push', '--dry-run', repositoryUrl, `HEAD:${branch}`], execaOpts);
} catch (err) {
debug(err);
throw err;
@@ -111,10 +131,12 @@ async function verifyAuth(repositoryUrl, branch) {
* Tag the commit head on the local repository.
*
* @param {String} tagName The name of the tag.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @throws {Error} if the tag creation failed.
*/
async function tag(tagName) {
await execa('git', ['tag', tagName]);
async function tag(tagName, execaOpts) {
await execa('git', ['tag', tagName], execaOpts);
}
/**
@@ -122,21 +144,25 @@ async function tag(tagName) {
*
* @param {String} repositoryUrl The remote repository URL.
* @param {String} branch The branch to push.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @throws {Error} if the push failed.
*/
async function push(repositoryUrl, branch) {
await execa('git', ['push', '--tags', repositoryUrl, `HEAD:${branch}`]);
async function push(repositoryUrl, branch, execaOpts) {
await execa('git', ['push', '--tags', repositoryUrl, `HEAD:${branch}`], execaOpts);
}
/**
* Verify a tag name is a valid Git reference.
*
* @param {string} tagName the tag name to verify.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {boolean} `true` if valid, falsy otherwise.
*/
async function verifyTagName(tagName) {
async function verifyTagName(tagName, execaOpts) {
try {
return (await execa('git', ['check-ref-format', `refs/tags/${tagName}`])).code === 0;
return (await execa('git', ['check-ref-format', `refs/tags/${tagName}`], execaOpts)).code === 0;
} catch (err) {
debug(err);
}
@@ -146,13 +172,15 @@ async function verifyTagName(tagName) {
* Verify the local branch is up to date with the remote one.
*
* @param {String} branch The repository branch for which to verify status.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Boolean} `true` is the HEAD of the current local branch is the same as the HEAD of the remote branch, falsy otherwise.
*/
async function isBranchUpToDate(branch) {
async function isBranchUpToDate(branch, execaOpts) {
try {
return await isRefInHistory(
(await execa.stdout('git', ['ls-remote', '--heads', 'origin', branch])).match(/^(\w+)?/)[1]
(await execa.stdout('git', ['ls-remote', '--heads', 'origin', branch], execaOpts)).match(/^(\w+)?/)[1],
execaOpts
);
} catch (err) {
debug(err);
+9 -7
View File
@@ -1,11 +1,13 @@
const {escapeRegExp} = require('lodash');
const {SECRET_REPLACEMENT} = require('./definitions/constants');
const toReplace = Object.keys(process.env).filter(
envVar => /token|password|credential|secret|private/i.test(envVar) && process.env[envVar].trim()
);
module.exports = env => {
const toReplace = Object.keys(env).filter(
envVar => /token|password|credential|secret|private/i.test(envVar) && env[envVar].trim()
);
const regexp = new RegExp(toReplace.map(envVar => escapeRegExp(process.env[envVar])).join('|'), 'g');
module.exports = output => {
return output && toReplace.length > 0 ? output.toString().replace(regexp, '[secure]') : output;
const regexp = new RegExp(toReplace.map(envVar => escapeRegExp(env[envVar])).join('|'), 'g');
return output => {
return output && toReplace.length > 0 ? output.toString().replace(regexp, SECRET_REPLACEMENT) : output;
};
};
+6
View File
@@ -20,4 +20,10 @@ module.exports = {
...(typeof format === 'string' ? [] : [format]).concat(rest)
);
},
stdout(...args) {
console.log(args);
},
stderr(...args) {
console.error(args);
},
};
+7 -6
View File
@@ -5,17 +5,17 @@ const PLUGINS_DEFINITIONS = require('../definitions/plugins');
const pipeline = require('./pipeline');
const normalize = require('./normalize');
module.exports = (options, pluginsPath, logger) => {
module.exports = ({cwd, options, logger}, pluginsPath) => {
const errors = [];
const plugins = Object.entries(PLUGINS_DEFINITIONS).reduce(
(
plugins,
[type, {configValidator, default: def, pipelineConfig, postprocess = identity, preprocess = identity}]
) => {
let pluginConfs;
let pluginOpts;
if (isUndefined(options[type])) {
pluginConfs = def;
pluginOpts = def;
} else {
const defaultPaths = castArray(def);
// If an object is passed and the path is missing, set the default one for single plugins
@@ -26,11 +26,12 @@ module.exports = (options, pluginsPath, logger) => {
errors.push(getError('EPLUGINCONF', {type, pluginConf: options[type]}));
return plugins;
}
pluginConfs = options[type];
pluginOpts = options[type];
}
const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITIONS));
const steps = castArray(pluginConfs).map(conf => normalize(type, pluginsPath, globalOpts, conf, logger));
const steps = castArray(pluginOpts).map(pluginOpt =>
normalize({cwd, options: omit(options, Object.keys(PLUGINS_DEFINITIONS)), logger}, type, pluginOpt, pluginsPath)
);
plugins[type] = async input =>
postprocess(await pipeline(steps, pipelineConfig && pipelineConfig(plugins, logger))(await preprocess(input)));
+8 -12
View File
@@ -5,17 +5,15 @@ const getError = require('../get-error');
const {extractErrors} = require('../utils');
const PLUGINS_DEFINITIONS = require('../definitions/plugins');
/* eslint max-params: ["error", 5] */
module.exports = (type, pluginsPath, globalOpts, pluginOpts, logger) => {
if (!pluginOpts) {
module.exports = ({cwd, options, logger}, type, pluginOpt, pluginsPath) => {
if (!pluginOpt) {
return noop;
}
const {path, ...config} = isString(pluginOpts) || isFunction(pluginOpts) ? {path: pluginOpts} : pluginOpts;
const {path, ...config} = isString(pluginOpt) || isFunction(pluginOpt) ? {path: pluginOpt} : pluginOpt;
const pluginName = isFunction(path) ? `[Function: ${path.name}]` : path;
if (!isFunction(pluginOpts)) {
if (!isFunction(pluginOpt)) {
if (pluginsPath[path]) {
logger.log('Load plugin "%s" from %s in shareable config %s', type, path, pluginsPath[path]);
} else {
@@ -24,17 +22,15 @@ module.exports = (type, pluginsPath, globalOpts, pluginOpts, logger) => {
}
const basePath = pluginsPath[path]
? dirname(resolveFrom.silent(__dirname, pluginsPath[path]) || resolveFrom(process.cwd(), pluginsPath[path]))
? dirname(resolveFrom.silent(__dirname, pluginsPath[path]) || resolveFrom(cwd, pluginsPath[path]))
: __dirname;
const plugin = isFunction(path)
? path
: require(resolveFrom.silent(basePath, path) || resolveFrom(process.cwd(), path));
const plugin = isFunction(path) ? path : require(resolveFrom.silent(basePath, path) || resolveFrom(cwd, path));
let func;
if (isFunction(plugin)) {
func = plugin.bind(null, cloneDeep({...globalOpts, ...config}));
func = plugin.bind(null, cloneDeep({...options, ...config}));
} else if (isPlainObject(plugin) && plugin[type] && isFunction(plugin[type])) {
func = plugin[type].bind(null, cloneDeep({...globalOpts, ...config}));
func = plugin[type].bind(null, cloneDeep({...options, ...config}));
} else {
throw getError('EPLUGIN', {type, pluginName});
}
+7 -7
View File
@@ -3,25 +3,25 @@ const AggregateError = require('aggregate-error');
const {isGitRepo, verifyTagName} = require('./git');
const getError = require('./get-error');
module.exports = async options => {
module.exports = async ({cwd, env, options: {repositoryUrl, tagFormat}}) => {
const errors = [];
if (!(await isGitRepo())) {
if (!(await isGitRepo({cwd, env}))) {
errors.push(getError('ENOGITREPO'));
} else if (!options.repositoryUrl) {
} else if (!repositoryUrl) {
errors.push(getError('ENOREPOURL'));
}
// Verify that compiling the `tagFormat` produce a valid Git tag
if (!(await verifyTagName(template(options.tagFormat)({version: '0.0.0'})))) {
errors.push(getError('EINVALIDTAGFORMAT', {tagFormat: options.tagFormat}));
if (!(await verifyTagName(template(tagFormat)({version: '0.0.0'})))) {
errors.push(getError('EINVALIDTAGFORMAT', {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(getError('ETAGNOVERSION', {tagFormat: options.tagFormat}));
if ((template(tagFormat)({version: ' '}).match(/ /g) || []).length !== 1) {
errors.push(getError('ETAGNOVERSION', {tagFormat}));
}
if (errors.length > 0) {