chore: improved publish script

- goes through the stages step by step
 - prompts the user to confirm some choices
 - smarter handling of tags and versions
This commit is contained in:
Sérgio Ramos 2017-05-29 23:03:25 +01:00
parent b96e4321d4
commit 0dbd8a6b84
2 changed files with 451 additions and 128 deletions

View File

@ -35,9 +35,13 @@
"apr-awaitify": "^1.0.4",
"apr-filter": "^1.0.5",
"apr-for-each": "^1.0.6",
"apr-intercept": "^1.0.4",
"apr-main": "^1.0.7",
"apr-map": "^1.0.5",
"apr-reduce": "^1.0.5",
"apr-sort-by": "^1.0.5",
"babel-eslint": "^7.2.3",
"chalk": "^1.1.3",
"conventional-changelog-angular": "^1.3.3",
"conventional-changelog-cli": "^1.3.1",
"conventional-changelog-lint": "^1.1.9",
@ -54,17 +58,21 @@
"eslint-plugin-react": "^7.0.1",
"eslint-tap": "^2.0.1",
"execa": "^0.6.3",
"figures": "^2.0.0",
"force-array": "^3.1.0",
"husky": "^0.13.3",
"inquirer": "^3.0.6",
"lerna": "^2.0.0-rc.5",
"lerna-wizard": "ramitos/lerna-wizard#7bcdc11",
"license-to-fail": "^2.2.0",
"listr": "^0.12.0",
"lodash.uniq": "^4.5.0",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.uniqby": "^4.7.0",
"npm-check-updates": "^2.11.2",
"npm-run-all": "^4.0.2",
"prettier": "1.3.1",
"quality-docs": "^3.3.0",
"read-pkg": "^2.0.0",
"semver": "^5.3.0",
"staged-git-files": "0.0.4",
"yargs": "^8.0.1"

View File

@ -1,159 +1,474 @@
#!/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 Listr = require('listr');
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 = {
major: argv.production,
minor: argv.staging,
patch: argv.dev
production: 'major',
staging: 'minor',
dev: 'patch'
};
const type = ['production', 'staging', 'dev']
.filter(k => argv[k])
.reduce((t, name) => name, '');
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'
]);
const exec = (...args) => {
const cp = execa(...args);
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']);
cp.stdout.pipe(process.stdout);
cp.stderr.pipe(process.stderr);
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'
]);
return cp;
};
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?`
}
]);
const errors = [
'Not on `master` branch. Use --any-branch to publish anyway.',
'Unclean working tree. Commit or stash changes first. Use --force to publish anyway.',
'Remote history differs. Please pull changes.',
'Use --staging/--dev/--production'
];
if (!publish) {
return {
published: false
};
}
if (!argv.staging && !argv.dev && !argv.production) {
throw new Error(errors[3]);
}
const msg = 'chore: publish packages';
const prevCommit = await execa.stdout('git', [
'log',
'-1',
"--format='%h'"
]);
// based on https://github.com/sindresorhus/np/blob/df8bb7153ecb05cd4674846f488d012f3cd252e1/lib/git.js
const tasks = new Listr(
[
{
title: 'Check',
task: () =>
new Listr([
{
title: 'Check current branch',
task: async () => {
const branch = await execa.stdout('git', [
'symbolic-ref',
'--short',
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 ({ published, 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)`)}`
}
])
},
{
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'
]);
if (branch !== 'master' && !argv['any-branch']) {
throw new Error(errors[0]);
}
}
},
{
title: 'Check local working tree',
task: async () => {
const status = await execa.stdout('git', [
'status',
'--porcelain'
]);
if (status !== '' && !argv.force) {
throw new Error(errors[1]);
// 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
}
}
},
{
title: 'Check remote history',
task: async () => {
const history = await execa.stdout('git', [
'rev-list',
'--count',
'--left-only',
'@{u}...HEAD'
]);
]);
if (history !== '0') {
throw new Error(errors[3]);
}
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}`;
// eslint-disable-next-line no-console
console.log(
`${prefix}${chalk.yellow('Tag Name: ')}\n${prefix}${prefix}${chalk.dim(tagName)}`
);
// eslint-disable-next-line no-console
console.log(`${prefix}${chalk.yellow('Tag Description: ')}`);
// eslint-disable-next-line no-console
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
};
}
])
},
{
title: 'Publish',
task: () =>
exec('lerna', [
'publish',
'--conventional-commits',
'--independent',
'-m',
'chore: publish'
])
},
{
title: 'Version',
task: async () => {
pkg.version = Object.keys(incs)
.filter(k => incs[k])
.reduce((v, release) => semver.inc(v, release), pkg.version);
await writeFile(
path.join(__dirname, '../package.json'),
JSON.stringify(pkg, null, 2),
'utf-8'
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)) {
// eslint-disable-next-line no-console
console.log(`${prefix}${chalk.green(figures.arrowRight)} ${title}`);
}
if (isString(description)) {
// eslint-disable-next-line no-console
console.log(
`${chalk.dim(`${prefix}${figures.arrowRight} ${description}`)}`
);
}
},
{
title: 'Tag',
task: async () => {
const lastTag = await execa.stdout('git', [
'describe',
'--tags',
'--abbrev=0'
]);
const lastCommits = await execa.stdout('git', [
'log',
`${lastTag}..HEAD`,
'--no-merges',
'--format="%h %s (%aN)"'
]);
const msg = lastCommits
.split(/\n/)
.map(commit => commit.replace(/^"/, '').replace(/"$/, ''))
.join('\n');
return exec('git', [
'tag',
'-a',
`${pkg.name}-${type}@${pkg.version}`,
'-m',
`${EOL}${pkg.name}@${pkg.version}${EOL}${msg}`
]);
if (Array.isArray(task)) {
return run(task, context, `${prefix} `);
}
},
{
title: 'Push',
task: () =>
exec('git', ['push', 'origin', `${pkg.name}-${type}@${pkg.version}`])
}
],
{
renderer: 'verbose'
}
);
tasks.run().catch(() => process.exit(1));
const [err, nCtx] = await intercept(task(context));
if (err) {
// eslint-disable-next-line no-console
console.log(`${chalk.red(figures.cross)} ${chalk.dim(err.message)}`);
// eslint-disable-next-line no-console
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);