From 0dbd8a6b842a62d2a949a9e4564630176d161773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81rgio=20Ramos?= Date: Mon, 29 May 2017 23:03:25 +0100 Subject: [PATCH] chore: improved publish script - goes through the stages step by step - prompts the user to confirm some choices - smarter handling of tags and versions --- package.json | 12 +- scripts/publish | 567 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 451 insertions(+), 128 deletions(-) diff --git a/package.json b/package.json index 335884dd..089ae134 100644 --- a/package.json +++ b/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" diff --git a/scripts/publish b/scripts/publish index 6fc7f2ee..62f6e720 100755 --- a/scripts/publish +++ b/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);