feat: get last release with git tags
- Remove the `getLastRelease` plugin type - Retrieve the last release based on Git tags - Create the next release Git tag before calling the `publish` plugins BREAKING CHANGE: Remove the `getLastRelease` plugin type The `getLastRelease` plugins will not be called anymore. BREAKING CHANGE: Git repository authentication is now mandatory The Git authentication is now mandatory and must be set via `GH_TOKEN`, `GITHUB_TOKEN`, `GL_TOKEN`, `GITLAB_TOKEN` or `GIT_CREDENTIALS` as described in [CI configuration](https://github.com/semantic-release/semantic-release/blob/caribou/docs/usage/ci-configuration.md#authentication).
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
function debugShell(message, shell, debug) {
|
||||
debug(message);
|
||||
debug('cmd: %O', shell.cmd);
|
||||
debug('stdout: %O', shell.stdout);
|
||||
debug('stderr: %O', shell.stderr);
|
||||
debug('code: %O', shell.code);
|
||||
}
|
||||
|
||||
module.exports = {debugShell};
|
||||
+6
-71
@@ -1,75 +1,21 @@
|
||||
const gitLogParser = require('git-log-parser');
|
||||
const getStream = require('get-stream');
|
||||
const debug = require('debug')('semantic-release:get-commits');
|
||||
const SemanticReleaseError = require('@semantic-release/error');
|
||||
const {unshallow, gitCommitTag, gitTagHead, isCommitInHistory} = require('./git');
|
||||
|
||||
/**
|
||||
* Commit message.
|
||||
* 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.
|
||||
*
|
||||
* @typedef {Object} Commit
|
||||
* @property {string} hash The commit hash.
|
||||
* @property {string} message The commit message.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Last release.
|
||||
*
|
||||
* @typedef {Object} LastRelease
|
||||
* @property {string} version The version number of the last release.
|
||||
* @property {string} [gitHead] The commit sha used to make the last release.
|
||||
* @property {string} [gitTag] The tag used to make the last release.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Result object.
|
||||
*
|
||||
* @typedef {Object} Result
|
||||
* @property {Array<Commit>} commits The list of commits since the last release.
|
||||
* @property {LastRelease} lastRelease The updated lastRelease.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieve the list of commits on the current branch since the last released version, or all the commits of the current branch if there is no last released version.
|
||||
*
|
||||
* The commit correspoding to the last released version is determined as follow:
|
||||
* - Use `lastRelease.gitHead` if defined and present in `branch` history.
|
||||
* - If `lastRelease.gitHead` is not in the `branch` history, unshallow the repository and try again.
|
||||
* - If `lastRelease.gitHead` is still not in the `branch` history, search for a tag named `v<version>` or `<version>` and verify if it's associated commit sha is present in `branch` history.
|
||||
*
|
||||
* @param {LastRelease} lastRelease The lastRelease object obtained from the getLastRelease plugin.
|
||||
* @param {string} branch The branch to release from.
|
||||
* @param {String} gitHead The commit sha associated with the last release.
|
||||
* @param {String} branch The branch to release from.
|
||||
* @param {Object} logger Global logger.
|
||||
*
|
||||
* @return {Promise<Result>} The list of commits on the branch `branch` since the last release and the updated lastRelease with the gitHead used to retrieve the commits.
|
||||
*
|
||||
* @throws {SemanticReleaseError} with code `ENOTINHISTORY` if `lastRelease.gitHead` or the commit sha derived from `config.lastRelease.version` is not in the direct history of `branch`.
|
||||
* @return {Promise<Array<Object>>} The list of commits on the branch `branch` since the last release.
|
||||
*/
|
||||
module.exports = async ({version, gitHead} = {}, branch, logger) => {
|
||||
module.exports = async (gitHead, branch, logger) => {
|
||||
if (gitHead) {
|
||||
// If gitHead doesn't exists in release branch
|
||||
if (!await isCommitInHistory(gitHead)) {
|
||||
// Unshallow the repository
|
||||
await unshallow();
|
||||
}
|
||||
// If gitHead still doesn't exists in release branch
|
||||
if (!await isCommitInHistory(gitHead)) {
|
||||
// Try to find the commit corresponding to the version, using got tags
|
||||
const tagHead = (await gitTagHead(`v${version}`)) || (await gitTagHead(version));
|
||||
|
||||
// If tagHead doesn't exists in release branch
|
||||
if (!tagHead || !await isCommitInHistory(tagHead)) {
|
||||
// Then the commit corresponding to the version cannot be found in the bracnh hsitory
|
||||
logger.error(notInHistoryMessage(gitHead, branch, version));
|
||||
throw new SemanticReleaseError('Commit not in history', 'ENOTINHISTORY');
|
||||
}
|
||||
gitHead = tagHead;
|
||||
}
|
||||
debug('Use gitHead: %s', gitHead);
|
||||
} else {
|
||||
logger.log('No previous release found, retrieving all commits');
|
||||
// If there is no gitHead nor a version, there is no previous release. Unshallow the repo in order to retrieve all commits
|
||||
await unshallow();
|
||||
}
|
||||
|
||||
Object.assign(gitLogParser.fields, {hash: 'H', message: 'B', gitTags: 'd', committerDate: {key: 'ci', type: Date}});
|
||||
@@ -82,16 +28,5 @@ module.exports = async ({version, gitHead} = {}, branch, logger) => {
|
||||
);
|
||||
logger.log('Found %s commits since last release', commits.length);
|
||||
debug('Parsed commits: %o', commits);
|
||||
return {commits, lastRelease: {version, gitHead, gitTag: await gitCommitTag(gitHead)}};
|
||||
return commits;
|
||||
};
|
||||
|
||||
function notInHistoryMessage(gitHead, branch, version) {
|
||||
return `The commit the last release of this package was derived from is not in the direct history of the "${branch}" branch.
|
||||
This means semantic-release can not extract the commits between now and then.
|
||||
This is usually caused by force pushing, releasing from an unrelated branch, or using an already existing package name.
|
||||
|
||||
You can recover from this error by restoring the commit "${gitHead}" or by creating a tag for the version "${version}" on the commit corresponding to this release:
|
||||
$ git tag -f v${version || '<version>'} <commit sha1 corresponding to last release>
|
||||
$ git push -f --tags origin ${branch}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const debug = require('debug')('semantic-release:config');
|
||||
const {repoUrl} = require('./git');
|
||||
const PLUGINS_DEFINITION = require('./plugins/definitions');
|
||||
const plugins = require('./plugins');
|
||||
const getGitAuthUrl = require('./get-git-auth-url');
|
||||
|
||||
module.exports = async (opts, logger) => {
|
||||
const {config} = (await cosmiconfig('release', {rcExtensions: true}).load(process.cwd())) || {};
|
||||
@@ -64,6 +65,8 @@ module.exports = async (opts, logger) => {
|
||||
throw new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL');
|
||||
}
|
||||
|
||||
options.repositoryUrl = getGitAuthUrl(options.repositoryUrl);
|
||||
|
||||
return {options, plugins: await plugins(options, pluginsPath, logger)};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const {parse, format} = require('url');
|
||||
const {isUndefined} = require('lodash');
|
||||
const gitUrlParse = require('git-url-parse');
|
||||
|
||||
const GIT_TOKENS = ['GH_TOKEN', 'GITHUB_TOKEN', 'GL_TOKEN', 'GITLAB_TOKEN', 'GIT_CREDENTIALS'];
|
||||
|
||||
/**
|
||||
* Generate the git repository URL with creadentials.
|
||||
* If the `gitCredentials` is defined, returns a http or https URL with Basic Authentication (`https://username:passowrd@hostname:port/path.git`).
|
||||
* If the `gitCredentials` is undefined, returns the `repositoryUrl`. In that case it's expected for the user to have setup the Git authentication on the CI (for example via SSH keys).
|
||||
*
|
||||
* @param {String} gitCredentials Basic HTTP Authentication credentials, can be `username:password` or a token for certain Git providers.
|
||||
* @param {String} repositoryUrl The git repository URL.
|
||||
* @return {String} The formatted Git repository URL.
|
||||
*/
|
||||
module.exports = repositoryUrl => {
|
||||
const envVar = GIT_TOKENS.find(envVar => !isUndefined(process.env[envVar]));
|
||||
const gitCredentials = ['GL_TOKEN', 'GITLAB_TOKEN'].includes(envVar)
|
||||
? `gitlab-ci-token:${process.env[envVar]}`
|
||||
: process.env[envVar];
|
||||
|
||||
if (!gitCredentials) {
|
||||
return repositoryUrl;
|
||||
}
|
||||
|
||||
const {protocols} = gitUrlParse(repositoryUrl);
|
||||
const protocol = protocols.includes('https') ? 'https' : protocols.includes('http') ? 'http' : 'https';
|
||||
return format({...parse(`${gitUrlParse(repositoryUrl).toString(protocol)}.git`), ...{auth: gitCredentials}});
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
const semver = require('semver');
|
||||
const pLocate = require('p-locate');
|
||||
const debug = require('debug')('semantic-release:get-last-release');
|
||||
const {gitTags, isRefInHistory, gitTagHead} = require('./git');
|
||||
|
||||
/**
|
||||
* Last release.
|
||||
*
|
||||
* @typedef {Object} LastRelease
|
||||
* @property {string} version The version number of the last release.
|
||||
* @property {string} [gitHead] The Git reference used to make the last release.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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
|
||||
* - Sort the tags
|
||||
* - Retrive the highest tag
|
||||
*
|
||||
* @param {Object} logger Global logger.
|
||||
* @return {Promise<LastRelease>} The last tagged release or `undefined` if none is found.
|
||||
*/
|
||||
module.exports = async logger => {
|
||||
const tags = (await gitTags()).filter(tag => semver.valid(semver.clean(tag))).sort(semver.rcompare);
|
||||
debug('found tags: %o', tags);
|
||||
|
||||
if (tags.length > 0) {
|
||||
const gitTag = await pLocate(tags, tag => isRefInHistory(tag), {concurrency: 1, preserveOrder: true});
|
||||
logger.log('Found git tag version %s', gitTag);
|
||||
return {gitTag, gitHead: await gitTagHead(gitTag), version: semver.valid(semver.clean(gitTag))};
|
||||
}
|
||||
|
||||
logger.log('No git tag version found');
|
||||
return {};
|
||||
};
|
||||
+80
-44
@@ -1,6 +1,5 @@
|
||||
const execa = require('execa');
|
||||
const debug = require('debug')('semantic-release:get-version-head');
|
||||
const {debugShell} = require('./debug');
|
||||
|
||||
/**
|
||||
* Get the commit sha for a given tag.
|
||||
@@ -10,45 +9,29 @@ const {debugShell} = require('./debug');
|
||||
* @return {string} The commit sha of the tag in parameter or `null`.
|
||||
*/
|
||||
async function gitTagHead(tagName) {
|
||||
try {
|
||||
const shell = await execa('git', ['rev-list', '-1', tagName]);
|
||||
debugShell('Get git tag head', shell, debug);
|
||||
return shell.stdout;
|
||||
} catch (err) {
|
||||
debug(err);
|
||||
return null;
|
||||
}
|
||||
return execa.stdout('git', ['rev-list', '-1', tagName], {reject: false});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tag associated with a commit sha.
|
||||
*
|
||||
* @param {string} gitHead The commit sha for which to retrieve the associated tag.
|
||||
*
|
||||
* @return {string} The tag associatedwith the sha in parameter or `undefined`.
|
||||
* @return {Array<String>} List of git tags.
|
||||
* @throws {Error} If the `git` command fails.
|
||||
*/
|
||||
async function gitCommitTag(gitHead) {
|
||||
try {
|
||||
const shell = await execa('git', ['describe', '--tags', '--exact-match', gitHead]);
|
||||
debugShell('Get git commit tag', shell, debug);
|
||||
return shell.stdout;
|
||||
} catch (err) {
|
||||
debug(err);
|
||||
return undefined;
|
||||
}
|
||||
async function gitTags() {
|
||||
return (await execa.stdout('git', ['tag']))
|
||||
.split('\n')
|
||||
.map(tag => tag.trim())
|
||||
.filter(tag => Boolean(tag));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if the commit `sha` is in the direct history of the current branch.
|
||||
* Verify if the `ref` is in the direct history of the current branch.
|
||||
*
|
||||
* @param {string} sha The sha of the commit to look for.
|
||||
* @param {string} ref The reference to look for.
|
||||
*
|
||||
* @return {boolean} `true` if the commit `sha` is in the history of the current branch, `false` otherwise.
|
||||
* @return {boolean} `true` if the reference is in the history of the current branch, `false` otherwise.
|
||||
*/
|
||||
async function isCommitInHistory(sha) {
|
||||
const shell = await execa('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], {reject: false});
|
||||
debugShell('Check if commit is in history', shell, debug);
|
||||
return shell.code === 0;
|
||||
async function isRefInHistory(ref) {
|
||||
return (await execa('git', ['merge-base', '--is-ancestor', ref, 'HEAD'], {reject: false})).code === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,30 +45,83 @@ async function unshallow() {
|
||||
* @return {string} the sha of the HEAD commit.
|
||||
*/
|
||||
async function gitHead() {
|
||||
try {
|
||||
const shell = await execa('git', ['rev-parse', 'HEAD']);
|
||||
debugShell('Get git head', shell, debug);
|
||||
return shell.stdout;
|
||||
} catch (err) {
|
||||
debug(err);
|
||||
throw new Error(err.stderr);
|
||||
}
|
||||
return execa.stdout('git', ['rev-parse', 'HEAD']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string|undefined} The value of the remote git URL.
|
||||
* @return {string} The value of the remote git URL.
|
||||
*/
|
||||
async function repoUrl() {
|
||||
return (await execa.stdout('git', ['remote', 'get-url', 'origin'], {reject: false})) || undefined;
|
||||
return execa.stdout('git', ['remote', 'get-url', 'origin'], {reject: false});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Boolean} `true` if the current working directory is in a git repository, `false` otherwise.
|
||||
*/
|
||||
async function isGitRepo() {
|
||||
const shell = await execa('git', ['rev-parse', '--git-dir'], {reject: false});
|
||||
debugShell('Check if the current working directory is a git repository', shell, debug);
|
||||
return shell.code === 0;
|
||||
return (await execa('git', ['rev-parse', '--git-dir'], {reject: false})).code === 0;
|
||||
}
|
||||
|
||||
module.exports = {gitTagHead, gitCommitTag, isCommitInHistory, unshallow, gitHead, repoUrl, isGitRepo};
|
||||
/**
|
||||
* Verify the write access authorization to remote repository with push dry-run.
|
||||
*
|
||||
* @param {String} origin The remote repository URL.
|
||||
* @param {String} branch The repositoru branch for which to verify write access.
|
||||
*
|
||||
* @return {Boolean} `true` is authorized to push, `false` otherwise.
|
||||
*/
|
||||
async function verifyAuth(origin, branch) {
|
||||
return (await execa('git', ['push', '--dry-run', origin, `HEAD:${branch}`], {reject: false})).code === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag the commit head on the local repository.
|
||||
*
|
||||
* @param {String} tagName The name of the tag.
|
||||
* @throws {Error} if the tag creation failed.
|
||||
*/
|
||||
async function tag(tagName) {
|
||||
await execa('git', ['tag', tagName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push to the remote repository.
|
||||
*
|
||||
* @param {String} origin The remote repository URL.
|
||||
* @param {String} branch The branch to push.
|
||||
* @throws {Error} if the push failed.
|
||||
*/
|
||||
async function push(origin, branch) {
|
||||
await execa('git', ['push', '--tags', origin, `HEAD:${branch}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag locally and remotely.
|
||||
*
|
||||
* @param {String} origin The remote repository URL.
|
||||
* @param {String} tagName The tag name to delete.
|
||||
* @throws {SemanticReleaseError} if the remote tag exists and references a commit that is not the local head commit.
|
||||
*/
|
||||
async function deleteTag(origin, tagName) {
|
||||
// Delete the local tag
|
||||
let shell = await execa('git', ['tag', '-d', tagName], {reject: false});
|
||||
debug('delete local tag', shell);
|
||||
|
||||
// Delete the tag remotely
|
||||
shell = await execa('git', ['push', '-d', origin, tagName], {reject: false});
|
||||
debug('delete remote tag', shell);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
gitTagHead,
|
||||
gitTags,
|
||||
isRefInHistory,
|
||||
unshallow,
|
||||
gitHead,
|
||||
repoUrl,
|
||||
isGitRepo,
|
||||
verifyAuth,
|
||||
tag,
|
||||
push,
|
||||
deleteTag,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const {isString, isObject, isFunction, isArray} = require('lodash');
|
||||
const semver = require('semver');
|
||||
const {isString, isFunction, isArray} = require('lodash');
|
||||
|
||||
const RELEASE_TYPE = ['major', 'premajor', 'minor', 'preminor', 'patch', 'prepatch', 'prerelease'];
|
||||
const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf);
|
||||
@@ -13,22 +12,6 @@ module.exports = {
|
||||
'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.',
|
||||
},
|
||||
},
|
||||
getLastRelease: {
|
||||
default: '@semantic-release/npm',
|
||||
config: {
|
||||
validator: conf => Boolean(conf) && validatePluginConfig(conf),
|
||||
message:
|
||||
'The "getLastRelease" plugin is mandatory, and must be a single plugin definition. A plugin definition is either a string or an object with a path property.',
|
||||
},
|
||||
output: {
|
||||
validator: output =>
|
||||
!output ||
|
||||
(isObject(output) && !output.version) ||
|
||||
(isString(output.version) && Boolean(semver.valid(semver.clean(output.version))) && Boolean(output.gitHead)),
|
||||
message:
|
||||
'The "getLastRelease" plugin output if defined, must be an object with a valid semver version in the "version" property and the corresponding git reference in "gitHead" property.',
|
||||
},
|
||||
},
|
||||
analyzeCommits: {
|
||||
default: '@semantic-release/commit-analyzer',
|
||||
config: {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const SemanticReleaseError = require('@semantic-release/error');
|
||||
const {isGitRepo, verifyAuth} = require('./git');
|
||||
|
||||
module.exports = async (options, branch, logger) => {
|
||||
if (!await isGitRepo()) {
|
||||
logger.error('Semantic-release must run from a git repository.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await verifyAuth(options.repositoryUrl, options.branch)) {
|
||||
throw new SemanticReleaseError(
|
||||
`The git credentials doesn't allow to push on the branch ${options.branch}.`,
|
||||
'EGITNOPERMISSION'
|
||||
);
|
||||
}
|
||||
|
||||
if (branch !== options.branch) {
|
||||
logger.log(
|
||||
`This test run was triggered on the branch ${branch}, while semantic-release is configured to only publish from ${
|
||||
options.branch
|
||||
}, therefore a new version won’t be published.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
Reference in New Issue
Block a user