Repository: semantic-release/semantic-release
Pull request #626 (2018-01-29 04:48:36)

 feat: add `tagFormat` option to customize Git tag name
Ref #193


Patch:
diff --git a/cli.js b/cli.js
index 61b1289..6d48c84 100755
--- a/cli.js
+++ b/cli.js
@@ -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>',
diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md
index 8d2b5b1..b406228 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 5dd4994..d16bd8f 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 36ec949..9373aa6 100644
--- a/lib/get-config.js
+++ b/lib/get-config.js
@@ -1,8 +1,7 @@
-const readPkgUp = require('read-pkg-up');
 const {castArray, pickBy, isUndefined, isNull, isString, isPlainObject} = require('lodash');
+const readPkgUp = require('read-pkg-up');
 const cosmiconfig = require('cosmiconfig');
 const resolveFrom = require('resolve-from');
-const SemanticReleaseError = require('@semantic-release/error');
 const debug = require('debug')('semantic-release:config');
 const {repoUrl} = require('./git');
 const PLUGINS_DEFINITION = require('./plugins/definitions');
@@ -47,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)),
   };
@@ -61,11 +61,7 @@ module.exports = async (opts, logger) => {
   debug('verifyRelease: %O', options.verifyRelease);
   debug('publish: %O', options.publish);
 
-  if (!options.repositoryUrl) {
-    throw new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL');
-  }
-
-  options.repositoryUrl = getGitAuthUrl(options.repositoryUrl);
+  options.repositoryUrl = options.repositoryUrl ? getGitAuthUrl(options.repositoryUrl) : options.repositoryUrl;
 
   return {options, plugins: await plugins(options, pluginsPath, logger)};
 };
diff --git a/lib/get-last-release.js b/lib/get-last-release.js
index bf34d56..179b580 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<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');
diff --git a/lib/git.js b/lib/git.js
index 18c8bb1..9398ef7 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/plugins/index.js b/lib/plugins/index.js
index 9bbd16d..c5808c0 100644
--- a/lib/plugins/index.js
+++ b/lib/plugins/index.js
@@ -1,11 +1,13 @@
 const {isArray, isObject, omit} = require('lodash');
+const AggregateError = require('aggregate-error');
 const SemanticReleaseError = require('@semantic-release/error');
 const PLUGINS_DEFINITION = require('./definitions');
 const pipeline = require('./pipeline');
 const normalize = require('./normalize');
 
