copilot/scripts/release

480 lines
13 KiB
JavaScript
Executable File

#!/usr/bin/env node
const { EOL } = require('os');
const inquirer = require('inquirer');
const isString = require('lodash.isstring');
const isPlainObject = require('lodash.isplainobject');
const uniqBy = require('lodash.uniqby');
const sortBy = require('apr-sort-by');
const map = require('apr-map');
const pkg = require('../package.json');
const { writeFile } = require('mz/fs');
const readPkg = require('read-pkg');
const reduce = require('apr-reduce');
const intercept = require('apr-intercept');
const execa = require('execa');
const argv = require('yargs').argv;
const semver = require('semver');
const globby = require('globby');
const figures = require('figures');
const chalk = require('chalk');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const exec = (...args) =>
execa(...args, {
stdio: 'inherit'
});
const releaseTypes = {
dev: 'dev',
staging: 'staging',
production: 'production'
};
const incs = {
production: 'major',
staging: 'minor',
dev: 'patch'
};
const tasks = [
{
title: 'Git Check',
description: 'Checks whether the current git state is ideal for a publish',
task: [
{
title: 'Branch',
description: 'Checks if the current branch is `master`. To ignore use the `--any-branch` flag',
filter: () => !argv['any-branch'],
task: async () => {
const branch = await execa.stdout('git', [
'symbolic-ref',
'--short',
'HEAD'
]);
if (branch !== 'master') {
throw new Error(
'Not on `master` branch. Use --any-branch to publish anyway'
);
}
}
},
{
title: 'Working tree',
description: 'Checks if working tree is clean. To ignore use the `--force` flag',
filter: () => !argv.force,
task: async () => {
const status = await execa.stdout('git', ['status', '--porcelain']);
if (status !== '') {
throw new Error(
'Unclean working tree. Commit or stash changes first. Use --force to publish anyway'
);
}
}
},
{
title: 'Remote history',
description: 'Checks if remote history differs',
task: async () => {
const history = await execa.stdout('git', [
'rev-list',
'--count',
'--left-only',
'@{u}...HEAD'
]);
if (history !== '0') {
throw new Error('Remote history differs. Please pull changes');
}
}
}
]
},
{
title: 'Lerna',
task: [
{
title: 'Updated',
description: 'Shows a list of updated packages',
task: () =>
exec('lerna', ['updated', '--conventional-commits', '--independent'])
},
{
title: 'Publish',
description: 'Publish updated packages, based on the changes from last tag',
task: async ({ prefix }) => {
const { publish } = await inquirer.prompt([
{
name: 'publish',
type: 'confirm',
message: `${prefix}Want to publish packages?`
}
]);
if (!publish) {
return {
published: false
};
}
const msg = 'chore: publish packages';
const prevCommit = await execa.stdout('git', [
'log',
'-1',
"--format='%h'"
]);
const [err] = await intercept(
exec('lerna', [
'publish',
'--conventional-commits',
'--independent',
'-m',
`"${msg}"`
])
);
// check if user denied publish
if (/^Command\sfailed:/.test(err.message)) {
return {
published: false
};
}
// check if last commit is the publish
const lastCommit = await execa.stdout('git', [
'log',
'-1',
"--format=\"title:'%s' hash:'%h'\""
]);
const [_, title, hash] = lastCommit.match(
/title:'(.*?) hash:'(.*?)'/
);
return {
published: title === msg && hash !== prevCommit
};
}
}
]
},
{
title: 'Release',
description: 'Cut a release for Continuous Delivery',
task: [
{
filter: ({ published }) => !published,
task: async ({ prefix }) =>
inquirer.prompt([
{
name: 'release',
type: 'confirm',
default: false,
message: `${prefix}No lerna publish detected. Are you sure you want to release? \n ${prefix}${chalk.dim(`(${chalk.yellow(figures.warning)} this can have negative effects on future lerna publishes since it detects changes based on tags)`)}`
}
])
},
{
filter: ({ published }) => published,
task: async ({ prefix }) =>
inquirer.prompt([
{
name: 'release',
type: 'confirm',
default: false,
message: `${prefix}Want to cut a release?`
}
])
},
{
title: 'Type',
filter: ({ release }) => release,
task: async ({ prefix }) =>
inquirer.prompt([
{
name: 'releaseType',
type: 'list',
message: `${prefix}What type of release?`,
choices: Object.keys(releaseTypes),
default: 'dev'
}
])
},
{
title: 'Version bump',
description: 'Bum version based on release type',
filter: ({ release }) => release,
task: async ({ releaseType }) => {
const version = Object.keys(incs)
.filter(k => releaseType === k)
.reduce((v, k) => semver.inc(v, incs[k]), pkg.version);
return {
version: String(version)
};
}
},
{
title: 'Tag',
description: 'Create new tag for release',
filter: ({ release }) => release,
task: async ({ version, releaseType, prefix }) => {
// from a tag hash, get the timestamp
const tagTimestamp = async ({ hash }) => {
if (!hash) {
return 0;
}
const show = await execa.stdout('git', [
'show',
'-s',
'--format=%ct',
hash
]);
return -Number(show.split(/\n/).pop());
};
// from a string of tags, get the name, hash, version and pkg
const parseTags = str =>
str.split(/\n/).map(line => {
const meta = line.match(/(.*?)\s*?refs\/tags\/(.*?)@(.*)/);
const hashname = line.match(/(.*?)\s*?refs\/tags\/(.*)/);
const [_, __, pkg, version] = meta || [];
const [___, hash, name] = hashname || [];
return {
hash,
name: (name || '').replace('^{}', ''),
pkg,
version: (version || '').replace('^{}', '')
};
});
// get a tag parent tag
// this is needed to build a summary of changes
const getLastTag = async () => {
// get all remote tags
const remoteTags = await execa.stdout('git', [
'ls-remote',
'--tags'
]);
// get all local tags
const localTags = await execa.stdout('git', [
'show-ref',
'--tags',
'-d'
]);
// gather all tags, remote and local
const allTags = parseTags(remoteTags)
.concat(parseTags(localTags))
.filter(Boolean);
// from all tags, filter duplicates
const uniqueTags = uniqBy(allTags, ({ name }) => name);
// sort tags by timestamp, most recent to oldest
const tags = await sortBy(uniqueTags, tagTimestamp);
// check whether any of the tags matches the name
const type = releaseTypes[releaseType];
const projTags = tags.filter(
({ name }) => name.indexOf(`${pkg.name}-${type}`) >= 0
);
// if tags found, get the most recent
if (projTags.length) {
return projTags.shift().name;
}
// get all package folders
const pkgFolders = (await globby(['packages/*'], {
cwd: path.join(__dirname, '..')
})).map(folder => path.resolve(ROOT, folder));
// get package names
const pkgs = await map(
pkgFolders,
async folder => (await readPkg(folder)).name
);
// filter tags that are scoped to packages
const nonLernaTags = tags.filter(
tag => !pkgs.some(pkg => tag.pkg === pkg)
);
// if no remaining tag, get first repo commit
if (!nonLernaTags.length) {
return execa.stdout('git', [
'rev-list',
'--max-parents=0',
'HEAD'
]);
}
// from the remaining tags, pick one
const { tag } = await inquirer.prompt([
{
name: 'tag',
type: 'list',
message: `${prefix}What tag to base your release on?`,
choices: nonLernaTags.map(({ name }) => name),
pageSize: nonLernaTags.length
}
]);
return tag;
};
const lastTag = await getLastTag();
const lastCommits = await execa.stdout('git', [
'log',
`${lastTag}..HEAD`,
'--no-merges',
'--format="%h %s (%aN)"'
]);
const tagName = `${pkg.name}-${releaseType}@${version}`;
const tagBody = `${EOL}${lastCommits}`;
console.log(
`${prefix}${chalk.yellow('Tag Name: ')}\n${prefix}${prefix}${chalk.dim(tagName)}`
);
console.log(`${prefix}${chalk.yellow('Tag Description: ')}`);
console.log(
`${chalk.dim(lastCommits
.split(/\n/)
.map(line => `${prefix}${prefix}${line}`)
.join('\n'))}`
);
const { createTag } = await inquirer.prompt([
{
name: 'createTag',
type: 'confirm',
message: `${prefix}Should above tag be created?`
}
]);
if (!createTag) {
return {
createTag
};
}
await exec('git', ['tag', tagName, '-m', tagBody]);
return {
tagName,
tagBody,
lastCommits,
createTag
};
}
},
{
title: 'Push',
description: 'Push just created tag to origin',
filter: ({ createTag }) => createTag,
task: async ({ tagName, prefix }) => {
const { pushTag } = await inquirer.prompt([
{
name: 'pushTag',
type: 'confirm',
message: `${prefix}Should ${chalk.yellow(tagName)} be pushed to origin?`
}
]);
if (!pushTag) {
return;
}
return exec('git', ['push', 'origin', tagName]);
}
},
{
title: 'Version write',
filter: ({ createTag }) => createTag,
description: 'Write new version to `package.json`',
task: ({ version }) => {
pkg.version = version;
return writeFile(
path.join(__dirname, '../package.json'),
JSON.stringify(pkg, null, 2),
'utf-8'
);
}
}
]
}
];
const run = (tasks = [], ctx = {}, prefix = '') =>
reduce(
tasks,
async (ctx = {}, { title, description, filter, task }) => {
if (!task) {
return;
}
const context = Object.assign({}, ctx, {
prefix
});
const shouldRun = filter ? await filter(context) : true;
if (!shouldRun) {
return;
}
if (isString(title)) {
console.log(`${prefix}${chalk.green(figures.arrowRight)} ${title}`);
}
if (isString(description)) {
console.log(
`${chalk.dim(`${prefix}${figures.arrowRight} ${description}`)}`
);
}
if (Array.isArray(task)) {
return run(task, context, `${prefix} `);
}
const [err, nCtx] = await intercept(task(context));
if (err) {
console.log(`${chalk.red(figures.cross)} ${chalk.dim(err.message)}`);
console.log(
`${chalk.dim(err.stack.replace(`Error: ${err.message}`, '').trim())}`
);
throw err;
}
return Object.assign({}, context, isPlainObject(nCtx) ? nCtx : {});
},
ctx
);
const exit = err => {
process.exit(1);
};
process.on('SIGTERM', exit);
process.on('SIGINT', exit);
process.on('SIGHUP', exit);
run(tasks).catch(exit);