Skip to content

Commit

Permalink
feat: add tagFormat option to customize Git tag name
Browse files Browse the repository at this point in the history
  • Loading branch information
pvdlg committed Jan 29, 2018
1 parent faabffb commit 39536fa
Show file tree
Hide file tree
Showing 12 changed files with 287 additions and 16 deletions.
1 change: 1 addition & 0 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = async () => {
.description('Run automated package publishing')
.option('-b, --branch <branch>', 'Branch to release from')
.option('-r, --repository-url <repositoryUrl>', 'Git repository URL')
.option('-t, --tag-format <tagFormat>', `Git tag format`)
.option('-e, --extends <paths>', 'Comma separated list of shareable config paths or packages name', list)
.option(
'--verify-conditions <paths>',
Expand Down
12 changes: 12 additions & 0 deletions docs/usage/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
5 changes: 3 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const {template} = require('lodash');
const marked = require('marked');
const TerminalRenderer = require('marked-terminal');
const envCi = require('env-ci');
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions lib/get-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
Expand Down
32 changes: 24 additions & 8 deletions lib/get-last-release.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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<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);
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');
Expand Down
12 changes: 12 additions & 0 deletions lib/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -124,4 +135,5 @@ module.exports = {
tag,
push,
deleteTag,
verifyTagName,
};
22 changes: 21 additions & 1 deletion lib/verify.js
Original file line number Diff line number Diff line change
@@ -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 = [];
Expand All @@ -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);
}
Expand Down
16 changes: 16 additions & 0 deletions test/get-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '[email protected]: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 => {
Expand All @@ -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, '[email protected]: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 => {
Expand All @@ -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 => {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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: '[email protected]:owner/module.git'};
// Create a git repository, set the current working directory at the root of the repo
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 46 additions & 4 deletions test/get-last-release.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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'});
});
Expand All @@ -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',
});
});
15 changes: 15 additions & 0 deletions test/git.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
gitTags,
isGitRepo,
deleteTag,
verifyTagName,
} from '../lib/git';
import {
gitRepo,
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 39536fa

Please sign in to comment.