feat: support multiple branches and distribution channels

- Allow to configure multiple branches to release from
- Allow to define a distribution channel associated with each branch
- Manage the availability on distribution channels based on git merges
- Support regular releases, maintenance releases and pre-releases
- Add the `addChannel` plugin step to make an existing release available on a different distribution channel

BREAKING CHANGE: the `branch` option has been removed in favor of `branches`

The new `branches` option expect either an Array or a single branch definition. To migrate your configuration:
- If you want to publish package from multiple branches, please the configuration documentation
- If you use the default configuration and want to publish only from `master`: nothing to change
- If you use the `branch` configuration and want to publish only from one branch: replace `branch` by `branches` (`"branch": "my-release-branch"` => `"branches": "my-release-branch"`)
This commit is contained in:
Pierre Vanduynslager
2018-11-29 14:13:03 -05:00
parent 7a9922a492
commit 7b4052470b
50 changed files with 4069 additions and 516 deletions
+18
View File
@@ -0,0 +1,18 @@
const {isString, remove, omit, mapValues, template} = require('lodash');
const micromatch = require('micromatch');
const {getBranches} = require('../git');
module.exports = async ({cwd}, branches) => {
const gitBranches = await getBranches({cwd});
return branches.reduce(
(branches, branch) => [
...branches,
...remove(gitBranches, name => micromatch(gitBranches, branch.name).includes(name)).map(name => ({
name,
...mapValues(omit(branch, 'name'), value => (isString(value) ? template(value)({name}) : value)),
})),
],
[]
);
};
+39
View File
@@ -0,0 +1,39 @@
const {template, escapeRegExp} = require('lodash');
const semver = require('semver');
const pReduce = require('p-reduce');
const debug = require('debug')('semantic-release:get-tags');
const {getTags, isRefInHistory, getTagHead} = require('../../lib/git');
module.exports = async ({cwd, env, options: {tagFormat}}, branches) => {
// 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 getTags({cwd, env}))
.map(tag => {
const [, version, channel] = tag.match(tagRegexp) || [];
return {gitTag: tag, version, channel};
})
.filter(({version}) => version && semver.valid(semver.clean(version)));
debug('found tags: %o', tags);
return pReduce(
branches,
async (branches, branch) => {
const branchTags = await pReduce(
tags,
async (tags, {gitTag, ...rest}) =>
(await isRefInHistory(gitTag, branch.name, true, {cwd, env}))
? [...tags, {...rest, gitTag, gitHead: await getTagHead(gitTag, {cwd, env})}]
: tags,
[]
);
debug('found tags for branch %s: %o', branch.name, branchTags);
return [...branches, {...branch, tags: branchTags}];
},
[]
);
};
+62
View File
@@ -0,0 +1,62 @@
const {isString, isRegExp} = require('lodash');
const AggregateError = require('aggregate-error');
const pEachSeries = require('p-each-series');
const DEFINITIONS = require('../definitions/branches');
const getError = require('../get-error');
const {verifyBranchName} = require('../git');
const expand = require('./expand');
const getTags = require('./get-tags');
const normalize = require('./normalize');
module.exports = async context => {
const branches = await getTags(
context,
await expand(
context,
context.options.branches.map(branch => (isString(branch) || isRegExp(branch) ? {name: branch} : branch))
)
);
const errors = [];
const branchesByType = Object.entries(DEFINITIONS).reduce(
(branchesByType, [type, {filter}]) => ({[type]: branches.filter(filter), ...branchesByType}),
{}
);
const result = Object.entries(DEFINITIONS).reduce((result, [type, {branchesValidator, branchValidator}]) => {
branchesByType[type].forEach(branch => {
if (branchValidator && !branchValidator(branch)) {
errors.push(getError(`E${type.toUpperCase()}BRANCH`, {branch}));
}
});
const branchesOfType = normalize[type](branchesByType);
if (!branchesValidator(branchesOfType)) {
errors.push(getError(`E${type.toUpperCase()}BRANCHES`, {branches: branchesOfType}));
}
return {...result, [type]: branchesOfType};
}, {});
const duplicates = [...branches]
.map(branch => branch.name)
.sort()
.filter((val, idx, arr) => arr[idx] === arr[idx + 1] && arr[idx] !== arr[idx - 1]);
if (duplicates.length > 0) {
errors.push(getError('EDUPLICATEBRANCHES', {duplicates}));
}
await pEachSeries(branches, async branch => {
if (!(await verifyBranchName(branch.name))) {
errors.push(getError('EINVALIDBRANCHNAME', branch));
}
});
if (errors.length > 0) {
throw new AggregateError(errors);
}
return [...result.maintenance, ...result.release, ...result.prerelease];
};
+110
View File
@@ -0,0 +1,110 @@
const {sortBy} = require('lodash');
const semver = require('semver');
const semverDiff = require('semver-diff');
const {FIRST_RELEASE, RELEASE_TYPE} = require('../definitions/constants');
const {
tagsToVersions,
isMajorRange,
getUpperBound,
getLowerBound,
highest,
lowest,
getLatestVersion,
getFirstVersion,
getRange,
} = require('../utils');
function maintenance({maintenance, release}) {
return sortBy(
maintenance.map(({name, range, channel, ...rest}) => ({
...rest,
name,
range: range || name,
channel: channel || name,
})),
'range'
).map(({name, range, tags, ...rest}, idx, branches) => {
const versions = tagsToVersions(tags);
// Find the lower bound based on Maintenance branches
const maintenanceMin =
// If the current branch has a major range (1.x or 1.x.x) and the previous doesn't
isMajorRange(range) && branches[idx - 1] && !isMajorRange(branches[idx - 1].range)
? // Then the lowest bound is the upper bound of the previous branch range
getUpperBound(branches[idx - 1].range)
: // Otherwise the lowest bound is the lowest bound of the current branch range
getLowerBound(range);
// The actual lower bound is the highest version between the current branch last release and `maintenanceMin`
const min = highest(getLatestVersion(versions) || FIRST_RELEASE, maintenanceMin);
// Determine the first release of the default branch not present in any maintenance branch
const base =
(release[0] &&
(getFirstVersion(tagsToVersions(release[0].tags), branches) ||
getLatestVersion(tagsToVersions(release[0].tags)))) ||
FIRST_RELEASE;
// The upper bound is the lowest version between the `base` version and the upper bound of the current branch range
const max = lowest(base, getUpperBound(range));
const diff = semverDiff(min, max);
return {
...rest,
type: 'maintenance',
name,
tags,
range: getRange(min, max),
accept: diff ? RELEASE_TYPE.slice(0, RELEASE_TYPE.indexOf(diff)) : [],
'merge-range': getRange(maintenanceMin, getUpperBound(range)),
};
});
}
function release({release}) {
if (release.length === 0) {
return release;
}
const breakpoints = release.length > 2 ? ['minor', 'major'] : ['major'];
// The intial bound is the last release from the base branch of `FIRST_RELEASE` (1.0.0)
let bound = getLatestVersion(tagsToVersions(release[0].tags)) || FIRST_RELEASE;
return release.map(({name, tags, channel, ...rest}, idx) => {
const versions = tagsToVersions(tags);
// The lower bound is the highest version between the current branch last release and the previous branch upper bound (`bound`)
const min = highest(getLatestVersion(versions), bound);
if (release.length - 1 === idx) {
// If the current branch is the last one of the release branch, there is no upper bound
bound = undefined;
} else {
// The default upper bound is the lower bound increment with the release type of the current branch position
const upperBound = semver.inc(min, breakpoints[idx]);
// Find the lowest version that is present on the current branch but none of the previous ones
const nextFirstVersion = getFirstVersion(tagsToVersions(release[idx + 1].tags), release.slice(0, idx + 1));
// The upper bound is the lowest version between `nextFirstVersion` and the default upper bound
bound = lowest(nextFirstVersion, upperBound);
}
const diff = bound ? semverDiff(min, bound) : null;
return {
...rest,
channel: idx === 0 ? channel : channel || name,
tags,
type: 'release',
name,
range: getRange(min, bound),
accept: bound ? RELEASE_TYPE.slice(0, RELEASE_TYPE.indexOf(diff)) : RELEASE_TYPE,
};
});
}
function prerelease({prerelease}) {
return prerelease.map(({name, prerelease, channel, tags, ...rest}) => {
const preid = prerelease === true ? name : prerelease;
return {
...rest,
channel: channel || name,
type: 'prerelease',
name,
prerelease: preid,
tags,
};
});
}
module.exports = {maintenance, release, prerelease};
+23
View File
@@ -0,0 +1,23 @@
const {isUndefined, uniqBy} = require('lodash');
const semver = require('semver');
const {isMaintenanceRange} = require('../utils');
const maintenance = {
filter: ({name, range}) => !isUndefined(range) || isMaintenanceRange(name),
branchValidator: ({range}) => (isUndefined(range) ? true : isMaintenanceRange(range)),
branchesValidator: branches => uniqBy(branches, ({range}) => semver.validRange(range)).length === branches.length,
};
const prerelease = {
filter: ({prerelease}) => !isUndefined(prerelease),
branchValidator: ({name, prerelease}) =>
Boolean(prerelease) && Boolean(semver.valid(`1.0.0-${prerelease === true ? name : prerelease}.1`)),
branchesValidator: branches => uniqBy(branches, 'prerelease').length === branches.length,
};
const release = {
filter: branch => !maintenance.filter(branch) && !prerelease.filter(branch),
branchesValidator: branches => branches.length <= 3 && branches.length > 0,
};
module.exports = {maintenance, prerelease, release};
+4 -1
View File
@@ -1,7 +1,9 @@
const RELEASE_TYPE = ['prerelease', 'prepatch', 'patch', 'preminor', 'minor', 'premajor', 'major'];
const RELEASE_TYPE = ['patch', 'minor', 'major'];
const FIRST_RELEASE = '1.0.0';
const FIRSTPRERELEASE = '1';
const COMMIT_NAME = 'semantic-release-bot';
const COMMIT_EMAIL = 'semantic-release-bot@martynus.net';
@@ -15,6 +17,7 @@ const SECRET_MIN_SIZE = 5;
module.exports = {
RELEASE_TYPE,
FIRST_RELEASE,
FIRSTPRERELEASE,
COMMIT_NAME,
COMMIT_EMAIL,
RELEASE_NOTES_SEPARATOR,
+118 -1
View File
@@ -1,12 +1,14 @@
const url = require('url');
const {inspect} = require('util');
const {toLower, isString} = require('lodash');
const {toLower, isString, trim} = require('lodash');
const pkg = require('../../package.json');
const {RELEASE_TYPE} = require('./constants');
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/master/${file}`;
const wordsList = words =>
`${words.slice(0, -1).join(', ')}${words.length > 1 ? ` or ${words[words.length - 1]}` : trim(words[0])}`;
module.exports = {
ENOGITREPO: ({cwd}) => ({
@@ -125,4 +127,119 @@ We recommend to report the issue to the \`${pluginName}\` authors, providing the
'docs/developer-guide/plugin.md'
)})`,
}),
EADDCHANNELOUTPUT: ({result, pluginName}) => ({
message: 'A `addChannel` plugin returned an invalid value. It must return an `Object`.',
details: `The \`addChannel\` plugins must return an \`Object\`.
The \`addChannel\` 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'
)})`,
}),
EINVALIDBRANCH: ({branch}) => ({
message: 'A branch is invalid in the `branches` configuration.',
details: `Each branch in the [branches configuration](${linkify(
'docs/usage/configuration.md#branches'
)}) must be either a string, a regexp or an object with a \`name\` property.
Your configuration for the problematic branch is \`${stringify(branch)}\`.`,
}),
EINVALIDBRANCHNAME: ({branch}) => ({
message: 'A branch name is invalid in the `branches` configuration.',
details: `Each branch in the [branches configuration](${linkify(
'docs/usage/configuration.md#branches'
)}) must be a [valid Git reference](https://git-scm.com/docs/git-check-ref-format#_description).
Your configuration for the problematic branch is \`${stringify(branch)}\`.`,
}),
EDUPLICATEBRANCHES: ({duplicates}) => ({
message: 'The `branches` configuration has duplicate branches.',
details: `Each branch in the [branches configuration](${linkify(
'docs/usage/configuration.md#branches'
)}) must havea unique name.
Your configuration contains duplicates for the following branch names: \`${stringify(duplicates)}\`.`,
}),
EMAINTENANCEBRANCH: ({branch}) => ({
message: 'A maintenance branch is invalid in the `branches` configuration.',
details: `Each maintenance branch in the [branches configuration](${linkify(
'docs/usage/configuration.md#branches'
)}) must have a \`range\` property formatted like \`N.x\`, \`N.x.x\` or \`N.N.x\` (\`N\` is a number).
Your configuration for the problematic branch is \`${stringify(branch)}\`.`,
}),
EMAINTENANCEBRANCHES: ({branches}) => ({
message: 'The maintenance branches are invalid in the `branches` configuration.',
details: `Each maintenance branch in the [branches configuration](${linkify(
'docs/usage/configuration.md#branches'
)}) must have a unique \`range\` property.
Your configuration for the problematic branches is \`${stringify(branches)}\`.`,
}),
ERELEASEBRANCHES: ({branches}) => ({
message: 'The release branches are invalid in the `branches` configuration.',
details: `A minimum of 1 and a maximum of 3 release branches are required in the [branches configuration](${linkify(
'docs/usage/configuration.md#branches'
)}).
Your configuration for the problematic branches is \`${stringify(branches)}\`.`,
}),
EPRERELEASEBRANCH: ({branch}) => ({
message: 'A pre-release branch configuration is invalid in the `branches` configuration.',
details: `Each pre-release branch in the [branches configuration](${linkify(
'docs/usage/configuration.md#branches'
)}) must have a \`prerelease\` property valid per the [Semantic Versioning Specification](https://semver.org/#spec-item-9). If the \`prerelease\` property is set to \`true\`, then the \`name\` property is used instead.
Your configuration for the problematic branch is \`${stringify(branch)}\`.`,
}),
EPRERELEASEBRANCHES: ({branches}) => ({
message: 'The pre-release branches are invalid in the `branches` configuration.',
details: `Each pre-release branch in the [branches configuration](${linkify(
'docs/usage/configuration.md#branches'
)}) must have a unique \`prerelease\` property. If the \`prerelease\` property is set to \`true\`, then the \`name\` property is used instead.
Your configuration for the problematic branches is \`${stringify(branches)}\`.`,
}),
EINVALIDNEXTVERSION: ({nextRelease, branch, commits, validBranches}) => ({
message: `The release \`${nextRelease.version}\` on branch \`${
branch.name
}\` cannot be published as it is out of range.`,
details: `Based on the releases published on other branches, only versions within the range \`${
branch.range
}\` can be published from branch \`${branch.name}\`.
The following commit${commits.length > 1 ? 's are' : ' is'} responsible for the invalid release:
${commits.map(({commit: {short}, subject}) => `- ${subject} (${short})`).join('\n')}
${
commits.length > 1 ? 'Those commits' : 'This commit'
} should be moved to a valid branch with [git merge](https://git-scm.com/docs/git-merge) or [git cherry-pick](https://git-scm.com/docs/git-cherry-pick) and removed from branch \`${
branch.name
}\` with [git revert](https://git-scm.com/docs/git-revert) or [git reset](https://git-scm.com/docs/git-reset).
A valid branch could be ${wordsList(validBranches.map(({name}) => `\`${name}\``))}.
See the [workflow configuration documentation](${linkify('docs/usage/workflow-configuration.md')}) for more details.`,
}),
EINVALIDMAINTENANCEMERGE: ({nextRelease, branch}) => ({
message: `The release \`${nextRelease.version}\` on branch \`${
branch.name
}\` cannot be published as it is out of range.`,
details: `Only releases within the range \`${branch['merge-range']}\` can be merged into the maintenance branch \`${
branch.name
}\` and published to the \`${nextRelease.channel}\` distribution channel.
The branch \`${
branch.name
}\` head should be [reset](https://git-scm.com/docs/git-reset) to a previous commit so the commit with tag \`${
nextRelease.gitTag
}\` is removed from the branch history.
See the [workflow configuration documentation](${linkify('docs/usage/workflow-configuration.md')}) for more details.`,
}),
};
+15 -1
View File
@@ -71,8 +71,22 @@ module.exports = {
pipelineConfig: () => ({
// Add `nextRelease` and plugin properties to published release
transform: (release, step, {nextRelease}) => ({
...(isPlainObject(release) ? release : {}),
...nextRelease,
...(release || {}),
...step,
}),
}),
},
addChannel: {
default: ['@semantic-release/npm', '@semantic-release/github'],
required: false,
dryRun: false,
outputValidator: output => !output || isPlainObject(output),
pipelineConfig: () => ({
// Add `nextRelease` and plugin properties to published release
transform: (release, step, {nextRelease}) => ({
...nextRelease,
...(release || {}),
...step,
}),
}),
+6 -13
View File
@@ -1,6 +1,5 @@
const gitLogParser = require('git-log-parser');
const getStream = require('get-stream');
const debug = require('debug')('semantic-release:get-commits');
const {getCommits} = require('./git');
/**
* 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.
@@ -9,21 +8,15 @@ const debug = require('debug')('semantic-release:get-commits');
*
* @return {Promise<Array<Object>>} The list of commits on the branch `branch` since the last release.
*/
module.exports = async ({cwd, env, lastRelease: {gitHead}, logger}) => {
if (gitHead) {
debug('Use gitHead: %s', gitHead);
module.exports = async ({cwd, env, lastRelease: {gitHead: from}, nextRelease: {gitHead: to = 'HEAD'} = {}, logger}) => {
if (from) {
debug('Use from: %s', from);
} else {
logger.log('No previous release found, retrieving all commits');
}
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`}, {cwd, env: {...process.env, ...env}})
)).map(commit => {
commit.message = commit.message.trim();
commit.gitTags = commit.gitTags.trim();
return commit;
});
const commits = await getCommits(from, to, {cwd, env});
logger.log(`Found ${commits.length} commits since last release`);
debug('Parsed commits: %o', commits);
return commits;
+9 -1
View File
@@ -62,7 +62,14 @@ module.exports = async (context, opts) => {
// Set default options values if not defined yet
options = {
branch: 'master',
branches: [
'+([1-9])?(.{+([1-9]),x}).x',
'master',
'next',
'next-major',
{name: 'beta', prerelease: true},
{name: 'alpha', prerelease: true},
],
repositoryUrl: (await pkgRepoUrl({normalize: false, cwd})) || (await repoUrl({cwd, env})),
tagFormat: `v\${version}`,
plugins: [
@@ -73,6 +80,7 @@ module.exports = async (context, opts) => {
],
// Remove `null` and `undefined` options so they can be replaced with default ones
...pickBy(options, option => !isNil(option)),
...(options.branches ? {branches: castArray(options.branches)} : {}),
};
debug('options values: %O', options);
+2 -2
View File
@@ -24,7 +24,7 @@ const GIT_TOKENS = {
*
* @return {String} The formatted Git repository URL.
*/
module.exports = async ({cwd, env, options: {repositoryUrl, branch}}) => {
module.exports = async ({cwd, env, branch, options: {repositoryUrl}}) => {
const info = hostedGitInfo.fromUrl(repositoryUrl, {noGitPlus: true});
const {protocol, ...parsed} = parse(repositoryUrl);
@@ -38,7 +38,7 @@ module.exports = async ({cwd, env, options: {repositoryUrl, branch}}) => {
// Test if push is allowed without transforming the URL (e.g. is ssh keys are set up)
try {
await verifyAuth(repositoryUrl, branch, {cwd, env});
await verifyAuth(repositoryUrl, branch.name, {cwd, env});
} catch (error) {
const envVar = Object.keys(GIT_TOKENS).find(envVar => !isNil(env[envVar]));
const gitCredentials = `${GIT_TOKENS[envVar] || ''}${env[envVar] || ''}`;
+14 -26
View File
@@ -1,8 +1,6 @@
const {escapeRegExp, template} = require('lodash');
const {isUndefined} = require('lodash');
const semver = require('semver');
const pLocate = require('p-locate');
const debug = require('debug')('semantic-release:get-last-release');
const {getTags, isRefInHistory, getTagHead} = require('./git');
const {makeTag} = require('./utils');
/**
* Last release.
@@ -15,37 +13,27 @@ const {getTags, isRefInHistory, getTagHead} = require('./git');
/**
* Determine the Git tag and version of the last tagged release.
*
* - Obtain all the tags referencing commits in the current branch history
* - Filter out the ones that are not valid semantic version or doesn't match the `tagFormat`
* - Filter out the branch tags that are not valid semantic version
* - Sort the versions
* - Retrive the highest version
*
* @param {Object} context semantic-release context.
* @param {Object} params Function parameters.
* @param {Object} params.before Find only releases with version number lower than this version.
*
* @return {Promise<LastRelease>} The last tagged release or `undefined` if none is found.
* @return {LastRelease} The last tagged release or empty object if none is found.
*/
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 getTags({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))
)
module.exports = ({branch: {name, tags, type}, options: {tagFormat}, logger}, {before} = {}) => {
const [{version, gitTag, gitHead, channel} = {}] = tags
.filter(tag => type === 'prerelease' || !semver.prerelease(tag.version))
.filter(tag => isUndefined(before) || semver.lt(tag.version, before))
.sort((a, b) => semver.rcompare(a.version, b.version));
debug('found tags: %o', tags);
const tag = await pLocate(tags, tag => isRefInHistory(tag.gitTag, {cwd, env}), {preserveOrder: true});
if (tag) {
logger.log(`Found git tag ${tag.gitTag} associated with version ${tag.version}`);
return {gitHead: await getTagHead(tag.gitTag, {cwd, env}), ...tag};
if (gitTag) {
logger.log(`Found git tag ${gitTag} associated with version ${version} on branch ${name}`);
return {version, gitTag, gitHead, channel, name: makeTag(tagFormat, version)};
}
logger.log('No git tag version found');
logger.log(`No git tag version found on branch ${name}`);
return {};
};
+10 -5
View File
@@ -1,13 +1,18 @@
const semver = require('semver');
const {FIRST_RELEASE} = require('./definitions/constants');
const {FIRST_RELEASE, FIRSTPRERELEASE} = require('./definitions/constants');
module.exports = ({nextRelease: {type}, lastRelease, logger}) => {
module.exports = ({branch, nextRelease: {type}, lastRelease, logger}) => {
let version;
if (lastRelease.version) {
version = semver.inc(lastRelease.version, type);
logger.log(`The next release version is ${version}`);
version =
branch.type === 'prerelease'
? semver.prerelease(lastRelease.version)
? semver.inc(lastRelease.version, 'prerelease')
: `${semver.inc(lastRelease.version, type)}-${branch.prerelease}.${FIRSTPRERELEASE}`
: semver.inc(lastRelease.version, type);
logger.log('The next release version is %s', version);
} else {
version = FIRST_RELEASE;
version = branch.type === 'prerelease' ? `${FIRST_RELEASE}-${branch.prerelease}.${FIRSTPRERELEASE}` : FIRST_RELEASE;
logger.log(`There is no previous release, the next release version is ${version}`);
}
+64
View File
@@ -0,0 +1,64 @@
const {uniq} = require('lodash');
const semver = require('semver');
const semverDiff = require('semver-diff');
const getLastRelease = require('./get-last-release');
const {makeTag} = require('./utils');
/**
* Find releases that have been merged from from a higher branch but not added on the channel of the current branch.
*
* @param {Object} context semantic-release context.
*
* @return {Array<Object>} Last release and next release to be added on the channel of the current branch.
*/
module.exports = context => {
const {
branch,
branches,
options: {tagFormat},
} = context;
return (
branches
// Consider only releases of higher branches
.slice(branches.findIndex(({name}) => name === branch.name) + 1)
// Exclude prerelease branches
.filter(({type}) => type !== 'prerelease')
// Find higher branch releases merged to building branch but not released on associated channel
.reduce(
(releases, higherBranch) => [
...releases,
// For all unique release version of the higher branch merged on current branch
...uniq(branch.tags.filter(({channel}) => channel === higherBranch.channel))
// Find ones that are not released on the building branch channel
.filter(tag =>
branch.tags.every(
({version, channel}) =>
version !== tag.version || channel === higherBranch.channel || channel !== branch.channel
)
)
// Sort in ascending order to add the most recent release last
.sort((a, b) => semver.compare(a.version, b.version))
// Construct the last and next release to add to the building branch channel
.map(({version, gitHead, gitTag}) => {
const lastRelease = getLastRelease(context, {before: version});
const type = lastRelease.version ? semverDiff(lastRelease.version, version) : 'major';
const name = makeTag(tagFormat, version);
return {
lastRelease,
currentRelease: {type, version, channel: higherBranch.channel, gitTag, name, gitHead},
nextRelease: {
type,
version,
channel: branch.channel,
gitTag: makeTag(tagFormat, version, branch.channel),
name,
gitHead,
},
};
}),
],
[]
)
);
};
+100 -11
View File
@@ -1,13 +1,18 @@
const {trimStart, matches, pick, memoize} = require('lodash');
const gitLogParser = require('git-log-parser');
const getStream = require('get-stream');
const execa = require('execa');
const debug = require('debug')('semantic-release:git');
Object.assign(gitLogParser.fields, {hash: 'H', message: 'B', gitTags: 'd', committerDate: {key: 'ci', type: Date}});
/**
* 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`.
* @return {String} The commit sha of the tag in parameter or `null`.
*/
async function getTagHead(tagName, execaOpts) {
try {
@@ -33,19 +38,67 @@ async function getTags(execaOpts) {
}
/**
* Verify if the `ref` is in the direct history of the current branch.
* Retrieve a range of commits.
*
* @param {String} from to includes all commits made after this sha (does not include this sha).
* @param {String} to to includes all commits made before this sha (also include this sha).
* @param {Object} [execaOpts] Options to pass to `execa`.
* @return {Promise<Array<Object>>} The list of commits between `from` and `to`.
*/
async function getCommits(from, to, execaOpts) {
return (await getStream.array(
gitLogParser.parse(
{_: `${from ? from + '..' : ''}${to}`},
{cwd: execaOpts.cwd, env: {...process.env, ...execaOpts.env}}
)
)).map(({message, gitTags, ...commit}) => ({...commit, message: message.trim(), gitTags: gitTags.trim()}));
}
/**
* Get all the repository branches.
*
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Array<String>} List of git branches.
* @throws {Error} If the `git` command fails.
*/
async function getBranches(execaOpts) {
return (await execa.stdout('git', ['branch', '--list', '--no-color'], execaOpts))
.split('\n')
.map(branch => trimStart(branch, '*').trim())
.filter(Boolean);
}
const getBranchCommits = memoize((branch, execaOpts) =>
getStream.array(gitLogParser.parse({_: branch}, {cwd: execaOpts.cwd, env: {...process.env, ...execaOpts.env}}))
);
/**
* Verify if the `ref` is in the direct history of a given branch.
*
* @param {String} ref The reference to look for.
* @param {String} branch The branch for which to check if the `ref` is in history.
* @param {Boolean} findRebasedTags Weither consider in history tags associated with a commit that was rebased to another branch (i.e. GitHub Rebase and Merge feature).
* @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, execaOpts) {
async function isRefInHistory(ref, branch, findRebasedTags, execaOpts) {
if (!(await isRefExists(branch, execaOpts))) {
return false;
}
try {
await execa('git', ['merge-base', '--is-ancestor', ref, 'HEAD'], execaOpts);
await execa('git', ['merge-base', '--is-ancestor', ref, branch], execaOpts);
return true;
} catch (error) {
if (error.code === 1) {
if (findRebasedTags) {
const [tagCommit] = await getStream.array(
gitLogParser.parse({_: ref, n: '1'}, {cwd: execaOpts.cwd, env: {...process.env, ...execaOpts.env}})
);
return (await getBranchCommits(branch, execaOpts)).some(matches(pick(tagCommit, ['message', 'author'])));
}
return false;
}
@@ -54,17 +107,32 @@ async function isRefInHistory(ref, execaOpts) {
}
}
/**
* Verify if the `ref` exits
*
* @param {String} ref The reference to verify.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Boolean} `true` if the reference exists, falsy otherwise.
*/
async function isRefExists(ref, execaOpts) {
try {
return (await execa('git', ['rev-parse', '--verify', ref], execaOpts)).code === 0;
} catch (error) {
debug(error);
}
}
/**
* 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, execaOpts) {
async function fetch(execaOpts) {
try {
await execa('git', ['fetch', '--unshallow', '--tags', repositoryUrl], execaOpts);
await execa('git', ['fetch', '--unshallow', '--tags'], execaOpts);
} catch (error) {
await execa('git', ['fetch', '--tags', repositoryUrl], execaOpts);
await execa('git', ['fetch', '--tags'], execaOpts);
}
}
@@ -131,12 +199,13 @@ async function verifyAuth(repositoryUrl, branch, execaOpts) {
* Tag the commit head on the local repository.
*
* @param {String} tagName The name of the tag.
* @param {String} ref The Git reference to tag.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @throws {Error} if the tag creation failed.
*/
async function tag(tagName, execaOpts) {
await execa('git', ['tag', tagName], execaOpts);
async function tag(tagName, ref, execaOpts) {
await execa('git', ['tag', tagName, ref], execaOpts);
}
/**
@@ -168,6 +237,22 @@ async function verifyTagName(tagName, execaOpts) {
}
}
/**
* Verify a branch name is a valid Git reference.
*
* @param {String} branch the branch name to verify.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Boolean} `true` if valid, falsy otherwise.
*/
async function verifyBranchName(branch, execaOpts) {
try {
return (await execa('git', ['check-ref-format', `refs/heads/${branch}`], execaOpts)).code === 0;
} catch (error) {
debug(error);
}
}
/**
* Verify the local branch is up to date with the remote one.
*
@@ -179,7 +264,7 @@ async function verifyTagName(tagName, execaOpts) {
async function isBranchUpToDate(branch, execaOpts) {
const remoteHead = await execa.stdout('git', ['ls-remote', '--heads', 'origin', branch], execaOpts);
try {
return await isRefInHistory(remoteHead.match(/^(\w+)?/)[1], execaOpts);
return await isRefInHistory(remoteHead.match(/^(\w+)?/)[1], branch, false, execaOpts);
} catch (error) {
debug(error);
}
@@ -188,7 +273,10 @@ async function isBranchUpToDate(branch, execaOpts) {
module.exports = {
getTagHead,
getTags,
getCommits,
getBranches,
isRefInHistory,
isRefExists,
fetch,
getGitHead,
repoUrl,
@@ -198,4 +286,5 @@ module.exports = {
push,
verifyTagName,
isBranchUpToDate,
verifyBranchName,
};
+70 -2
View File
@@ -1,4 +1,5 @@
const {isFunction} = require('lodash');
const {isFunction, union, template} = require('lodash');
const semver = require('semver');
const hideSensitive = require('./hide-sensitive');
function extractErrors(err) {
@@ -17,4 +18,71 @@ function hideSensitiveValues(env, objs) {
});
}
module.exports = {extractErrors, hideSensitiveValues};
function tagsToVersions(tags) {
return tags.map(({version}) => version);
}
function isMajorRange(range) {
return /^\d\.x(?:\.x)?$/i.test(range);
}
function isMaintenanceRange(range) {
return /^\d\.[\dx](?:\.x)?$/i.test(range);
}
function getUpperBound(range) {
return semver.valid(range) ? range : ((semver.validRange(range) || '').match(/<(\d\.\d\.\d)$/) || [])[1];
}
function getLowerBound(range) {
return ((semver.validRange(range) || '').match(/(\d\.\d\.\d)/) || [])[1];
}
function highest(version1, version2) {
return version1 && version2 ? (semver.gt(version1, version2) ? version1 : version2) : version1 || version2;
}
function lowest(version1, version2) {
return version1 && version2 ? (semver.lt(version1, version2) ? version1 : version2) : version1 || version2;
}
function getLatestVersion(versions, {withPrerelease} = {}) {
return versions.filter(version => withPrerelease || !semver.prerelease(version)).sort(semver.rcompare)[0];
}
function getEarliestVersion(versions, {withPrerelease} = {}) {
return versions.filter(version => withPrerelease || !semver.prerelease(version)).sort(semver.compare)[0];
}
function getFirstVersion(versions, lowerBranches) {
const lowerVersion = union(...lowerBranches.map(({tags}) => tagsToVersions(tags))).sort(semver.rcompare);
if (lowerVersion[0]) {
return versions.sort(semver.compare).find(version => semver.gt(version, lowerVersion[0]));
}
return getEarliestVersion(versions);
}
function getRange(min, max) {
return `>=${min}${max ? ` <${max}` : ''}`;
}
function makeTag(tagFormat, version, channel) {
return template(tagFormat)({version: `${version}${channel ? `@${channel}` : ''}`});
}
module.exports = {
extractErrors,
hideSensitiveValues,
tagsToVersions,
isMajorRange,
isMaintenanceRange,
getUpperBound,
getLowerBound,
highest,
lowest,
getLatestVersion,
getEarliestVersion,
getFirstVersion,
getRange,
makeTag,
};
+10 -2
View File
@@ -1,9 +1,9 @@
const {template} = require('lodash');
const {template, isString, isPlainObject} = require('lodash');
const AggregateError = require('aggregate-error');
const {isGitRepo, verifyTagName} = require('./git');
const getError = require('./get-error');
module.exports = async ({cwd, env, options: {repositoryUrl, tagFormat}}) => {
module.exports = async ({cwd, env, options: {repositoryUrl, tagFormat, branches}}) => {
const errors = [];
if (!(await isGitRepo({cwd, env}))) {
@@ -24,6 +24,14 @@ module.exports = async ({cwd, env, options: {repositoryUrl, tagFormat}}) => {
errors.push(getError('ETAGNOVERSION', {tagFormat}));
}
branches.forEach(branch => {
if (
!((isString(branch) && branch.trim()) || (isPlainObject(branch) && isString(branch.name) && branch.name.trim()))
) {
errors.push(getError('EINVALIDBRANCH', {branch}));
}
});
if (errors.length > 0) {
throw new AggregateError(errors);
}