-module.exports = (options, pluginsPath, logger) =>
-  Object.keys(PLUGINS_DEFINITION).reduce((plugins, pluginType) => {
+module.exports = (options, pluginsPath, logger) => {
+  const errors = [];
+  const plugins = Object.keys(PLUGINS_DEFINITION).reduce((plugins, pluginType) => {
     const {config, output, default: def} = PLUGINS_DEFINITION[pluginType];
     let pluginConfs;
     if (options[pluginType]) {
@@ -14,7 +16,8 @@ module.exports = (options, pluginsPath, logger) =>
         options[pluginType].path = def;
       }
       if (config && !config.validator(options[pluginType])) {
-        throw new SemanticReleaseError(config.message, 'EPLUGINCONF');
+        errors.push(new SemanticReleaseError(config.message, 'EPLUGINCONF'));
+        return plugins;
       }
       pluginConfs = options[pluginType];
     } else {
@@ -29,3 +32,8 @@ module.exports = (options, pluginsPath, logger) =>
 
     return plugins;
   }, {});
+  if (errors.length > 0) {
+    throw new AggregateError(errors);
+  }
+  return plugins;
+};
diff --git a/lib/verify.js b/lib/verify.js
index c68c5f0..7cbc23f 100644
--- a/lib/verify.js
+++ b/lib/verify.js
@@ -1,19 +1,50 @@
+const {template} = require('lodash');
 const SemanticReleaseError = require('@semantic-release/error');
-const {isGitRepo, verifyAuth} = require('./git');
+const AggregateError = require('aggregate-error');
+const {isGitRepo, verifyAuth, verifyTagName} = require('./git');
 
 module.exports = async (options, branch, logger) => {
+  const errors = [];
+
   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 (!options.repositoryUrl) {
+    errors.push(new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL'));
+  } else if (!await verifyAuth(options.repositoryUrl, options.branch)) {
+    errors.push(
+      new SemanticReleaseError(
+        `The git credentials doesn't allow to push on the branch ${options.branch}.`,
+        'EGITNOPERMISSION'
+      )
     );
   }
 
+  // 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);
+  }
+
   if (branch !== options.branch) {
     logger.log(
       `This test run was triggered on the branch ${branch}, while semantic-release is configured to only publish from ${


Test patch:
diff --git a/test/get-config.test.js b/test/get-config.test.js
index 4c492f5..f2cf824 100644
--- a/test/get-config.test.js
+++ b/test/get-config.test.js
@@ -13,12 +13,12 @@ const envBackup = Object.assign({}, process.env);
 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;
+  // Delete environment variables that could have been set on the machine running the tests
   t.context.plugins = stub().returns({});
   t.context.getConfig = proxyquire('../lib/get-config', {'./plugins': t.context.plugins});
 });
@@ -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 78277ae..17dbd9c 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 215b555..f56a284 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 3e0e2f3..11a16fc 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);
@@ -601,11 +636,11 @@ test.serial('Throw SemanticReleaseError if repositoryUrl is not set and cannot b
     './lib/logger': t.context.logger,
     'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
   });
-  const error = await t.throws(semanticRelease());
+  const errors = Array.from(await t.throws(semanticRelease()));
 
   // Verify error code and type
-  t.is(error.code, 'ENOREPOURL');
-  t.is(error.name, 'SemanticReleaseError');
+  t.is(errors[0].code, 'ENOREPOURL');
+  t.is(errors[0].name, 'SemanticReleaseError');
 });
 
 test.serial('Throw an Error if plugin returns an unexpected value', async t => {
diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js
index 1c0a5e7..f60727c 100644
--- a/test/plugins/plugins.test.js
+++ b/test/plugins/plugins.test.js
@@ -127,26 +127,34 @@ test('Merge global options with plugin options', async t => {
   t.deepEqual(result.pluginConfig, {localOpt: 'local', globalOpt: 'global', otherOpt: 'locally-defined'});
 });
 
-test('Throw an error if plugin configuration is missing a path for plugin pipeline', t => {
-  const error = t.throws(() => getPlugins({verifyConditions: {}}, {}, t.context.logger));
+test('Throw an error if plugins configuration are missing a path for plugin pipeline', t => {
+  const errors = Array.from(
+    t.throws(() => getPlugins({verifyConditions: {}, verifyRelease: {}}, {}, t.context.logger))
+  );
 
-  t.is(error.name, 'SemanticReleaseError');
-  t.is(error.code, 'EPLUGINCONF');
+  t.is(errors[0].name, 'SemanticReleaseError');
+  t.is(errors[0].code, 'EPLUGINCONF');
   t.is(
-    error.message,
+    errors[0].message,
     '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.'
   );
+  t.is(errors[1].name, 'SemanticReleaseError');
+  t.is(errors[1].code, 'EPLUGINCONF');
+  t.is(
+    errors[1].message,
+    'The "verifyRelease" 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.'
+  );
 });
 
 test('Throw an error if an array of plugin configuration is missing a path for plugin pipeline', t => {
-  const error = t.throws(() =>
-    getPlugins({verifyConditions: [{path: '@semantic-release/npm'}, {}]}, {}, t.context.logger)
+  const errors = Array.from(
+    t.throws(() => getPlugins({verifyConditions: [{path: '@semantic-release/npm'}, {}]}, {}, t.context.logger))
   );
 
-  t.is(error.name, 'SemanticReleaseError');
-  t.is(error.code, 'EPLUGINCONF');
+  t.is(errors[0].name, 'SemanticReleaseError');
+  t.is(errors[0].code, 'EPLUGINCONF');
   t.is(
-    error.message,
+    errors[0].message,
     '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.'
   );
 });
diff --git a/test/verify.test.js b/test/verify.test.js
new file mode 100644
index 0000000..08e1122
--- /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));
+});