480 lines
13 KiB
JavaScript
Executable File
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);
|