#!/usr/bin/env node import * as promisify from 'es6-promisify'; import * as fs from 'fs'; import lernaGetPackages = require('lerna-get-packages'); import * as _ from 'lodash'; import * as moment from 'moment'; import opn = require('opn'); import * as path from 'path'; import { exec as execAsync, spawn } from 'promisify-child-process'; import * as prompt from 'prompt'; import semverDiff = require('semver-diff'); import semverSort = require('semver-sort'); import { constants } from './constants'; import { Changelog, Changes, PackageToVersionChange, SemVerIndex, UpdatedPackage } from './types'; import { utils } from './utils'; const DOC_GEN_COMMAND = 'docs:json'; const NPM_NAMESPACE = '@0xproject/'; const IS_DRY_RUN = process.env.IS_DRY_RUN === 'true'; const TODAYS_TIMESTAMP = moment().unix(); const LERNA_EXECUTABLE = './node_modules/lerna/bin/lerna.js'; const semverNameToIndex: { [semver: string]: number } = { patch: SemVerIndex.Patch, minor: SemVerIndex.Minor, major: SemVerIndex.Major, }; const packageNameToWebsitePath: { [name: string]: string } = { '0x.js': '0xjs', 'web3-wrapper': 'web3_wrapper', contracts: 'contracts', connect: 'connect', 'json-schemas': 'json-schemas', 'sol-compiler': 'sol-compiler', 'sol-cov': 'sol-cov', subproviders: 'subproviders', 'order-utils': 'order-utils', }; (async () => { const hasRequiredSetup = await checkPublishRequiredSetupAsync(); if (!hasRequiredSetup) { return; // abort } // Fetch public, updated Lerna packages const shouldIncludePrivate = false; const updatedPublicLernaPackages = await utils.getUpdatedLernaPackagesAsync(shouldIncludePrivate); await confirmDocPagesRenderAsync(updatedPublicLernaPackages); // Update CHANGELOGs const updatedPublicLernaPackageNames = _.map(updatedPublicLernaPackages, pkg => pkg.package.name); utils.log(`Will update CHANGELOGs and publish: \n${updatedPublicLernaPackageNames.join('\n')}\n`); const packageToVersionChange = await updateChangeLogsAsync(updatedPublicLernaPackages); // Push changelog changes to Github if (!IS_DRY_RUN) { await pushChangelogsToGithubAsync(); } // Call LernaPublish utils.log('Version updates to apply:'); _.each(packageToVersionChange, (versionChange: string, packageName: string) => { utils.log(`${packageName} -> ${versionChange}`); }); utils.log(`Calling 'lerna publish'...`); await lernaPublishAsync(packageToVersionChange); })().catch(err => { utils.log(err); process.exit(1); }); async function confirmDocPagesRenderAsync(packages: LernaPackage[]): Promise { // push docs to staging utils.log("Upload all docJson's to S3 staging..."); await execAsync(`yarn stage_docs`, { cwd: constants.monorepoRootPath }); // deploy website to staging utils.log('Deploy website to staging...'); const pathToWebsite = `${constants.monorepoRootPath}/packages/website`; await execAsync(`yarn deploy_staging`, { cwd: pathToWebsite }); const packagesWithDocs = _.filter(packages, pkg => { const scriptsIfExists = pkg.package.scripts; if (_.isUndefined(scriptsIfExists)) { throw new Error('Found a public package without any scripts in package.json'); } return !_.isUndefined(scriptsIfExists[DOC_GEN_COMMAND]); }); _.each(packagesWithDocs, pkg => { const name = pkg.package.name; const nameWithoutPrefix = _.startsWith(name, NPM_NAMESPACE) ? name.split('@0xproject/')[1] : name; const docSegmentIfExists = packageNameToWebsitePath[nameWithoutPrefix]; if (_.isUndefined(docSegmentIfExists)) { throw new Error( `Found package '${name}' with doc commands but no corresponding docSegment in monorepo_scripts package.ts. Please add an entry for it and try again.`, ); } const link = `${constants.stagingWebsite}/docs/${docSegmentIfExists}`; // tslint:disable-next-line:no-floating-promises opn(link); }); prompt.start(); const message = 'Do all the doc pages render properly? (yn)'; const result = await promisify(prompt.get)([message]); const didConfirm = result[message] === 'y'; if (!didConfirm) { utils.log('Publish process aborted.'); process.exit(0); } } async function checkPublishRequiredSetupAsync(): Promise { // check to see if logged into npm before publishing try { // HACK: for some reason on some setups, the `npm whoami` will not recognize a logged-in user // unless run with `sudo` (i.e Fabio's NVM setup) but is fine for others (Jacob's N setup). await execAsync(`sudo npm whoami`); } catch (err) { utils.log('You must be logged into npm in the commandline to publish. Run `npm login` and try again.'); return false; } // Check to see if Git personal token setup if (_.isUndefined(constants.githubPersonalAccessToken)) { utils.log( 'You must have a Github personal access token set to an envVar named `GITHUB_PERSONAL_ACCESS_TOKEN_0X_JS`. Add it then try again.', ); return false; } // Check Yarn version is 1.X const result = await execAsync(`yarn --version`); const version = result.stdout; const versionSegments = version.split('.'); const majorVersion = _.parseInt(versionSegments[0]); if (majorVersion < 1) { utils.log('Your yarn version must be v1.x or higher. Upgrade yarn and try again.'); return false; } // Check that `aws` commandline tool is installed try { await execAsync(`aws help`); } catch (err) { utils.log('You must have `awscli` commandline tool installed. Install it and try again.'); return false; } // Check that `aws` credentials are setup try { await execAsync(`aws sts get-caller-identity`); } catch (err) { utils.log('You must setup your AWS credentials by running `aws configure`. Do this and try again.'); return false; } return true; } async function pushChangelogsToGithubAsync(): Promise { await execAsync(`git add . --all`, { cwd: constants.monorepoRootPath }); await execAsync(`git commit -m "Updated CHANGELOGS"`, { cwd: constants.monorepoRootPath }); await execAsync(`git push`, { cwd: constants.monorepoRootPath }); utils.log(`Pushed CHANGELOG updates to Github`); } async function updateChangeLogsAsync(updatedPublicLernaPackages: LernaPackage[]): Promise { const packageToVersionChange: PackageToVersionChange = {}; for (const lernaPackage of updatedPublicLernaPackages) { const packageName = lernaPackage.package.name; const changelogJSONPath = path.join(lernaPackage.location, 'CHANGELOG.json'); const changelogJSON = utils.getChangelogJSONOrCreateIfMissing(changelogJSONPath); let changelogs: Changelog[]; try { changelogs = JSON.parse(changelogJSON); } catch (err) { throw new Error( `${lernaPackage.package.name}'s CHANGELOG.json contains invalid JSON. Please fix and try again.`, ); } const currentVersion = lernaPackage.package.version; const shouldAddNewEntry = shouldAddNewChangelogEntry(currentVersion, changelogs); if (shouldAddNewEntry) { // Create a new entry for a patch version with generic changelog entry. const nextPatchVersion = utils.getNextPatchVersion(currentVersion); const newChangelogEntry: Changelog = { timestamp: TODAYS_TIMESTAMP, version: nextPatchVersion, changes: [ { note: 'Dependencies updated', }, ], }; changelogs = [newChangelogEntry, ...changelogs]; packageToVersionChange[packageName] = semverDiff(currentVersion, nextPatchVersion); } else { // Update existing entry with timestamp const lastEntry = changelogs[0]; if (_.isUndefined(lastEntry.timestamp)) { lastEntry.timestamp = TODAYS_TIMESTAMP; } // Check version number is correct. const proposedNextVersion = lastEntry.version; lastEntry.version = updateVersionNumberIfNeeded(currentVersion, proposedNextVersion); changelogs[0] = lastEntry; packageToVersionChange[packageName] = semverDiff(currentVersion, lastEntry.version); } // Save updated CHANGELOG.json fs.writeFileSync(changelogJSONPath, JSON.stringify(changelogs, null, '\t')); await utils.prettifyAsync(changelogJSONPath, constants.monorepoRootPath); utils.log(`${packageName}: Updated CHANGELOG.json`); // Generate updated CHANGELOG.md const changelogMd = generateChangelogMd(changelogs); const changelogMdPath = path.join(lernaPackage.location, 'CHANGELOG.md'); fs.writeFileSync(changelogMdPath, changelogMd); await utils.prettifyAsync(changelogMdPath, constants.monorepoRootPath); utils.log(`${packageName}: Updated CHANGELOG.md`); } return packageToVersionChange; } async function lernaPublishAsync(packageToVersionChange: { [name: string]: string }): Promise { // HACK: Lerna publish does not provide a way to specify multiple package versions via // flags so instead we need to interact with their interactive prompt interface. const child = spawn('lerna', ['publish', '--registry=https://registry.npmjs.org/'], { cwd: constants.monorepoRootPath, }); let shouldPrintOutput = false; child.stdout.on('data', (data: Buffer) => { const output = data.toString('utf8'); if (shouldPrintOutput) { utils.log(output); } const isVersionPrompt = _.includes(output, 'Select a new version'); if (isVersionPrompt) { const outputStripLeft = output.split('new version for ')[1]; const packageName = outputStripLeft.split(' ')[0]; let versionChange = packageToVersionChange[packageName]; const isPrivatePackage = _.isUndefined(versionChange); if (isPrivatePackage) { versionChange = 'patch'; // Always patch updates to private packages. } const semVerIndex = semverNameToIndex[versionChange]; child.stdin.write(`${semVerIndex}\n`); } const isFinalPrompt = _.includes(output, 'Are you sure you want to publish the above changes?'); if (isFinalPrompt && !IS_DRY_RUN) { child.stdin.write(`y\n`); // After confirmations, we want to print the output to watch the `lerna publish` command shouldPrintOutput = true; } else if (isFinalPrompt && IS_DRY_RUN) { utils.log( `Submitted all versions to Lerna but since this is a dry run, did not confirm. You need to CTRL-C to exit.`, ); } }); child.stderr.on('data', (data: Buffer) => { const output = data.toString('utf8'); utils.log('Stderr:', output); }); } function updateVersionNumberIfNeeded(currentVersion: string, proposedNextVersion: string): string { if (proposedNextVersion === currentVersion) { return utils.getNextPatchVersion(currentVersion); } const sortedVersions = semverSort.desc([proposedNextVersion, currentVersion]); if (sortedVersions[0] !== proposedNextVersion) { return utils.getNextPatchVersion(currentVersion); } return proposedNextVersion; } function shouldAddNewChangelogEntry(currentVersion: string, changelogs: Changelog[]): boolean { if (_.isEmpty(changelogs)) { return true; } const lastEntry = changelogs[0]; const isLastEntryCurrentVersion = lastEntry.version === currentVersion; return isLastEntryCurrentVersion; } function generateChangelogMd(changelogs: Changelog[]): string { let changelogMd = ` CHANGELOG `; _.each(changelogs, changelog => { if (_.isUndefined(changelog.timestamp)) { throw new Error( 'All CHANGELOG.json entries must be updated to include a timestamp before generating their MD version', ); } const date = moment(`${changelog.timestamp}`, 'X').format('MMMM D, YYYY'); const title = `\n## v${changelog.version} - _${date}_\n\n`; changelogMd += title; let changes = ''; _.each(changelog.changes, change => { let line = ` * ${change.note}`; if (!_.isUndefined(change.pr)) { line += ` (#${change.pr})`; } line += '\n'; changes += line; }); changelogMd += `${changes}`; }); return changelogMd; }