#!/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);