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:
parent
b96e4321d4
commit
0dbd8a6b84
12
package.json
12
package.json
@ -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"
|
||||
|
567
scripts/publish
567
scripts/publish
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user