Skip to content

Commit

Permalink
feat: use Git notes to store the channels on which a version has been…
Browse files Browse the repository at this point in the history
… released

BREAKING CHANGE: this feature change the way semantic-release keep track of the channels on which a version has been released.
It now use a JSON object stored in a [Git note](https://git-scm.com/docs/git-notes) instead of Git tags formatted as v{version}@{channel}.

The tags formatted as v{version}@{channel} will now be ignored. If you have made releases with v16.0.0 on branches other than the default one you will have to update your repository.

The changes to make consist in:
- Finding all the versions that have been released on a branch other than the default one by searching for all tags formatted v{version}@{channel}
- For each of those version:
  - Create a tag without the {@channel} if none doesn't already exists
  - Add a Git note to the tag without the {@channel} containing the channels on which the version was released formatted as `{"channels":["channel1","channel2"]}` and using `null` for the default channel (for example.`{"channels":[null,"channel1","channel2"]}`)
  - Push the tags and notes
  - Update the GitHub releases that refer to a tag formatted as v{version}@{channel} to use the tag without it
  - Delete the tags formatted as v{version}@{channel}
  • Loading branch information
pvdlg committed Dec 3, 2019
1 parent 2caafba commit b2c1b2c
Show file tree
Hide file tree
Showing 15 changed files with 386 additions and 210 deletions.
15 changes: 9 additions & 6 deletions index.js
Expand Up @@ -17,7 +17,7 @@ const {extractErrors, makeTag} = require('./lib/utils');
const getGitAuthUrl = require('./lib/get-git-auth-url');
const getBranches = require('./lib/branches');
const getLogger = require('./lib/get-logger');
const {verifyAuth, isBranchUpToDate, getGitHead, tag, push, getTagHead} = require('./lib/git');
const {verifyAuth, isBranchUpToDate, getGitHead, tag, push, pushNotes, getTagHead, addNote} = require('./lib/git');
const getError = require('./lib/get-error');
const {COMMIT_NAME, COMMIT_EMAIL} = require('./lib/definitions/constants');

Expand Down Expand Up @@ -109,9 +109,10 @@ async function run(context, plugins) {
if (options.dryRun) {
logger.warn(`Skip ${nextRelease.gitTag} tag creation in dry-run mode`);
} else {
await tag(nextRelease.gitTag, nextRelease.gitHead, {cwd, env});
await addNote({channels: [...currentRelease.channels, nextRelease.channel]}, nextRelease.gitHead, {cwd, env});
await push(options.repositoryUrl, {cwd, env});
logger.success(`Created tag ${nextRelease.gitTag}`);
await pushNotes(options.repositoryUrl, {cwd, env});
logger.success(`Add channel ${nextRelease.channel} to tag ${nextRelease.gitTag}`);
}

context.branch.tags.push({
Expand Down Expand Up @@ -148,7 +149,7 @@ async function run(context, plugins) {

const nextRelease = {
type: await plugins.analyzeCommits(context),
channel: context.branch.channel,
channel: context.branch.channel || null,
gitHead: await getGitHead({cwd, env}),
};
if (!nextRelease.type) {
Expand All @@ -158,8 +159,8 @@ async function run(context, plugins) {

context.nextRelease = nextRelease;
nextRelease.version = getNextVersion(context);
nextRelease.gitTag = makeTag(options.tagFormat, nextRelease.version, nextRelease.channel);
nextRelease.name = makeTag(options.tagFormat, nextRelease.version);
nextRelease.gitTag = makeTag(options.tagFormat, nextRelease.version);
nextRelease.name = nextRelease.gitTag;

if (context.branch.type !== 'prerelease' && !semver.satisfies(nextRelease.version, context.branch.range)) {
throw getError('EINVALIDNEXTVERSION', {
Expand All @@ -181,7 +182,9 @@ async function run(context, plugins) {
} else {
// Create the tag before calling the publish plugins as some require the tag to exists
await tag(nextRelease.gitTag, nextRelease.gitHead, {cwd, env});
await addNote({channels: [nextRelease.channel]}, nextRelease.gitHead, {cwd, env});
await push(options.repositoryUrl, {cwd, env});
await pushNotes(options.repositoryUrl, {cwd, env});
logger.success(`Created tag ${nextRelease.gitTag}`);
}

Expand Down
31 changes: 13 additions & 18 deletions lib/branches/get-tags.js
@@ -1,34 +1,29 @@
const {template, escapeRegExp, flatMap} = require('lodash');
const {template, escapeRegExp} = require('lodash');
const semver = require('semver');
const pReduce = require('p-reduce');
const debug = require('debug')('semantic-release:get-tags');
const {getTags} = require('../../lib/git');
const {getTags, getNote} = 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 tagRegexp = `^${escapeRegExp(template(tagFormat)({version: ' '})).replace(' ', '(.+)')}`;

return pReduce(
branches,
async (branches, branch) => {
const versions = (await getTags(branch.name, {cwd, env})).reduce((versions, tag) => {
const [, version, channel] = tag.match(tagRegexp) || [];
if (version && semver.valid(semver.clean(version))) {
return {
...versions,
[version]: versions[version]
? {...versions[version], channels: [...versions[version].channels, channel]}
: {gitTag: tag, version, channels: [channel]},
};
}

return versions;
}, {});

const branchTags = flatMap(versions);
const branchTags = await pReduce(
await getTags(branch.name, {cwd, env}),
async (branchTags, tag) => {
const [, version] = tag.match(tagRegexp) || [];
return version && semver.valid(semver.clean(version))
? [...branchTags, {gitTag: tag, version, channels: (await getNote(tag, {cwd, env})).channels || [null]}]
: branchTags;
},
[]
);

debug('found tags for branch %s: %o', branch.name, branchTags);
return [...branches, {...branch, tags: branchTags}];
Expand Down
4 changes: 3 additions & 1 deletion lib/branches/index.js
Expand Up @@ -3,7 +3,7 @@ const AggregateError = require('aggregate-error');
const pEachSeries = require('p-each-series');
const DEFINITIONS = require('../definitions/branches');
const getError = require('../get-error');
const {fetch, verifyBranchName} = require('../git');
const {fetch, fetchNotes, verifyBranchName} = require('../git');
const expand = require('./expand');
const getTags = require('./get-tags');
const normalize = require('./normalize');
Expand All @@ -21,6 +21,8 @@ module.exports = async (repositoryUrl, context) => {
await fetch(repositoryUrl, name, {cwd, env});
});

await fetchNotes(repositoryUrl, {cwd, env});

const branches = await getTags(context, remoteBranches);

const errors = [];
Expand Down
3 changes: 3 additions & 0 deletions lib/definitions/constants.js
Expand Up @@ -14,6 +14,8 @@ const SECRET_REPLACEMENT = '[secure]';

const SECRET_MIN_SIZE = 5;

const GIT_NOTE_REF = 'semantic-release';

module.exports = {
RELEASE_TYPE,
FIRST_RELEASE,
Expand All @@ -23,4 +25,5 @@ module.exports = {
RELEASE_NOTES_SEPARATOR,
SECRET_REPLACEMENT,
SECRET_MIN_SIZE,
GIT_NOTE_REF,
};
8 changes: 4 additions & 4 deletions lib/get-release-to-add.js
Expand Up @@ -23,12 +23,12 @@ module.exports = context => {
.slice(branches.findIndex(({name}) => name === branch.name) + 1)
// Exclude prerelease branches
.filter(({type}) => type !== 'prerelease')
.map(({channel}) => channel);
.map(({channel}) => channel || null);

const versiontoAdd = uniqBy(
branch.tags.filter(
({channels, version}) =>
!channels.includes(branch.channel) &&
!channels.includes(branch.channel || null) &&
intersection(channels, higherChannels).length > 0 &&
(branch.type !== 'maintenance' || semver.gte(version, getLowerBound(branch.mergeRange)))
),
Expand All @@ -50,8 +50,8 @@ module.exports = context => {
nextRelease: {
type,
version,
channel: branch.channel,
gitTag: makeTag(tagFormat, version, branch.channel),
channel: branch.channel || null,
gitTag: makeTag(tagFormat, version),
name,
gitHead: gitTag,
},
Expand Down
70 changes: 70 additions & 0 deletions lib/git.js
Expand Up @@ -2,6 +2,7 @@ const gitLogParser = require('git-log-parser');
const getStream = require('get-stream');
const execa = require('execa');
const debug = require('debug')('semantic-release:git');
const {GIT_NOTE_REF} = require('./definitions/constants');

Object.assign(gitLogParser.fields, {hash: 'H', message: 'B', gitTags: 'd', committerDate: {key: 'ci', type: Date}});

Expand Down Expand Up @@ -146,6 +147,27 @@ async function fetch(repositoryUrl, branch, execaOpts) {
}
}

/**
* Unshallow the git repository if necessary and fetch all the notes.
*
* @param {String} repositoryUrl The remote repository URL.
* @param {Object} [execaOpts] Options to pass to `execa`.
*/
async function fetchNotes(repositoryUrl, execaOpts) {
try {
await execa(
'git',
['fetch', '--unshallow', repositoryUrl, `+refs/notes/${GIT_NOTE_REF}:refs/notes/${GIT_NOTE_REF}`],
execaOpts
);
} catch (_) {
await execa('git', ['fetch', repositoryUrl, `+refs/notes/${GIT_NOTE_REF}:refs/notes/${GIT_NOTE_REF}`], {
...execaOpts,
reject: false,
});
}
}

/**
* Get the HEAD sha.
*
Expand Down Expand Up @@ -230,6 +252,18 @@ async function push(repositoryUrl, execaOpts) {
await execa('git', ['push', '--tags', repositoryUrl], execaOpts);
}

/**
* Push notes to the remote repository.
*
* @param {String} repositoryUrl The remote repository URL.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @throws {Error} if the push failed.
*/
async function pushNotes(repositoryUrl, execaOpts) {
await execa('git', ['push', repositoryUrl, `refs/notes/${GIT_NOTE_REF}`], execaOpts);
}

/**
* Verify a tag name is a valid Git reference.
*
Expand Down Expand Up @@ -280,6 +314,38 @@ async function isBranchUpToDate(repositoryUrl, branch, execaOpts) {
}
}

/**
* Get and parse the JSON note of a given reference.
*
* @param {String} ref The Git reference for which to retrieve the note.
* @param {Object} [execaOpts] Options to pass to `execa`.
*
* @return {Object} the parsed JSON note if there is one, an empty object otherwise.
*/
async function getNote(ref, execaOpts) {
try {
return JSON.parse((await execa('git', ['notes', '--ref', GIT_NOTE_REF, 'show', ref], execaOpts)).stdout);
} catch (error) {
if (error.exitCode === 1) {
return {};
}

debug(error);
throw error;
}
}

/**
* Get and parse the JSON note of a given reference.
*
* @param {Object} note The object to save in the reference note.
* @param {String} ref The Git reference to add the note to.
* @param {Object} [execaOpts] Options to pass to `execa`.
*/
async function addNote(note, ref, execaOpts) {
await execa('git', ['notes', '--ref', GIT_NOTE_REF, 'add', '-f', '-m', JSON.stringify(note), ref], execaOpts);
}

module.exports = {
getTagHead,
getTags,
Expand All @@ -288,13 +354,17 @@ module.exports = {
isRefInHistory,
isRefExists,
fetch,
fetchNotes,
getGitHead,
repoUrl,
isGitRepo,
verifyAuth,
tag,
push,
pushNotes,
verifyTagName,
isBranchUpToDate,
verifyBranchName,
getNote,
addNote,
};
4 changes: 2 additions & 2 deletions lib/utils.js
Expand Up @@ -67,8 +67,8 @@ function getRange(min, max) {
return `>=${min}${max ? ` <${max}` : ''}`;
}

function makeTag(tagFormat, version, channel) {
return template(tagFormat)({version: `${version}${channel ? `@${channel}` : ''}`});
function makeTag(tagFormat, version) {
return template(tagFormat)({version});
}

function isSameChannel(channel, otherChannel) {
Expand Down

0 comments on commit b2c1b2c

Please sign in to comment.