Skip to content

Instantly share code, notes, and snippets.

@claudioc
Created January 28, 2021 09:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save claudioc/77eb0c06c4c415bb1273d5499aaeccb1 to your computer and use it in GitHub Desktop.
Save claudioc/77eb0c06c4c415bb1273d5499aaeccb1 to your computer and use it in GitHub Desktop.
#!/usr/bin/env node
// This is a script "recipe" for releasing
// It has been inspired by yeoman generator-release
// The reason for this to exist is that I didn't want to include an external dependency (yeoman) for a 'task',
// when all the tasks of the project are managed by gulp
//
// What this recipe does:
// - Runs sanity check to see if we can release
// - are we in the `releaseEvents` branch?
// - is the working directory git-clean?
// - are we behind some commits, after fetching?
// - Calculates the next version, either from the command line (using the --magnolia:version or --magnolia:incr switches)
// or automatically getting the next `patch` number
// - Verifies that tne new version makes sense (semver.valid())
// - Finds the range of commits for the this release (using two methods) and parses each of them
// to understand if they belong to a PR or not
// - Shows a preview of the changes (list of PR and commits) and asks for confirmation
// - Creates or updates `release-notes.md` with the list of changes
// - Updates package.json with the new version
// - git add and commits the changes (release-notes.md and packages.json)
// - git push to origin
// - git tag (annotated) with the version and pushes
//
// The difference with generator-release is that this recipe doesn't need to interact
// with the github API, which means that it's easier to use but also that (for the moment)
// we are not automatically creating a release (but Github will have it anyway) and the
// release-notes are less descriptive
const semver = require('semver');
const minimist = require('minimist');
const inq = require('inquirer');
const dateFormat = require('dateformat');
const exec = require('child_process').exec;
const _ = require('lodash');
const fs = require('fs');
const path = require('path');
let tagName;
let remoteRepo = '';
let newVersion;
let currentVersion;
const sprintf = require('util').format;
const pulls = [];
const commits = [];
const packageJson = require('../package.json');
testGitBranch()
.then(testGitWorkingDirectory)
.then(gitFetch)
.then(testGitCommits)
.then(increaseVersionNumber)
.then(gatherRepoInfo)
.then(gatherCommits)
.then(previewChanges)
.then(updateReleaseNotes)
.then(commitRelease)
.then(tagRelease)
// .then(pushDockerImage)
.catch(e => {
console.log(e);
});
function gitExec(command) {
return new Promise((resolve, reject) => {
exec(`git ${command}`, (err, data) => {
if (err) {
reject(`Error running git ${command}`);
return;
}
resolve(data);
});
});
}
function testGitBranch() {
return gitExec('rev-parse --abbrev-ref HEAD').then(branch => {
if (branch.trim() !== 'release' && branch.trim() !== 'release-script') {
console.warn('You have to run `release` from the `release` branch.');
process.exit();
}
});
}
function testGitWorkingDirectory() {
return gitExec('diff-index --name-only HEAD --').then(data => {
if (data.trim() !== '') {
// throw 'Git working directory not clean.';
}
});
}
function gitFetch() {
return gitExec('fetch');
}
function testGitCommits() {
return gitExec('branch -v --no-color | grep -e "^\\*"', data => {
if (/\[behind (.*)\]/.test(data)) {
throw `Your repo is behind by ${RegExp.$1} commits.`;
}
});
}
function increaseVersionNumber() {
const options = minimist(process.argv.slice(2));
// Get the current version
let incr;
currentVersion = packageJson.version;
// Calculate the new version
// Command line param can specify:
// --version <new version>
// --incr <major, minor, patch>
// no params: increases patch version
if (options['version']) {
newVersion = options['version'];
if (!semver.valid(newVersion)) {
throw 'Requested version is not syntactically correct. It should be in the form <major>.<minor>.<patch>.';
}
}
if (options['incr']) {
incr = options['incr'];
if (incr === true || ['major', 'minor', 'patch'].indexOf(incr) === -1) {
incr = 'patch';
}
newVersion = semver.inc(currentVersion, incr);
}
if (!newVersion) {
newVersion = semver.inc(currentVersion, 'patch');
}
if (semver.lt(newVersion, currentVersion)) {
throw 'The new version must be greater than the old one.';
}
tagName = `v${newVersion}`;
}
function gatherRepoInfo() {
return gitExec('remote show origin').then(data => {
/Fetch URL: .*:(.*)\.git/.test(data);
remoteRepo = RegExp.$1;
});
}
function gatherCommits() {
return gitExec('log --no-color --oneline HEAD...origin/release').then(data => {
_.compact(
_.map(data.trim().split('\n'), line => {
const info = /(\S+) (.*)/.exec(line);
let url;
let pr;
if (!info) {
return;
}
if (/^Merge branch/.test(info[2])) {
return;
}
if (/Merge pull request #(\d+) from/.test(info[2])) {
pr = RegExp.$1;
url = sprintf('https://github.com/%s/pull/%s', remoteRepo, pr);
}
return {
sha: info[1],
title: info[2],
pr,
url
};
})
).forEach(commit => {
(commit.pr ? pulls : commits).push(commit);
});
});
}
function previewChanges() {
const executor = (resolve, reject) => {
console.log('\nReleasing %s', newVersion);
if (pulls.length > 0) {
console.log('\nList of included Pull Requests:');
console.log('———————————————————————————————');
pulls.forEach(pull => {
console.log('✓ %s [ %s ]', pull.title, pull.url);
});
}
console.log('\nList of included Commits:');
console.log('—————————————————————————');
commits.forEach(commit => {
console.log('✓ %s', commit.title);
});
console.log('');
// Ask the user if it's OK to continue
inq
.prompt([
{
type: 'confirm',
name: 'confirm',
message: `About to release new version ${newVersion} upgrading from ${currentVersion}. Is it OK?`,
default: false
}
])
.then(response => {
if (!response.confirm) {
console.info('Aborted per user request. Bye.');
process.exit();
}
resolve();
});
};
return new Promise(executor);
}
function updateReleaseNotes() {
let notes = fs.readFileSync('release-notes.md').toString();
const notesContent = [];
notesContent.push(
sprintf('## Release %s — %s\n', newVersion, dateFormat(new Date(), 'mmmm dS, yyyy'))
);
notesContent.push('#### Pull Requests');
pulls.forEach(pull => {
notesContent.push(sprintf('- [%s](%s) – %s', pull.pr, pull.url, pull.title));
});
notesContent.push('\n#### Commits');
commits.forEach(commit => {
notesContent.push(sprintf('- %s', commit.title));
});
notesContent.push(
sprintf(
'\n[Compare to previous release](https://github.com/%s/compare/v%s...%s)\n',
remoteRepo,
currentVersion,
tagName
)
);
notes = notes.replace(/# Latest updates/, '# Latest updates\n\n' + notesContent.join('\n'));
fs.writeFileSync('release-notes.md', notes);
packageJson.version = newVersion;
fs.writeFileSync(
path.resolve(process.cwd(), './package.json'),
JSON.stringify(packageJson, undefined, 2) + '\n'
);
return gitExec('add release-notes.md package.json');
}
function commitRelease() {
return gitExec(`commit -m "Release ${newVersion}"`).then(
gitExec.bind(null, 'push origin release')
);
}
function pushDockerImage() {
return new Promise((resolve, reject) => {
exec('./docker.sh release', (err, data) => {
if (err) {
console.log(err);
reject(`Error running ./docker.sh release`);
return;
}
resolve(data);
});
});
}
function tagRelease() {
return gitExec(`tag -a ${tagName} -m "Release ${newVersion}"`).then(
gitExec.bind(null, `push origin ${tagName}`)
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment