diff --git a/cli.js b/cli.js index 61b128970f..6d48c8412e 100755 --- a/cli.js +++ b/cli.js @@ -11,6 +11,7 @@ module.exports = async () => { .description('Run automated package publishing') .option('-b, --branch ', 'Branch to release from') .option('-r, --repository-url ', 'Git repository URL') + .option('-t, --tag-format ', `Git tag format`) .option('-e, --extends ', 'Comma separated list of shareable config paths or packages name', list) .option( '--verify-conditions ', diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 8d2b5b1a18..b40622851e 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -65,6 +65,18 @@ Any valid git url format is supported (See [Git protocols](https://git-scm.com/b **Note**: 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.** +### tagFormat + +Type: `String` + +Default: `v${version}` + +CLI arguments: `-t`, `--tag-format` + +The [Git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) format used by **semantic-release** to identify releases. The tag name is generated with [Lodash template](https://lodash.com/docs#template) and will be compiled with the `version` variable. + +**Note**: The `tagFormat` must contain the `version` variable and compile to a [valid Git reference](https://git-scm.com/docs/git-check-ref-format#_description). + ### dryRun Type: `Boolean` diff --git a/index.js b/index.js index 5dd49948fa..d16bd8f8b1 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,4 @@ +const {template} = require('lodash'); const marked = require('marked'); const TerminalRenderer = require('marked-terminal'); const envCi = require('env-ci'); @@ -38,7 +39,7 @@ async function run(opts) { // Unshallow the repo in order to get all the tags await unshallow(); - const lastRelease = await getLastRelease(logger); + const lastRelease = await getLastRelease(options.tagFormat, logger); const commits = await getCommits(lastRelease.gitHead, options.branch, logger); logger.log('Call plugin %s', 'analyze-commits'); @@ -53,7 +54,7 @@ async function run(opts) { return; } const version = getNextVersion(type, lastRelease, logger); - const nextRelease = {type, version, gitHead: await getGitHead(), gitTag: `v${version}`}; + const nextRelease = {type, version, gitHead: await getGitHead(), gitTag: template(options.tagFormat)({version})}; logger.log('Call plugin %s', 'verify-release'); await plugins.verifyRelease({options, logger, lastRelease, commits, nextRelease}, true); diff --git a/lib/get-config.js b/lib/get-config.js index 422dd9b5e7..9373aa6eb4 100644 --- a/lib/get-config.js +++ b/lib/get-config.js @@ -46,6 +46,7 @@ module.exports = async (opts, logger) => { options = { branch: 'master', repositoryUrl: (await pkgRepoUrl()) || (await repoUrl()), + tagFormat: `v\${version}`, // Remove `null` and `undefined` options so they can be replaced with default ones ...pickBy(options, option => !isUndefined(option) && !isNull(option)), }; diff --git a/lib/get-last-release.js b/lib/get-last-release.js index bf34d56bd0..179b58077c 100644 --- a/lib/get-last-release.js +++ b/lib/get-last-release.js @@ -1,3 +1,4 @@ +const {escapeRegExp, template} = require('lodash'); const semver = require('semver'); const pLocate = require('p-locate'); const debug = require('debug')('semantic-release:get-last-release'); @@ -15,21 +16,36 @@ const {gitTags, isRefInHistory, gitTagHead} = 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 - * - Sort the tags - * - Retrive the highest tag + * - Filter out the ones that are not valid semantic version or doesn't match the `tagFormat` + * - Sort the versions + * - Retrive the highest version * * @param {Object} logger Global logger. * @return {Promise} 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); +module.exports = async (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()) + .map(tag => { + return {gitTag: tag, version: (tag.match(tagRegexp) || new Array(2))[1]}; + }) + .filter(tag => tag.version && semver.valid(semver.clean(tag.version))) + .sort((a, b) => semver.rcompare(a.version, b.version)); + 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))}; + const {gitTag, version} = await pLocate(tags, tag => isRefInHistory(tag.gitTag), { + concurrency: 1, + preserveOrder: true, + }); + logger.log('Found git tag %s associated with version %s', gitTag, version); + + return {gitHead: await gitTagHead(gitTag), gitTag, version}; } logger.log('No git tag version found'); diff --git a/lib/git.js b/lib/git.js index 18c8bb1172..9398ef73b8 100644 --- a/lib/git.js +++ b/lib/git.js @@ -112,6 +112,17 @@ async function deleteTag(origin, tagName) { debug('delete remote tag', shell); } +/** + * Verify a tag name is a valid Git reference. + * + * @method verifyTagName + * @param {string} tagName the tag name to verify. + * @return {boolean} `true` if valid, `false` otherwise. + */ +async function verifyTagName(tagName) { + return (await execa('git', ['check-ref-format', `refs/tags/${tagName}`], {reject: false})).code === 0; +} + module.exports = { gitTagHead, gitTags, @@ -124,4 +135,5 @@ module.exports = { tag, push, deleteTag, + verifyTagName, }; diff --git a/lib/verify.js b/lib/verify.js index f4050865d1..7cbc23f179 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -1,6 +1,7 @@ +const {template} = require('lodash'); const SemanticReleaseError = require('@semantic-release/error'); const AggregateError = require('aggregate-error'); -const {isGitRepo, verifyAuth} = require('./git'); +const {isGitRepo, verifyAuth, verifyTagName} = require('./git'); module.exports = async (options, branch, logger) => { const errors = []; @@ -21,6 +22,25 @@ module.exports = async (options, branch, logger) => { ); } + // Verify that compiling the `tagFormat` produce a valid Git tag + if (!await verifyTagName(template(options.tagFormat)({version: '0.0.0'}))) { + errors.push( + new SemanticReleaseError('The tagFormat template must compile to a valid Git tag format', 'EINVALIDTAGFORMAT') + ); + } + + // 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( + new SemanticReleaseError( + `The tagFormat template must contain the variable "\${version}" exactly once`, + 'ETAGNOVERSION' + ) + ); + } + if (errors.length > 0) { throw new AggregateError(errors); } diff --git a/test/get-config.test.js b/test/get-config.test.js index 4cb362aa26..f2cf82429f 100644 --- a/test/get-config.test.js +++ b/test/get-config.test.js @@ -45,6 +45,7 @@ test.serial('Default values, reading repositoryUrl from package.json', async t = // Verify the default options are set t.is(options.branch, 'master'); t.is(options.repositoryUrl, 'git@package.com:owner/module.git'); + t.is(options.tagFormat, `v\${version}`); }); test.serial('Default values, reading repositoryUrl from repo if not set in package.json', async t => { @@ -58,6 +59,7 @@ test.serial('Default values, reading repositoryUrl from repo if not set in packa // Verify the default options are set t.is(options.branch, 'master'); t.is(options.repositoryUrl, 'git@repo.com:owner/module.git'); + t.is(options.tagFormat, `v\${version}`); }); test.serial('Default values, reading repositoryUrl (http url) from package.json if not set in repo', async t => { @@ -72,6 +74,7 @@ test.serial('Default values, reading repositoryUrl (http url) from package.json // Verify the default options are set t.is(options.branch, 'master'); t.is(options.repositoryUrl, pkg.repository); + t.is(options.tagFormat, `v\${version}`); }); test.serial('Read options from package.json', async t => { @@ -80,6 +83,7 @@ test.serial('Read options from package.json', async t => { generateNotes: 'generateNotes', branch: 'test_branch', repositoryUrl: 'git+https://hostname.com/owner/module.git', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -100,6 +104,7 @@ test.serial('Read options from .releaserc.yml', async t => { analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, branch: 'test_branch', repositoryUrl: 'git+https://hostname.com/owner/module.git', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -120,6 +125,7 @@ test.serial('Read options from .releaserc.json', async t => { analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, branch: 'test_branch', repositoryUrl: 'git+https://hostname.com/owner/module.git', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -140,6 +146,7 @@ test.serial('Read options from .releaserc.js', async t => { analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, branch: 'test_branch', repositoryUrl: 'git+https://hostname.com/owner/module.git', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -160,6 +167,7 @@ test.serial('Read options from release.config.js', async t => { analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, branch: 'test_branch', repositoryUrl: 'git+https://hostname.com/owner/module.git', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -184,6 +192,7 @@ test.serial('Prioritise CLI/API parameters over file configuration and git repo' analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_cli'}, branch: 'branch_cli', repositoryUrl: 'http://cli-url.com/owner/package', + tagFormat: `cli\${version}`, }; const pkg = {release, repository: 'git@hostname.com:owner/module.git'}; // Create a git repository, set the current working directory at the root of the repo @@ -209,6 +218,7 @@ test.serial('Read configuration from file path in "extends"', async t => { generateNotes: 'generateNotes', branch: 'test_branch', repositoryUrl: 'git+https://hostname.com/owner/module.git', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -236,6 +246,7 @@ test.serial('Read configuration from module path in "extends"', async t => { generateNotes: 'generateNotes', branch: 'test_branch', repositoryUrl: 'git+https://hostname.com/owner/module.git', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -270,6 +281,7 @@ test.serial('Read configuration from an array of paths in "extends"', async t => generateNotes: 'generateNotes2', analyzeCommits: {path: 'analyzeCommits2', param: 'analyzeCommits_param2'}, branch: 'test_branch', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -307,6 +319,7 @@ test.serial('Prioritize configuration from config file over "extends"', async t publish: [{path: 'publishShareable', param: 'publishShareable_param'}], branch: 'test_branch', repositoryUrl: 'git+https://hostname.com/owner/module.git', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -352,6 +365,7 @@ test.serial('Prioritize configuration from cli/API options over "extends"', asyn analyzeCommits: 'analyzeCommits2', publish: [{path: 'publishShareable', param: 'publishShareable_param2'}], branch: 'test_branch2', + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -379,6 +393,7 @@ test.serial('Allow to unset properties defined in shareable config with "null"', const shareable = { generateNotes: 'generateNotes', analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo @@ -412,6 +427,7 @@ test.serial('Allow to unset properties defined in shareable config with "undefin const shareable = { generateNotes: 'generateNotes', analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, + tagFormat: `v\${version}`, }; // Create a git repository, set the current working directory at the root of the repo diff --git a/test/get-last-release.test.js b/test/get-last-release.test.js index 78277ae228..17dbd9c686 100644 --- a/test/get-last-release.test.js +++ b/test/get-last-release.test.js @@ -30,10 +30,10 @@ test.serial('Get the highest valid tag', async t => { await gitCommits(['Fourth']); await gitTagVersion('v3.0'); - const result = await getLastRelease(t.context.logger); + const result = await getLastRelease(`v\${version}`, t.context.logger); t.deepEqual(result, {gitHead: commits[0].hash, gitTag: 'v2.0.0', version: '2.0.0'}); - t.deepEqual(t.context.log.args[0], ['Found git tag version %s', 'v2.0.0']); + t.deepEqual(t.context.log.args[0], ['Found git tag %s associated with version %s', 'v2.0.0', '2.0.0']); }); test.serial('Get the highest tag in the history of the current branch', async t => { @@ -55,7 +55,7 @@ test.serial('Get the highest tag in the history of the current branch', async t // Create the tag corresponding to version 2.0.0 await gitTagVersion('v2.0.0'); - const result = await getLastRelease(t.context.logger); + const result = await getLastRelease(`v\${version}`, t.context.logger); t.deepEqual(result, {gitHead: commits[0].hash, gitTag: 'v2.0.0', version: '2.0.0'}); }); @@ -71,8 +71,50 @@ test.serial('Return empty object if no valid tag is found', async t => { await gitCommits(['Third']); await gitTagVersion('v3.0'); - const result = await getLastRelease(t.context.logger); + const result = await getLastRelease(`v\${version}`, t.context.logger); t.deepEqual(result, {}); t.is(t.context.log.args[0][0], 'No git tag version found'); }); + +test.serial('Get the highest valid tag corresponding to the "tagFormat"', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Create some commits and tags + const [{hash: gitHead}] = await gitCommits(['First']); + + await gitTagVersion('1.0.0'); + t.deepEqual(await getLastRelease(`\${version}`, t.context.logger), { + gitHead, + gitTag: '1.0.0', + version: '1.0.0', + }); + + await gitTagVersion('foo-1.0.0-bar'); + t.deepEqual(await getLastRelease(`foo-\${version}-bar`, t.context.logger), { + gitHead, + gitTag: 'foo-1.0.0-bar', + version: '1.0.0', + }); + + await gitTagVersion('foo-v1.0.0-bar'); + t.deepEqual(await getLastRelease(`foo-v\${version}-bar`, t.context.logger), { + gitHead, + gitTag: 'foo-v1.0.0-bar', + version: '1.0.0', + }); + + await gitTagVersion('(.+)/1.0.0/(a-z)'); + t.deepEqual(await getLastRelease(`(.+)/\${version}/(a-z)`, t.context.logger), { + gitHead, + gitTag: '(.+)/1.0.0/(a-z)', + version: '1.0.0', + }); + + await gitTagVersion('2.0.0-1.0.0-bar.1'); + t.deepEqual(await getLastRelease(`2.0.0-\${version}-bar.1`, t.context.logger), { + gitHead, + gitTag: '2.0.0-1.0.0-bar.1', + version: '1.0.0', + }); +}); diff --git a/test/git.test.js b/test/git.test.js index 215b55539b..f56a284223 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -11,6 +11,7 @@ import { gitTags, isGitRepo, deleteTag, + verifyTagName, } from '../lib/git'; import { gitRepo, @@ -175,6 +176,20 @@ test.serial('Return "false" if not in a Git repository', async t => { t.false(await isGitRepo()); }); +test.serial('Return "true" for valid tag names', async t => { + t.true(await verifyTagName('1.0.0')); + t.true(await verifyTagName('v1.0.0')); + t.true(await verifyTagName('tag_name')); + t.true(await verifyTagName('tag/name')); +}); + +test.serial('Return "false" for invalid tag names', async t => { + t.false(await verifyTagName('?1.0.0')); + t.false(await verifyTagName('*1.0.0')); + t.false(await verifyTagName('[1.0.0]')); + t.false(await verifyTagName('1.0.0..')); +}); + test.serial('Throws error if obtaining the tags fails', async t => { const dir = tempy.directory(); process.chdir(dir); diff --git a/test/index.test.js b/test/index.test.js index 9f378a5f39..11a16fcf27 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -68,7 +68,7 @@ test.serial('Plugins are called with expected values', async t => { const generateNotes = stub().resolves(notes); const publish = stub().resolves(); - const config = {branch: 'master', repositoryUrl, globalOpt: 'global'}; + const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `v\${version}`}; const options = { ...config, verifyConditions: [verifyConditions1, verifyConditions2], @@ -130,6 +130,41 @@ test.serial('Plugins are called with expected values', async t => { t.is(await gitRemoteTagHead(repositoryUrl, nextRelease.gitTag), nextRelease.gitHead); }); +test.serial('Use custom tag format', async t => { + const repositoryUrl = await gitRepo(true); + await gitCommits(['First']); + await gitTagVersion('test-1.0.0'); + await gitCommits(['Second']); + + const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'test-2.0.0'}; + const notes = 'Release notes'; + const verifyConditions = stub().resolves(); + const analyzeCommits = stub().resolves(nextRelease.type); + const verifyRelease = stub().resolves(); + const generateNotes = stub().resolves(notes); + const publish = stub().resolves(); + + const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `test-\${version}`}; + const options = { + ...config, + verifyConditions, + analyzeCommits, + verifyRelease, + generateNotes, + publish, + }; + + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + t.truthy(await semanticRelease(options)); + + // Verify the tag has been created on the local and remote repo and reference the gitHead + t.is(await gitTagHead(nextRelease.gitTag), nextRelease.gitHead); + t.is(await gitRemoteTagHead(repositoryUrl, nextRelease.gitTag), nextRelease.gitHead); +}); + test.serial('Use new gitHead, and recreate release notes if a publish plugin create a commit', async t => { // Create a git repository, set the current working directory at the root of the repo const repositoryUrl = await gitRepo(true); diff --git a/test/verify.test.js b/test/verify.test.js new file mode 100644 index 0000000000..08e1122e8c --- /dev/null +++ b/test/verify.test.js @@ -0,0 +1,100 @@ +import test from 'ava'; +import {stub} from 'sinon'; +import tempy from 'tempy'; +import verify from '../lib/verify'; +import {gitRepo} from './helpers/git-utils'; + +// Save the current process.env +const envBackup = Object.assign({}, process.env); +// Save the current working diretory +const cwd = process.cwd(); + +test.beforeEach(t => { + // Delete environment variables that could have been set on the machine running the tests + delete process.env.GIT_CREDENTIALS; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GL_TOKEN; + delete process.env.GITLAB_TOKEN; + // Stub the logger functions + t.context.log = stub(); + t.context.error = stub(); + t.context.logger = {log: t.context.log, error: t.context.error}; +}); + +test.afterEach.always(() => { + // Restore process.env + process.env = envBackup; + // Restore the current working directory + process.chdir(cwd); +}); + +test.serial('Return "false" if does not run on a git repository', async t => { + const dir = tempy.directory(); + process.chdir(dir); + + t.false(await verify({}, 'master', t.context.logger)); +}); + +test.serial('Throw a AggregateError', async t => { + await gitRepo(); + + const errors = Array.from(await t.throws(verify({}, 'master', t.context.logger))); + + t.is(errors[0].name, 'SemanticReleaseError'); + t.is(errors[0].message, 'The repositoryUrl option is required'); + t.is(errors[0].code, 'ENOREPOURL'); + t.is(errors[1].name, 'SemanticReleaseError'); + t.is(errors[1].message, 'The tagFormat template must compile to a valid Git tag format'); + t.is(errors[1].code, 'EINVALIDTAGFORMAT'); + t.is(errors[2].name, 'SemanticReleaseError'); + t.is(errors[2].message, `The tagFormat template must contain the variable "\${version}" exactly once`); + t.is(errors[2].code, 'ETAGNOVERSION'); +}); + +test.serial('Throw a SemanticReleaseError if the "tagFormat" is not valid', async t => { + const repositoryUrl = await gitRepo(true); + const options = {repositoryUrl, tagFormat: `?\${version}`}; + + const errors = Array.from(await t.throws(verify(options, 'master', t.context.logger))); + + t.is(errors[0].name, 'SemanticReleaseError'); + t.is(errors[0].message, 'The tagFormat template must compile to a valid Git tag format'); + t.is(errors[0].code, 'EINVALIDTAGFORMAT'); +}); + +test.serial('Throw a SemanticReleaseError if the "tagFormat" does not contains the "version" variable', async t => { + const repositoryUrl = await gitRepo(true); + const options = {repositoryUrl, tagFormat: 'test'}; + + const errors = Array.from(await t.throws(verify(options, 'master', t.context.logger))); + + t.is(errors[0].name, 'SemanticReleaseError'); + t.is(errors[0].message, `The tagFormat template must contain the variable "\${version}" exactly once`); + t.is(errors[0].code, 'ETAGNOVERSION'); +}); + +test.serial('Throw a SemanticReleaseError if the "tagFormat" contains multiple "version" variables', async t => { + const repositoryUrl = await gitRepo(true); + const options = {repositoryUrl, tagFormat: `\${version}v\${version}`}; + + const errors = Array.from(await t.throws(verify(options, 'master', t.context.logger))); + + t.is(errors[0].name, 'SemanticReleaseError'); + t.is(errors[0].message, `The tagFormat template must contain the variable "\${version}" exactly once`); + t.is(errors[0].code, 'ETAGNOVERSION'); +}); + +test.serial('Return "false" if the current branch is not the once configured', async t => { + const repositoryUrl = await gitRepo(true); + const options = {repositoryUrl, tagFormat: `v\${version}`, branch: 'master'}; + + t.false(await verify(options, 'other', t.context.logger)); +}); + +test.serial('Return "true" if all verification pass', async t => { + const repositoryUrl = await gitRepo(true); + const options = {repositoryUrl, tagFormat: `v\${version}`, branch: 'master'}; + + t.true(await verify(options, 'master', t.context.logger)); +});