diff options
Diffstat (limited to 'packages/monorepo-scripts/src')
-rw-r--r-- | packages/monorepo-scripts/src/constants.ts | 3 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/convert_changelogs.ts | 99 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/deps_versions.ts | 4 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/find_unused_dependencies.ts | 34 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/globals.d.ts | 24 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/postpublish_utils.ts | 123 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/prepublish_checks.ts | 189 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/publish.ts | 323 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/test_installation.ts | 176 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/types.ts | 45 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/utils.ts | 20 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/utils/changelog_utils.ts | 106 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/utils/configs.ts | 8 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/utils/npm_utils.ts | 29 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/utils/utils.ts | 167 |
15 files changed, 934 insertions, 416 deletions
diff --git a/packages/monorepo-scripts/src/constants.ts b/packages/monorepo-scripts/src/constants.ts index 74387a159..e5d3348bd 100644 --- a/packages/monorepo-scripts/src/constants.ts +++ b/packages/monorepo-scripts/src/constants.ts @@ -2,4 +2,7 @@ import * as path from 'path'; export const constants = { monorepoRootPath: path.join(__dirname, '../../..'), + stagingWebsite: 'http://staging-0xproject.s3-website-us-east-1.amazonaws.com', + lernaExecutable: path.join('node_modules', '@0x-lerna-fork', 'lerna', 'cli.js'), + githubPersonalAccessToken: process.env.GITHUB_PERSONAL_ACCESS_TOKEN_0X_JS, }; diff --git a/packages/monorepo-scripts/src/convert_changelogs.ts b/packages/monorepo-scripts/src/convert_changelogs.ts deleted file mode 100644 index b5be14ed8..000000000 --- a/packages/monorepo-scripts/src/convert_changelogs.ts +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node -/** - * TEMPORARY SCRIPT - * This script exists to migrate the legacy CHANGELOG.md to the canonical CHANGELOG.md - * TODO: Remove after migration is successful and committed. - */ - -import * as fs from 'fs'; -import lernaGetPackages = require('lerna-get-packages'); -import * as _ from 'lodash'; -import * as moment from 'moment'; -import * as path from 'path'; -import { exec as execAsync } from 'promisify-child-process'; - -import { constants } from './constants'; -import { Changelog, Changes, UpdatedPackage } from './types'; -import { utils } from './utils'; - -const HEADER_PRAGMA = '##'; - -(async () => { - const allLernaPackages = lernaGetPackages(constants.monorepoRootPath); - const publicLernaPackages = _.filter(allLernaPackages, pkg => !pkg.package.private); - for (const lernaPackage of publicLernaPackages) { - const changelogMdIfExists = getChangelogMdIfExists(lernaPackage.package.name, lernaPackage.location); - if (_.isUndefined(changelogMdIfExists)) { - throw new Error(`${lernaPackage.package.name} should have CHANGELOG.md b/c it's public. Add one.`); - } - - const lines = changelogMdIfExists.split('\n'); - const changelogs: Changelog[] = []; - let changelog: Changelog = { - version: '', - changes: [], - }; - /** - * Example MD entry: - * ## v0.3.1 - _March 18, 2018_ - * - * * Add TS types for `yargs` (#400) - */ - for (const line of lines) { - if (_.startsWith(line, `${HEADER_PRAGMA} `)) { - let version = line.substr(4).split(' - ')[0]; - if (version === '0.x.x') { - version = utils.getNextPatchVersion(lernaPackage.package.version); - } - const dateStr = line.split('_')[1]; - let date; - if (!_.includes(dateStr, 'TBD')) { - date = moment(dateStr, 'MMMM D, YYYY'); - } - changelog = { - version, - changes: [], - }; - if (!_.isUndefined(date)) { - changelog.timestamp = date.unix(); - } - if (!_.includes(dateStr, 'TBD')) { - changelog.isPublished = true; - } - changelogs.push(changelog); - } else if (_.includes(line, '* ')) { - const note = line.split('* ')[1].split(' (#')[0]; - const prChunk = line.split(' (#')[1]; - let pr; - if (!_.isUndefined(prChunk)) { - pr = prChunk.split(')')[0]; - } - const changes: Changes = { - note, - }; - if (!_.isUndefined(pr)) { - changes.pr = _.parseInt(pr); - } - changelog.changes.push(changes); - } - } - const changelogJSON = JSON.stringify(changelogs, null, 4); - const changelogJSONPath = `${lernaPackage.location}/CHANGELOG.json`; - fs.writeFileSync(changelogJSONPath, changelogJSON); - await utils.prettifyAsync(changelogJSONPath, constants.monorepoRootPath); - } -})().catch(err => { - utils.log(err.stdout); - process.exit(1); -}); - -function getChangelogMdIfExists(packageName: string, location: string): string | undefined { - const changelogPath = path.join(location, 'CHANGELOG.md'); - let changelogMd: string; - try { - changelogMd = fs.readFileSync(changelogPath, 'utf-8'); - return changelogMd; - } catch (err) { - return undefined; - } -} diff --git a/packages/monorepo-scripts/src/deps_versions.ts b/packages/monorepo-scripts/src/deps_versions.ts index 07292a160..1053906b7 100644 --- a/packages/monorepo-scripts/src/deps_versions.ts +++ b/packages/monorepo-scripts/src/deps_versions.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import { sync as globSync } from 'glob'; import * as _ from 'lodash'; -import { utils } from './utils'; +import { utils } from './utils/utils'; interface Dependencies { [depName: string]: string; @@ -19,6 +19,7 @@ interface VersionsByDependency { const PACKAGE_JSON_GLOB = '../*/package.json'; +// tslint:disable:no-unused-variable function getDependencies(path: string): Dependencies { const file = fs.readFileSync(path).toString(); const parsed = JSON.parse(file); @@ -52,3 +53,4 @@ _.map(versionsByDependency, (versions: Versions, depName: string) => { }); } }); +// tslint:disable:no-unused-variable diff --git a/packages/monorepo-scripts/src/find_unused_dependencies.ts b/packages/monorepo-scripts/src/find_unused_dependencies.ts new file mode 100644 index 000000000..4bb4b7dc5 --- /dev/null +++ b/packages/monorepo-scripts/src/find_unused_dependencies.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import * as depcheckAsync from 'depcheck'; +import * as _ from 'lodash'; + +import { constants } from './constants'; +import { utils } from './utils/utils'; + +// For some reason, `depcheck` hangs on some packages. Add them here. +const IGNORE_PACKAGES = ['@0xproject/sol-compiler']; + +(async () => { + utils.log('*** NOTE: Not all deps listed here are actually not required. ***'); + utils.log("*** `depcheck` isn't perfect so double check before actually removing any. ***\n"); + const packages = utils.getPackages(constants.monorepoRootPath); + for (const pkg of packages) { + if (_.includes(IGNORE_PACKAGES, pkg.packageJson.name)) { + continue; // skip + } + utils.log(`Checking ${pkg.packageJson.name} for unused deps. This might take a while...`); + + const configs = {}; + const { dependencies } = await depcheckAsync(pkg.location, configs); + if (!_.isEmpty(dependencies)) { + _.each(dependencies, dep => { + utils.log(dep); + }); + } + utils.log('\n'); + } +})().catch(err => { + utils.log(err); + process.exit(1); +}); diff --git a/packages/monorepo-scripts/src/globals.d.ts b/packages/monorepo-scripts/src/globals.d.ts index c5898d0f5..2b25802ee 100644 --- a/packages/monorepo-scripts/src/globals.d.ts +++ b/packages/monorepo-scripts/src/globals.d.ts @@ -3,27 +3,15 @@ declare module 'publish-release'; declare module 'es6-promisify'; declare module 'semver-diff'; +declare module 'prompt' { + const start: () => void; + const get: (promptMessages: string[], callback: (err: Error, result: string) => void) => void; +} + // semver-sort declarations declare module 'semver-sort' { const desc: (versions: string[]) => string[]; } -declare interface LernaPackage { - location: string; - package: { - private?: boolean; - version: string; - name: string; - main?: string; - config?: { - additionalTsTypings?: string[]; - }; - }; -} -declare function lernaGetPackages(path: string): LernaPackage[]; -// lerna-get-packages declarations -declare module 'lerna-get-packages' { - export = lernaGetPackages; -} - declare module 'promisify-child-process'; +declare module '@lerna/batch-packages'; diff --git a/packages/monorepo-scripts/src/postpublish_utils.ts b/packages/monorepo-scripts/src/postpublish_utils.ts index 236b54379..8e445a045 100644 --- a/packages/monorepo-scripts/src/postpublish_utils.ts +++ b/packages/monorepo-scripts/src/postpublish_utils.ts @@ -4,13 +4,12 @@ import * as fs from 'fs'; import * as _ from 'lodash'; import * as path from 'path'; import * as publishRelease from 'publish-release'; -import semverSort = require('semver-sort'); import { constants } from './constants'; -import { utils } from './utils'; +import { configs } from './utils/configs'; +import { utils } from './utils/utils'; const publishReleaseAsync = promisify(publishRelease); -const githubPersonalAccessToken = process.env.GITHUB_PERSONAL_ACCESS_TOKEN_0X_JS; const generatedDocsDirectoryName = 'generated_docs'; export interface PostpublishConfigs { @@ -36,7 +35,7 @@ export const postpublishUtils = { throw new Error('version field required in package.json. Cannot publish release notes to Github.'); } const postpublishConfig = _.get(packageJSON, 'config.postpublish', {}); - const configs: PostpublishConfigs = { + const postpublishConfigs: PostpublishConfigs = { cwd, packageName: packageJSON.name, version: packageJSON.version, @@ -50,54 +49,56 @@ export const postpublishUtils = { s3StagingBucketPath: _.get(postpublishConfig, 'docPublishConfigs.s3StagingBucketPath'), }, }; - return configs; + return postpublishConfigs; }, async runAsync(packageJSON: any, tsConfigJSON: any, cwd: string): Promise<void> { - const configs = this.generateConfig(packageJSON, tsConfigJSON, cwd); - const release = await this.publishReleaseNotesAsync( - configs.cwd, - configs.packageName, - configs.version, - configs.assets, + if (configs.IS_LOCAL_PUBLISH) { + return; + } + const postpublishConfigs = postpublishUtils.generateConfig(packageJSON, tsConfigJSON, cwd); + await postpublishUtils.publishReleaseNotesAsync( + postpublishConfigs.packageName, + postpublishConfigs.version, + postpublishConfigs.assets, ); if ( - !_.isUndefined(configs.docPublishConfigs.s3BucketPath) || - !_.isUndefined(configs.docPublishConfigs.s3StagingBucketPath) + !_.isUndefined(postpublishConfigs.docPublishConfigs.s3BucketPath) || + !_.isUndefined(postpublishConfigs.docPublishConfigs.s3StagingBucketPath) ) { utils.log('POSTPUBLISH: Release successful, generating docs...'); await postpublishUtils.generateAndUploadDocsAsync( - configs.cwd, - configs.docPublishConfigs.fileIncludes, - configs.version, - configs.docPublishConfigs.s3BucketPath, + postpublishConfigs.cwd, + postpublishConfigs.docPublishConfigs.fileIncludes, + postpublishConfigs.version, + postpublishConfigs.docPublishConfigs.s3BucketPath, ); } else { utils.log(`POSTPUBLISH: No S3Bucket config found for ${packageJSON.name}. Skipping doc JSON generation.`); } }, - async publishDocsToStagingAsync(packageJSON: any, tsConfigJSON: any, cwd: string) { - const configs = this.generateConfig(packageJSON, tsConfigJSON, cwd); - if (_.isUndefined(configs.docPublishConfigs.s3StagingBucketPath)) { + async publishDocsToStagingAsync(packageJSON: any, tsConfigJSON: any, cwd: string): Promise<void> { + const postpublishConfigs = postpublishUtils.generateConfig(packageJSON, tsConfigJSON, cwd); + if (_.isUndefined(postpublishConfigs.docPublishConfigs.s3StagingBucketPath)) { utils.log('config.postpublish.docPublishConfigs.s3StagingBucketPath entry in package.json not found!'); return; } utils.log('POSTPUBLISH: Generating docs...'); await postpublishUtils.generateAndUploadDocsAsync( - configs.cwd, - configs.docPublishConfigs.fileIncludes, - configs.version, - configs.docPublishConfigs.s3StagingBucketPath, + postpublishConfigs.cwd, + postpublishConfigs.docPublishConfigs.fileIncludes, + postpublishConfigs.version, + postpublishConfigs.docPublishConfigs.s3StagingBucketPath, ); }, - async publishReleaseNotesAsync(cwd: string, packageName: string, version: string, assets: string[]): Promise<void> { - const notes = this.getReleaseNotes(packageName); - const releaseName = this.getReleaseName(packageName, version); - const tag = this.getTag(packageName, version); - const finalAssets = this.adjustAssetPaths(cwd, assets); + async publishReleaseNotesAsync(packageName: string, version: string, assets: string[]): Promise<void> { + const notes = postpublishUtils.getReleaseNotes(packageName, version); + const releaseName = postpublishUtils.getReleaseName(packageName, version); + const tag = postpublishUtils.getTag(packageName, version); + postpublishUtils.adjustAssetPaths(assets); utils.log('POSTPUBLISH: Releasing ', releaseName, '...'); - const result = await publishReleaseAsync({ - token: githubPersonalAccessToken, + await publishReleaseAsync({ + token: constants.githubPersonalAccessToken, owner: '0xProject', repo: '0x-monorepo', tag, @@ -109,9 +110,8 @@ export const postpublishUtils = { reuseDraftOnly: false, assets, }); - this.updateChangelogIsPublished(packageName); }, - getReleaseNotes(packageName: string) { + getReleaseNotes(packageName: string, version: string): string { const packageNameWithNamespace = packageName.replace('@0xproject/', ''); const changelogJSONPath = path.join( constants.monorepoRootPath, @@ -122,45 +122,34 @@ export const postpublishUtils = { const changelogJSON = fs.readFileSync(changelogJSONPath, 'utf-8'); const changelogs = JSON.parse(changelogJSON); const latestLog = changelogs[0]; - if (_.isUndefined(latestLog.isPublished)) { - let notes = ''; - _.each(latestLog.changes, change => { - notes += `* ${change.note}`; - if (change.pr) { - notes += ` (${change.pr})`; - } - notes += `\n`; - }); - return notes; + // We sanity check that the version for the changelog notes we are about to publish to Github + // correspond to the new version of the package. + if (version !== latestLog.version) { + throw new Error('Expected CHANGELOG.json latest entry version to coincide with published version.'); } - return 'N/A'; - }, - updateChangelogIsPublished(packageName: string) { - const packageNameWithNamespace = packageName.replace('@0xproject/', ''); - const changelogJSONPath = path.join( - constants.monorepoRootPath, - 'packages', - packageNameWithNamespace, - 'CHANGELOG.json', - ); - const changelogJSON = fs.readFileSync(changelogJSONPath, 'utf-8'); - const changelogs = JSON.parse(changelogJSON); - const latestLog = changelogs[0]; - latestLog.isPublished = true; - changelogs[0] = latestLog; - fs.writeFileSync(changelogJSONPath, JSON.stringify(changelogs, null, '\t')); + let notes = ''; + _.each(latestLog.changes, change => { + notes += `* ${change.note}`; + if (change.pr) { + notes += ` (#${change.pr})`; + } + notes += `\n`; + }); + return notes; }, - getTag(packageName: string, version: string) { + getTag(packageName: string, version: string): string { return `${packageName}@${version}`; }, getReleaseName(subPackageName: string, version: string): string { const releaseName = `${subPackageName} v${version}`; return releaseName; }, - adjustAssetPaths(cwd: string, assets: string[]) { + // Asset paths should described from the monorepo root. This method prefixes + // the supplied path with the absolute path to the monorepo root. + adjustAssetPaths(assets: string[]): string[] { const finalAssets: string[] = []; _.each(assets, (asset: string) => { - finalAssets.push(`${cwd}/${asset}`); + finalAssets.push(`${constants.monorepoRootPath}/${asset}`); }); return finalAssets; }, @@ -173,14 +162,20 @@ export const postpublishUtils = { // HACK: tsconfig.json needs wildcard directory endings as `/**/*` // but TypeDoc needs it as `/**` in order to pick up files at the root if (_.endsWith(includePath, '/**/*')) { + // tslint:disable-next-line:custom-no-magic-numbers includePath = includePath.slice(0, -2); } return includePath; }); return fileIncludesAdjusted; }, - async generateAndUploadDocsAsync(cwd: string, fileIncludes: string[], version: string, S3BucketPath: string) { - const fileIncludesAdjusted = this.adjustFileIncludePaths(fileIncludes, cwd); + async generateAndUploadDocsAsync( + cwd: string, + fileIncludes: string[], + version: string, + S3BucketPath: string, + ): Promise<void> { + const fileIncludesAdjusted = postpublishUtils.adjustFileIncludePaths(fileIncludes, cwd); const projectFiles = fileIncludesAdjusted.join(' '); const jsonFilePath = `${cwd}/${generatedDocsDirectoryName}/index.json`; const result = await execAsync( diff --git a/packages/monorepo-scripts/src/prepublish_checks.ts b/packages/monorepo-scripts/src/prepublish_checks.ts new file mode 100644 index 000000000..683c26094 --- /dev/null +++ b/packages/monorepo-scripts/src/prepublish_checks.ts @@ -0,0 +1,189 @@ +import * as _ from 'lodash'; +import { exec as execAsync } from 'promisify-child-process'; +import semver = require('semver'); +import semverSort = require('semver-sort'); + +import { constants } from './constants'; +import { Package } from './types'; +import { changelogUtils } from './utils/changelog_utils'; +import { npmUtils } from './utils/npm_utils'; +import { utils } from './utils/utils'; + +async function prepublishChecksAsync(): Promise<void> { + const shouldIncludePrivate = false; + const updatedPublicPackages = await utils.getUpdatedPackagesAsync(shouldIncludePrivate); + + await checkCurrentVersionMatchesLatestPublishedNPMPackageAsync(updatedPublicPackages); + await checkChangelogFormatAsync(updatedPublicPackages); + await checkGitTagsForNextVersionAndDeleteIfExistAsync(updatedPublicPackages); + await checkPublishRequiredSetupAsync(); +} + +async function checkGitTagsForNextVersionAndDeleteIfExistAsync(updatedPublicPackages: Package[]): Promise<void> { + const packageNames = _.map(updatedPublicPackages, pkg => pkg.packageJson.name); + const localGitTags = await utils.getLocalGitTagsAsync(); + const localTagVersionsByPackageName = await utils.getGitTagsByPackageNameAsync(packageNames, localGitTags); + + const remoteGitTags = await utils.getRemoteGitTagsAsync(); + const remoteTagVersionsByPackageName = await utils.getGitTagsByPackageNameAsync(packageNames, remoteGitTags); + + for (const pkg of updatedPublicPackages) { + const currentVersion = pkg.packageJson.version; + const packageName = pkg.packageJson.name; + const packageLocation = pkg.location; + const nextVersion = await utils.getNextPackageVersionAsync(currentVersion, packageName, packageLocation); + + const remoteTagVersions = remoteTagVersionsByPackageName[packageName]; + if (_.includes(remoteTagVersions, nextVersion)) { + const tagName = `:refs/tags/${packageName}@${nextVersion}`; + await utils.removeRemoteTagAsync(tagName); + } + + const localTagVersions = localTagVersionsByPackageName[packageName]; + if (_.includes(localTagVersions, nextVersion)) { + const tagName = `${packageName}@${nextVersion}`; + await utils.removeLocalTagAsync(tagName); + } + } +} + +async function checkCurrentVersionMatchesLatestPublishedNPMPackageAsync( + updatedPublicPackages: Package[], +): Promise<void> { + utils.log('Check package versions against npm registry...'); + const versionMismatches = []; + for (const pkg of updatedPublicPackages) { + const packageName = pkg.packageJson.name; + const packageVersion = pkg.packageJson.version; + const packageRegistryJsonIfExists = await npmUtils.getPackageRegistryJsonIfExistsAsync(packageName); + if (_.isUndefined(packageRegistryJsonIfExists)) { + continue; // noop for packages not yet published to NPM + } + const allVersionsIncludingUnpublished = npmUtils.getPreviouslyPublishedVersions(packageRegistryJsonIfExists); + const sortedVersions = semverSort.desc(allVersionsIncludingUnpublished); + const latestNPMVersion = sortedVersions[0]; + if (packageVersion !== latestNPMVersion) { + versionMismatches.push({ + packageJsonVersion: packageVersion, + npmVersion: latestNPMVersion, + packageName, + }); + } + } + if (!_.isEmpty(versionMismatches)) { + utils.log(`Found version mismatches between package.json and NPM published versions (might be unpublished).`); + _.each(versionMismatches, versionMismatch => { + utils.log( + `${versionMismatch.packageName}: ${versionMismatch.packageJsonVersion} package.json, ${ + versionMismatch.npmVersion + } on NPM`, + ); + }); + throw new Error(`Please fix the above package.json/NPM inconsistencies.`); + } +} + +async function checkChangelogFormatAsync(updatedPublicPackages: Package[]): Promise<void> { + utils.log('Check CHANGELOGs for inconsistencies...'); + const changeLogInconsistencies = []; + for (const pkg of updatedPublicPackages) { + const packageName = pkg.packageJson.name; + const changelog = changelogUtils.getChangelogOrCreateIfMissing(packageName, pkg.location); + + const currentVersion = pkg.packageJson.version; + if (!_.isEmpty(changelog)) { + const lastEntry = changelog[0]; + const doesLastEntryHaveTimestamp = !_.isUndefined(lastEntry.timestamp); + if (semver.lt(lastEntry.version, currentVersion)) { + changeLogInconsistencies.push({ + packageJsonVersion: currentVersion, + changelogVersion: lastEntry.version, + packageName, + }); + } else if (semver.gt(lastEntry.version, currentVersion) && doesLastEntryHaveTimestamp) { + // Remove incorrectly added timestamp + delete changelog[0].timestamp; + // Save updated CHANGELOG.json + await changelogUtils.writeChangelogJsonFileAsync(pkg.location, changelog); + utils.log(`${packageName}: Removed timestamp from latest CHANGELOG.json entry.`); + } + } + } + if (!_.isEmpty(changeLogInconsistencies)) { + utils.log(`CHANGELOG versions cannot below package.json versions:`); + _.each(changeLogInconsistencies, inconsistency => { + utils.log( + `${inconsistency.packageName}: ${inconsistency.packageJsonVersion} package.json, ${ + inconsistency.changelogVersion + } CHANGELOG.json`, + ); + }); + throw new Error('Fix the above inconsistencies to continue.'); + } +} + +async function checkPublishRequiredSetupAsync(): Promise<void> { + // 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 NVM setup). + utils.log('Checking that the user is logged in on npm...'); + await execAsync(`sudo npm whoami`); + } catch (err) { + throw new Error('You must be logged into npm in the commandline to publish. Run `npm login` and try again.'); + } + + // Check to see if Git personal token setup + if (_.isUndefined(constants.githubPersonalAccessToken)) { + throw new Error( + 'You must have a Github personal access token set to an envVar named `GITHUB_PERSONAL_ACCESS_TOKEN_0X_JS`. Add it then try again.', + ); + } + + // Check Yarn version is 1.X + utils.log('Checking the yarn version...'); + const result = await execAsync(`yarn --version`); + const version = result.stdout; + const versionSegments = version.split('.'); + const majorVersion = _.parseInt(versionSegments[0]); + if (majorVersion < 1) { + throw new Error('Your yarn version must be v1.x or higher. Upgrade yarn and try again.'); + } + + // Check that `aws` commandline tool is installed + try { + utils.log('Checking that aws CLI tool is installed...'); + await execAsync(`aws help`); + } catch (err) { + throw new Error('You must have `awscli` commandline tool installed. Install it and try again.'); + } + + // Check that `aws` credentials are setup + try { + utils.log('Checking that aws credentials are configured...'); + await execAsync(`aws sts get-caller-identity`); + } catch (err) { + throw new Error('You must setup your AWS credentials by running `aws configure`. Do this and try again.'); + } + + utils.log('Checking that git branch is up to date with upstream...'); + await execAsync('git fetch'); + const res = await execAsync('git status -bs'); // s - short format, b - branch info + /** + * Possible outcomes + * ## branch_name...origin/branch_name [behind n] + * ## branch_name...origin/branch_name [ahead n] + * ## branch_name...origin/branch_name + */ + const gitShortStatusHeader = res.stdout.split('\n')[0]; + if (gitShortStatusHeader.includes('behind')) { + throw new Error('Your branch is behind upstream. Please pull before publishing.'); + } else if (gitShortStatusHeader.includes('ahead')) { + throw new Error('Your branch is ahead of upstream. Please push before publishing.'); + } +} + +prepublishChecksAsync().catch(err => { + utils.log(err.message); + process.exit(1); +}); diff --git a/packages/monorepo-scripts/src/publish.ts b/packages/monorepo-scripts/src/publish.ts index d749ec630..6ff0c9bef 100644 --- a/packages/monorepo-scripts/src/publish.ts +++ b/packages/monorepo-scripts/src/publish.ts @@ -1,217 +1,214 @@ #!/usr/bin/env node -import * as fs from 'fs'; -import lernaGetPackages = require('lerna-get-packages'); +import * as promisify from 'es6-promisify'; import * as _ from 'lodash'; import * as moment from 'moment'; -import * as path from 'path'; -import { exec as execAsync, spawn } from 'promisify-child-process'; -import semverDiff = require('semver-diff'); +import opn = require('opn'); +import { exec as execAsync } from 'promisify-child-process'; +import * as prompt from 'prompt'; +import semver = require('semver'); import semverSort = require('semver-sort'); import { constants } from './constants'; -import { Changelog, Changes, SemVerIndex, UpdatedPackage } from './types'; -import { utils } from './utils'; +import { Package, PackageToNextVersion, VersionChangelog } from './types'; +import { changelogUtils } from './utils/changelog_utils'; +import { configs } from './utils/configs'; +import { utils } from './utils/utils'; -const IS_DRY_RUN = process.env.IS_DRY_RUN === 'true'; +const DOC_GEN_COMMAND = 'docs:json'; +const NPM_NAMESPACE = '@0xproject/'; 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', + 'ethereum-types': 'ethereum-types', }; +async function confirmAsync(message: string): Promise<void> { + prompt.start(); + const result = await promisify(prompt.get)([message]); + const didConfirm = result[message] === 'y'; + if (!didConfirm) { + utils.log('Publish process aborted.'); + process.exit(0); + } +} + (async () => { - const updatedPublicPackages = await getPublicLernaUpdatedPackagesAsync(); - const updatedPackageNames = _.map(updatedPublicPackages, pkg => pkg.name); + // Fetch public, updated Lerna packages + const shouldIncludePrivate = true; + const allUpdatedPackages = await utils.getUpdatedPackagesAsync(shouldIncludePrivate); + + if (!configs.IS_LOCAL_PUBLISH) { + await confirmAsync( + 'THIS IS NOT A TEST PUBLISH! You are about to publish one or more packages to npm. Are you sure you want to continue? (y/n)', + ); + await confirmDocPagesRenderAsync(allUpdatedPackages); + } + + // Update CHANGELOGs + const updatedPublicPackages = _.filter(allUpdatedPackages, pkg => !pkg.packageJson.private); + const updatedPublicPackageNames = _.map(updatedPublicPackages, pkg => pkg.packageJson.name); + utils.log(`Will update CHANGELOGs and publish: \n${updatedPublicPackageNames.join('\n')}\n`); + const packageToNextVersion = await updateChangeLogsAsync(updatedPublicPackages); + + const updatedPrivatePackages = _.filter(allUpdatedPackages, pkg => pkg.packageJson.private); + _.each(updatedPrivatePackages, pkg => { + const currentVersion = pkg.packageJson.version; + const packageName = pkg.packageJson.name; + const nextPatchVersionIfValid = semver.inc(currentVersion, 'patch'); + if (!_.isNull(nextPatchVersionIfValid)) { + packageToNextVersion[packageName] = nextPatchVersionIfValid; + } else { + throw new Error(`Encountered invalid semver version: ${currentVersion} for package: ${packageName}`); + } + }); + + // Push changelog changes to Github + if (!configs.IS_LOCAL_PUBLISH) { + await pushChangelogsToGithubAsync(); + } + + // Call LernaPublish + utils.log('Version updates to apply:'); + _.each(packageToNextVersion, (versionChange: string, packageName: string) => { + utils.log(`${packageName} -> ${versionChange}`); + }); + utils.log(`Calling 'lerna publish'...`); + await lernaPublishAsync(packageToNextVersion); +})().catch(err => { + utils.log(err); + process.exit(1); +}); + +async function confirmDocPagesRenderAsync(packages: Package[]): Promise<void> { + // push docs to staging + utils.log("Upload all docJson's to S3 staging..."); + await execAsync(`yarn stage_docs`, { cwd: constants.monorepoRootPath }); - const allLernaPackages = lernaGetPackages(constants.monorepoRootPath); - const updatedPublicLernaPackages = _.filter(allLernaPackages, pkg => { - return _.includes(updatedPackageNames, pkg.package.name); + // 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.packageJson.scripts; + if (_.isUndefined(scriptsIfExists)) { + throw new Error('Found a public package without any scripts in package.json'); + } + return !_.isUndefined(scriptsIfExists[DOC_GEN_COMMAND]); }); - const updatedPublicLernaPackageNames = _.map(updatedPublicLernaPackages, pkg => pkg.package.name); - utils.log(`Will update CHANGELOGs and publish: \n${updatedPublicLernaPackageNames.join('\n')}\n`); - - const packageToVersionChange: { [name: string]: string } = {}; - for (const lernaPackage of updatedPublicLernaPackages) { - const packageName = lernaPackage.package.name; - const changelogJSONPath = path.join(lernaPackage.location, 'CHANGELOG.json'); - const changelogJSON = getChangelogJSONOrCreateIfMissing(lernaPackage.package.name, changelogJSONPath); - let changelogs: Changelog[]; - try { - changelogs = JSON.parse(changelogJSON); - } catch (err) { + _.each(packagesWithDocs, pkg => { + const name = pkg.packageJson.name; + const nameWithoutPrefix = _.startsWith(name, NPM_NAMESPACE) ? name.split('@0xproject/')[1] : name; + const docSegmentIfExists = packageNameToWebsitePath[nameWithoutPrefix]; + if (_.isUndefined(docSegmentIfExists)) { throw new Error( - `${lernaPackage.package.name}'s CHANGELOG.json contains invalid JSON. Please fix and try again.`, + `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); + }); + + await confirmAsync('Do all the doc pages render properly? (y/n)'); +} + +async function pushChangelogsToGithubAsync(): Promise<void> { + 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`); +} - const currentVersion = lernaPackage.package.version; - const shouldAddNewEntry = shouldAddNewChangelogEntry(changelogs); +async function updateChangeLogsAsync(updatedPublicPackages: Package[]): Promise<PackageToNextVersion> { + const packageToNextVersion: PackageToNextVersion = {}; + for (const pkg of updatedPublicPackages) { + const packageName = pkg.packageJson.name; + let changelog = changelogUtils.getChangelogOrCreateIfMissing(packageName, pkg.location); + + const currentVersion = pkg.packageJson.version; + const shouldAddNewEntry = changelogUtils.shouldAddNewChangelogEntry( + pkg.packageJson.name, + currentVersion, + changelog, + ); if (shouldAddNewEntry) { // Create a new entry for a patch version with generic changelog entry. - const nextPatchVersion = utils.getNextPatchVersion(currentVersion); - const newChangelogEntry: Changelog = { + const nextPatchVersionIfValid = semver.inc(currentVersion, 'patch'); + if (_.isNull(nextPatchVersionIfValid)) { + throw new Error(`Encountered invalid semver version: ${currentVersion} for package: ${packageName}`); + } + const newChangelogEntry: VersionChangelog = { timestamp: TODAYS_TIMESTAMP, - version: nextPatchVersion, + version: nextPatchVersionIfValid, changes: [ { note: 'Dependencies updated', }, ], }; - changelogs = [newChangelogEntry, ...changelogs]; - packageToVersionChange[packageName] = semverDiff(currentVersion, nextPatchVersion); + changelog = [newChangelogEntry, ...changelog]; + packageToNextVersion[packageName] = nextPatchVersionIfValid; } else { // Update existing entry with timestamp - const lastEntry = changelogs[0]; + const lastEntry = changelog[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); + changelog[0] = lastEntry; + packageToNextVersion[packageName] = lastEntry.version; } // Save updated CHANGELOG.json - fs.writeFileSync(changelogJSONPath, JSON.stringify(changelogs, null, 4)); - await utils.prettifyAsync(changelogJSONPath, constants.monorepoRootPath); + await changelogUtils.writeChangelogJsonFileAsync(pkg.location, changelog); 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); + const changelogMd = changelogUtils.generateChangelogMd(changelog); + await changelogUtils.writeChangelogMdFileAsync(pkg.location, changelogMd); utils.log(`${packageName}: Updated CHANGELOG.md`); } - if (!IS_DRY_RUN) { - 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`); - } - - 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 lernaPublishAsync(packageToVersionChange: { [name: string]: string }) { - // 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.`, - ); - } - }); + return packageToNextVersion; } -async function getPublicLernaUpdatedPackagesAsync(): Promise<UpdatedPackage[]> { - const result = await execAsync(`${LERNA_EXECUTABLE} updated --json`, { cwd: constants.monorepoRootPath }); - const updatedPackages = JSON.parse(result.stdout); - const updatedPublicPackages = _.filter(updatedPackages, updatedPackage => !updatedPackage.private); - return updatedPublicPackages; +async function lernaPublishAsync(packageToNextVersion: { [name: string]: string }): Promise<void> { + const packageVersionString = _.map(packageToNextVersion, (nextVersion: string, packageName: string) => { + return `${packageName}@${nextVersion}`; + }).join(','); + let lernaPublishCmd = `node ${constants.lernaExecutable} publish --cdVersions=${packageVersionString} --registry=${ + configs.NPM_REGISTRY_URL + } --yes`; + if (configs.IS_LOCAL_PUBLISH) { + lernaPublishCmd += ` --skip-git`; + } + utils.log('Lerna is publishing...'); + await execAsync(lernaPublishCmd, { cwd: constants.monorepoRootPath }); } -function updateVersionNumberIfNeeded(currentVersion: string, proposedNextVersion: string) { +function updateVersionNumberIfNeeded(currentVersion: string, proposedNextVersion: string): string { + const updatedVersionIfValid = semver.inc(currentVersion, 'patch'); + if (_.isNull(updatedVersionIfValid)) { + throw new Error(`Encountered invalid semver: ${currentVersion}`); + } if (proposedNextVersion === currentVersion) { - return utils.getNextPatchVersion(currentVersion); + return updatedVersionIfValid; } const sortedVersions = semverSort.desc([proposedNextVersion, currentVersion]); if (sortedVersions[0] !== proposedNextVersion) { - return utils.getNextPatchVersion(currentVersion); + return updatedVersionIfValid; } return proposedNextVersion; } - -function getChangelogJSONOrCreateIfMissing(packageName: string, changelogPath: string): string { - let changelogJSON: string; - try { - changelogJSON = fs.readFileSync(changelogPath, 'utf-8'); - return changelogJSON; - } catch (err) { - // If none exists, create new, empty one. - const emptyChangelogJSON = JSON.stringify([], null, 4); - fs.writeFileSync(changelogPath, emptyChangelogJSON); - return emptyChangelogJSON; - } -} - -function shouldAddNewChangelogEntry(changelogs: Changelog[]): boolean { - if (_.isEmpty(changelogs)) { - return true; - } - const lastEntry = changelogs[0]; - return !!lastEntry.isPublished; -} - -function generateChangelogMd(changelogs: Changelog[]): string { - let changelogMd = `<!-- -This file is auto-generated using the monorepo-scripts package. Don't edit directly. -Edit the package's CHANGELOG.json file only. ---> - -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; -} diff --git a/packages/monorepo-scripts/src/test_installation.ts b/packages/monorepo-scripts/src/test_installation.ts index 195b64b2a..87c4ad1d7 100644 --- a/packages/monorepo-scripts/src/test_installation.ts +++ b/packages/monorepo-scripts/src/test_installation.ts @@ -1,58 +1,150 @@ #!/usr/bin/env node import * as fs from 'fs'; -import lernaGetPackages = require('lerna-get-packages'); import * as _ from 'lodash'; +import * as mkdirp from 'mkdirp'; import * as path from 'path'; import { exec as execAsync } from 'promisify-child-process'; import * as rimraf from 'rimraf'; +import { promisify } from 'util'; -import { utils } from './utils'; +import { Package } from './types'; +import { utils } from './utils/utils'; + +// Packages might not be runnable if they are command-line tools or only run in browsers. +const UNRUNNABLE_PACKAGES = [ + '@0xproject/abi-gen', + '@0xproject/sra-report', + '@0xproject/react-shared', + '@0xproject/react-docs', +]; + +const mkdirpAsync = promisify(mkdirp); +const rimrafAsync = promisify(rimraf); +const writeFileAsync = promisify(fs.writeFile); + +interface PackageErr { + packageName: string; + error: ExecError; +} + +interface ExecError { + message: string; + stack: string; + stderr: string; + stdout: string; +} + +// returns the index for the given package name. +function findPackageIndex(packages: Package[], packageName: string): number { + return _.findIndex(packages, pkg => pkg.packageJson.name === packageName); +} + +function logIfDefined(x: any): void { + if (!_.isUndefined(x)) { + utils.log(x); + } +} (async () => { + const IS_LOCAL_PUBLISH = process.env.IS_LOCAL_PUBLISH === 'true'; + const registry = IS_LOCAL_PUBLISH ? 'http://localhost:4873/' : 'https://registry.npmjs.org/'; const monorepoRootPath = path.join(__dirname, '../../..'); - const lernaPackages = lernaGetPackages(monorepoRootPath); + const packages = utils.getPackages(monorepoRootPath); const installablePackages = _.filter( - lernaPackages, - lernaPackage => - !lernaPackage.package.private && - !_.isUndefined(lernaPackage.package.main) && - lernaPackage.package.main.endsWith('.js'), + packages, + pkg => !pkg.packageJson.private && !_.isUndefined(pkg.packageJson.main) && pkg.packageJson.main.endsWith('.js'), ); - for (const installableLernaPackage of installablePackages) { - const packagePath = installableLernaPackage.location; - const packageName = installableLernaPackage.package.name; - utils.log(`Testing ${packageName}`); - let result = await execAsync('npm pack', { cwd: packagePath }); - const packedPackageFileName = result.stdout.trim(); - const testDirectory = path.join(monorepoRootPath, '../test-env'); - fs.mkdirSync(testDirectory); - result = await execAsync('yarn init --yes', { cwd: testDirectory }); - utils.log(`Installing ${packedPackageFileName}`); - result = await execAsync(`yarn add ${packagePath}/${packedPackageFileName}`, { cwd: testDirectory }); - const indexFilePath = path.join(testDirectory, 'index.ts'); - fs.writeFileSync(indexFilePath, `import * as Package from '${packageName}'`); - const tsConfig = { - compilerOptions: { - typeRoots: ['node_modules/@0xproject/typescript-typings/types', 'node_modules/@types'], - module: 'commonjs', - target: 'es5', - lib: ['es2017', 'dom'], - declaration: true, - noImplicitReturns: true, - pretty: true, - strict: true, - }, - include: ['index.ts'], - }; - const tsconfigFilePath = path.join(testDirectory, 'tsconfig.json'); - fs.writeFileSync(tsconfigFilePath, JSON.stringify(tsConfig, null, 4)); - utils.log(`Compiling ${packageName}`); - await execAsync('../node_modules/typescript/bin/tsc', { cwd: testDirectory }); - utils.log(`Successfully compiled with ${packageName} as a dependency`); - rimraf.sync(testDirectory); + utils.log('Testing packages:'); + _.map(installablePackages, pkg => utils.log(`* ${pkg.packageJson.name}`)); + // Run all package tests asynchronously and push promises into an array so + // we can wait for all of them to resolve. + const promises: Array<Promise<void>> = []; + const errors: PackageErr[] = []; + for (const installablePackage of installablePackages) { + const packagePromise = testInstallPackageAsync(monorepoRootPath, registry, installablePackage).catch(error => { + errors.push({ packageName: installablePackage.packageJson.name, error }); + }); + promises.push(packagePromise); + } + await Promise.all(promises); + if (errors.length > 0) { + // We sort error messages according to package topology so that we can + // them in a more intuitive order. E.g. if package A has an error and + // package B imports it, the tests for both package A and package B will + // fail. But package B only fails because of an error in package A. + // Since the error in package A is the root cause, we log it first. + const topologicallySortedPackages = utils.getTopologicallySortedPackages(monorepoRootPath); + const topologicallySortedErrors = _.sortBy(errors, packageErr => + findPackageIndex(topologicallySortedPackages, packageErr.packageName), + ); + _.forEach(topologicallySortedErrors, packageError => { + utils.log(`ERROR in package ${packageError.packageName}:`); + logIfDefined(packageError.error.message); + logIfDefined(packageError.error.stderr); + logIfDefined(packageError.error.stdout); + logIfDefined(packageError.error.stack); + }); + process.exit(0); } })().catch(err => { - utils.log(err.stdout); - process.exit(1); + utils.log(`Unexpected error: ${err.message}`); + process.exit(0); }); + +async function testInstallPackageAsync( + monorepoRootPath: string, + registry: string, + installablePackage: Package, +): Promise<void> { + const changelogPath = path.join(installablePackage.location, 'CHANGELOG.json'); + const lastChangelogVersion = JSON.parse(fs.readFileSync(changelogPath).toString())[0].version; + const packageName = installablePackage.packageJson.name; + utils.log(`Testing ${packageName}@${lastChangelogVersion}`); + const packageDirName = path.join(...(packageName + '-test').split('/')); + const testDirectory = path.join( + monorepoRootPath, + 'packages', + 'monorepo-scripts', + '.installation-test', + packageDirName, + ); + await rimrafAsync(testDirectory); + await mkdirpAsync(testDirectory); + await execAsync('yarn init --yes', { cwd: testDirectory }); + const npmrcFilePath = path.join(testDirectory, '.npmrc'); + await writeFileAsync(npmrcFilePath, `registry=${registry}`); + utils.log(`Installing ${packageName}@${lastChangelogVersion}`); + await execAsync(`npm install --save ${packageName}@${lastChangelogVersion} --registry=${registry}`, { + cwd: testDirectory, + }); + const indexFilePath = path.join(testDirectory, 'index.ts'); + await writeFileAsync(indexFilePath, `import * as Package from '${packageName}';\nconsole.log(Package);\n`); + const tsConfig = { + compilerOptions: { + typeRoots: ['node_modules/@0xproject/typescript-typings/types', 'node_modules/@types'], + module: 'commonjs', + target: 'es5', + lib: ['es2017', 'dom'], + declaration: true, + noImplicitReturns: true, + pretty: true, + strict: true, + }, + include: ['index.ts'], + }; + const tsconfigFilePath = path.join(testDirectory, 'tsconfig.json'); + await writeFileAsync(tsconfigFilePath, JSON.stringify(tsConfig, null, '\t')); + utils.log(`Compiling ${packageName}`); + const tscBinaryPath = path.join(monorepoRootPath, './node_modules/typescript/bin/tsc'); + await execAsync(tscBinaryPath, { cwd: testDirectory }); + utils.log(`Successfully compiled with ${packageName} as a dependency`); + const isUnrunnablePkg = _.includes(UNRUNNABLE_PACKAGES, packageName); + if (!isUnrunnablePkg) { + const transpiledIndexFilePath = path.join(testDirectory, 'index.js'); + utils.log(`Running test script with ${packageName} imported`); + await execAsync(`node ${transpiledIndexFilePath}`); + utils.log(`Successfilly ran test script with ${packageName} imported`); + } + await rimrafAsync(testDirectory); +} diff --git a/packages/monorepo-scripts/src/types.ts b/packages/monorepo-scripts/src/types.ts index 7adec202f..d9e1dfabb 100644 --- a/packages/monorepo-scripts/src/types.ts +++ b/packages/monorepo-scripts/src/types.ts @@ -4,21 +4,48 @@ export interface UpdatedPackage { private: boolean; } -export interface Changes { +export interface Change { note: string; pr?: number; } -export interface Changelog { +export type Changelog = VersionChangelog[]; + +export interface VersionChangelog { timestamp?: number; version: string; - changes: Changes[]; - isPublished?: boolean; + changes: Change[]; +} + +export interface PackageToNextVersion { + [name: string]: string; +} + +export interface PackageRegistryJson { + versions: { + [version: string]: any; + }; + time: { + [version: string]: string; + }; +} + +export interface GitTagsByPackageName { + [packageName: string]: string[]; +} + +export interface PackageJSON { + private?: boolean; + version: string; + name: string; + main?: string; + scripts?: { [command: string]: string }; + config?: { + additionalTsTypings?: string[]; + }; } -export enum SemVerIndex { - Invalid, - Patch, - Minor, - Major, +export interface Package { + location: string; + packageJson: PackageJSON; } diff --git a/packages/monorepo-scripts/src/utils.ts b/packages/monorepo-scripts/src/utils.ts deleted file mode 100644 index 9aa37e272..000000000 --- a/packages/monorepo-scripts/src/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as _ from 'lodash'; -import { exec as execAsync, spawn } from 'promisify-child-process'; - -export const utils = { - log(...args: any[]): void { - console.log(...args); // tslint:disable-line:no-console - }, - getNextPatchVersion(currentVersion: string): string { - const versionSegments = currentVersion.split('.'); - const patch = _.parseInt(_.last(versionSegments) as string); - const newPatch = patch + 1; - const newPatchVersion = `${versionSegments[0]}.${versionSegments[1]}.${newPatch}`; - return newPatchVersion; - }, - async prettifyAsync(filePath: string, cwd: string) { - await execAsync(`prettier --write ${filePath} --config .prettierrc`, { - cwd, - }); - }, -}; diff --git a/packages/monorepo-scripts/src/utils/changelog_utils.ts b/packages/monorepo-scripts/src/utils/changelog_utils.ts new file mode 100644 index 000000000..4781b3b7d --- /dev/null +++ b/packages/monorepo-scripts/src/utils/changelog_utils.ts @@ -0,0 +1,106 @@ +import * as fs from 'fs'; +import * as _ from 'lodash'; +import * as moment from 'moment'; +import * as path from 'path'; +import { exec as execAsync } from 'promisify-child-process'; +import semver = require('semver'); + +import { constants } from '../constants'; +import { Change, Changelog, VersionChangelog } from '../types'; + +const CHANGELOG_MD_HEADER = ` +<!-- +changelogUtils.file is auto-generated using the monorepo-scripts package. Don't edit directly. +Edit the package's CHANGELOG.json file only. +--> + +CHANGELOG +`; + +export const changelogUtils = { + getChangelogMdTitle(versionChangelog: VersionChangelog): string { + if (_.isUndefined(versionChangelog.timestamp)) { + throw new Error( + 'All CHANGELOG.json entries must be updated to include a timestamp before generating their MD version', + ); + } + const date = moment(`${versionChangelog.timestamp}`, 'X').format('MMMM D, YYYY'); + const title = `\n## v${versionChangelog.version} - _${date}_\n\n`; + return title; + }, + getChangelogMdChange(change: Change): string { + let line = ` * ${change.note}`; + if (!_.isUndefined(change.pr)) { + line += ` (#${change.pr})`; + } + return line; + }, + generateChangelogMd(changelog: Changelog): string { + let changelogMd = CHANGELOG_MD_HEADER; + _.each(changelog, versionChangelog => { + const title = changelogUtils.getChangelogMdTitle(versionChangelog); + changelogMd += title; + const changelogVersionLines = _.map( + versionChangelog.changes, + changelogUtils.getChangelogMdChange.bind(changelogUtils), + ); + changelogMd += `${_.join(changelogVersionLines, '\n')}`; + }); + + return changelogMd; + }, + shouldAddNewChangelogEntry(packageName: string, currentVersion: string, changelog: Changelog): boolean { + if (_.isEmpty(changelog)) { + return true; + } + const lastEntry = changelog[0]; + if (semver.lt(lastEntry.version, currentVersion)) { + throw new Error( + `Found CHANGELOG version lower then current package version. ${packageName} current: ${currentVersion}, Changelog: ${ + lastEntry.version + }`, + ); + } + const isLastEntryCurrentVersion = lastEntry.version === currentVersion; + return isLastEntryCurrentVersion; + }, + getChangelogJSONIfExists(changelogPath: string): string | undefined { + try { + const changelogJSON = fs.readFileSync(changelogPath, 'utf-8'); + return changelogJSON; + } catch (err) { + return undefined; + } + }, + getChangelogOrCreateIfMissing(packageName: string, packageLocation: string): Changelog { + const changelogJSONPath = path.join(packageLocation, 'CHANGELOG.json'); + let changelogJsonIfExists = changelogUtils.getChangelogJSONIfExists(changelogJSONPath); + if (_.isUndefined(changelogJsonIfExists)) { + // If none exists, create new, empty one. + changelogJsonIfExists = '[]'; + fs.writeFileSync(changelogJSONPath, changelogJsonIfExists); + } + let changelog: Changelog; + try { + changelog = JSON.parse(changelogJsonIfExists); + } catch (err) { + throw new Error(`${packageName}'s CHANGELOG.json contains invalid JSON. Please fix and try again.`); + } + return changelog; + }, + async writeChangelogJsonFileAsync(packageLocation: string, changelog: Changelog): Promise<void> { + const changelogJSONPath = path.join(packageLocation, 'CHANGELOG.json'); + fs.writeFileSync(changelogJSONPath, JSON.stringify(changelog, null, '\t')); + await changelogUtils.prettifyAsync(changelogJSONPath, constants.monorepoRootPath); + }, + async writeChangelogMdFileAsync(packageLocation: string, changelogMdString: string): Promise<void> { + const changelogMarkdownPath = path.join(packageLocation, 'CHANGELOG.md'); + fs.writeFileSync(changelogMarkdownPath, changelogMdString); + await changelogUtils.prettifyAsync(changelogMarkdownPath, constants.monorepoRootPath); + }, + async prettifyAsync(filePath: string, cwd: string): Promise<void> { + await execAsync(`prettier --write ${filePath} --config .prettierrc`, { + cwd, + }); + }, +}; diff --git a/packages/monorepo-scripts/src/utils/configs.ts b/packages/monorepo-scripts/src/utils/configs.ts new file mode 100644 index 000000000..e579bdb7c --- /dev/null +++ b/packages/monorepo-scripts/src/utils/configs.ts @@ -0,0 +1,8 @@ +const IS_LOCAL_PUBLISH = process.env.IS_LOCAL_PUBLISH === 'true'; +const LOCAL_NPM_REGISTRY_URL = 'http://localhost:4873'; +const REMOTE_NPM_REGISTRY_URL = 'https://registry.npmjs.org'; + +export const configs = { + IS_LOCAL_PUBLISH, + NPM_REGISTRY_URL: IS_LOCAL_PUBLISH ? LOCAL_NPM_REGISTRY_URL : REMOTE_NPM_REGISTRY_URL, +}; diff --git a/packages/monorepo-scripts/src/utils/npm_utils.ts b/packages/monorepo-scripts/src/utils/npm_utils.ts new file mode 100644 index 000000000..363e31fbb --- /dev/null +++ b/packages/monorepo-scripts/src/utils/npm_utils.ts @@ -0,0 +1,29 @@ +import 'isomorphic-fetch'; +import * as _ from 'lodash'; + +import { PackageRegistryJson } from '../types'; + +import { configs } from './configs'; + +const SUCCESS_STATUS = 200; +const NOT_FOUND_STATUS = 404; + +export const npmUtils = { + async getPackageRegistryJsonIfExistsAsync(packageName: string): Promise<PackageRegistryJson | undefined> { + const url = `${configs.NPM_REGISTRY_URL}/${packageName}`; + const response = await fetch(url); + + if (response.status === NOT_FOUND_STATUS) { + return undefined; + } else if (response.status !== SUCCESS_STATUS) { + throw new Error(`Request to ${url} failed. Check your internet connection and that npm registry is up.`); + } + const packageRegistryJson = await response.json(); + return packageRegistryJson; + }, + getPreviouslyPublishedVersions(packageRegistryJson: PackageRegistryJson): string[] { + const timeWithOnlyVersions = _.omit(packageRegistryJson.time, ['modified', 'created']); + const versions = _.keys(timeWithOnlyVersions); + return versions; + }, +}; diff --git a/packages/monorepo-scripts/src/utils/utils.ts b/packages/monorepo-scripts/src/utils/utils.ts new file mode 100644 index 000000000..26ac801bd --- /dev/null +++ b/packages/monorepo-scripts/src/utils/utils.ts @@ -0,0 +1,167 @@ +import batchPackages = require('@lerna/batch-packages'); +import * as fs from 'fs'; +import * as _ from 'lodash'; +import { exec as execAsync } from 'promisify-child-process'; +import semver = require('semver'); + +import { constants } from '../constants'; +import { GitTagsByPackageName, Package, PackageJSON, UpdatedPackage } from '../types'; + +import { changelogUtils } from './changelog_utils'; + +export const utils = { + log(...args: any[]): void { + console.log(...args); // tslint:disable-line:no-console + }, + getTopologicallySortedPackages(rootDir: string): Package[] { + const packages = utils.getPackages(rootDir); + const batchedPackages: PackageJSON[] = _.flatten(batchPackages(_.map(packages, pkg => pkg.packageJson), false)); + const topsortedPackages: Package[] = _.map( + batchedPackages, + (pkg: PackageJSON) => _.find(packages, pkg1 => pkg1.packageJson.name === pkg.name) as Package, + ); + return topsortedPackages; + }, + getPackages(rootDir: string): Package[] { + const rootPackageJsonString = fs.readFileSync(`${rootDir}/package.json`, 'utf8'); + const rootPackageJson = JSON.parse(rootPackageJsonString); + if (_.isUndefined(rootPackageJson.workspaces)) { + throw new Error(`Did not find 'workspaces' key in root package.json`); + } + const packages = []; + for (const workspace of rootPackageJson.workspaces) { + // HACK: Remove allowed wildcards from workspace entries. + // This might be entirely comprehensive. + const workspacePath = workspace.replace('*', '').replace('**/*', ''); + const subpackageNames = fs.readdirSync(`${rootDir}/${workspacePath}`); + for (const subpackageName of subpackageNames) { + if (_.startsWith(subpackageName, '.')) { + continue; + } + const pathToPackageJson = `${rootDir}/${workspacePath}${subpackageName}`; + try { + const packageJsonString = fs.readFileSync(`${pathToPackageJson}/package.json`, 'utf8'); + const packageJson = JSON.parse(packageJsonString); + const pkg = { + location: pathToPackageJson, + packageJson, + }; + packages.push(pkg); + } catch (err) { + utils.log(`Couldn't find a 'package.json' for ${subpackageName}. Skipping.`); + } + } + } + return packages; + }, + async getUpdatedPackagesAsync(shouldIncludePrivate: boolean): Promise<Package[]> { + const updatedPublicPackages = await utils.getLernaUpdatedPackagesAsync(shouldIncludePrivate); + const updatedPackageNames = _.map(updatedPublicPackages, pkg => pkg.name); + + const allPackages = utils.getPackages(constants.monorepoRootPath); + const updatedPackages = _.filter(allPackages, pkg => { + return _.includes(updatedPackageNames, pkg.packageJson.name); + }); + return updatedPackages; + }, + async getLernaUpdatedPackagesAsync(shouldIncludePrivate: boolean): Promise<UpdatedPackage[]> { + const result = await execAsync(`${constants.lernaExecutable} updated --json`, { + cwd: constants.monorepoRootPath, + }); + const updatedPackages = JSON.parse(result.stdout); + if (!shouldIncludePrivate) { + const updatedPublicPackages = _.filter(updatedPackages, updatedPackage => !updatedPackage.private); + return updatedPublicPackages; + } + return updatedPackages; + }, + async getNextPackageVersionAsync( + currentVersion: string, + packageName: string, + packageLocation: string, + ): Promise<string> { + let nextVersionIfValid; + const changelog = changelogUtils.getChangelogOrCreateIfMissing(packageName, packageLocation); + if (_.isEmpty(changelog)) { + nextVersionIfValid = semver.inc(currentVersion, 'patch'); + } + const lastEntry = changelog[0]; + if (semver.gt(currentVersion, lastEntry.version)) { + throw new Error(`Package.json version cannot be greater then last CHANGELOG entry. Check: ${packageName}`); + } + nextVersionIfValid = semver.eq(lastEntry.version, currentVersion) + ? semver.inc(currentVersion, 'patch') + : lastEntry.version; + if (_.isNull(nextVersionIfValid)) { + throw new Error(`Encountered invalid semver: ${currentVersion} associated with ${packageName}`); + } + return nextVersionIfValid; + }, + async getRemoteGitTagsAsync(): Promise<string[]> { + const result = await execAsync(`git ls-remote --tags`, { + cwd: constants.monorepoRootPath, + }); + const tagsString = result.stdout; + const tagOutputs: string[] = tagsString.split('\n'); + const tags = _.compact( + _.map(tagOutputs, tagOutput => { + const tag = tagOutput.split('refs/tags/')[1]; + // Tags with `^{}` are duplicateous so we ignore them + // Source: https://stackoverflow.com/questions/15472107/when-listing-git-ls-remote-why-theres-after-the-tag-name + if (_.endsWith(tag, '^{}')) { + return undefined; + } + return tag; + }), + ); + return tags; + }, + async getLocalGitTagsAsync(): Promise<string[]> { + const result = await execAsync(`git tag`, { + cwd: constants.monorepoRootPath, + }); + const tagsString = result.stdout; + const tags = tagsString.split('\n'); + return tags; + }, + async getGitTagsByPackageNameAsync(packageNames: string[], gitTags: string[]): Promise<GitTagsByPackageName> { + const tagVersionByPackageName: GitTagsByPackageName = {}; + _.each(gitTags, tag => { + const packageNameIfExists = _.find(packageNames, name => { + return _.includes(tag, `${name}@`); + }); + if (_.isUndefined(packageNameIfExists)) { + return; // ignore tags not related to a package we care about. + } + const splitTag = tag.split(`${packageNameIfExists}@`); + if (splitTag.length !== 2) { + throw new Error(`Unexpected tag name found: ${tag}`); + } + const version = splitTag[1]; + (tagVersionByPackageName[packageNameIfExists] || (tagVersionByPackageName[packageNameIfExists] = [])).push( + version, + ); + }); + return tagVersionByPackageName; + }, + async removeLocalTagAsync(tagName: string): Promise<void> { + try { + await execAsync(`git tag -d ${tagName}`, { + cwd: constants.monorepoRootPath, + }); + } catch (err) { + throw new Error(`Failed to delete local git tag. Got err: ${err}`); + } + utils.log(`Removed local tag: ${tagName}`); + }, + async removeRemoteTagAsync(tagName: string): Promise<void> { + try { + await execAsync(`git push origin ${tagName}`, { + cwd: constants.monorepoRootPath, + }); + } catch (err) { + throw new Error(`Failed to delete remote git tag. Got err: ${err}`); + } + utils.log(`Removed remote tag: ${tagName}`); + }, +}; |