diff --git a/scripts/devtools/.gitignore b/scripts/devtools/.gitignore index c8d23da2ed..c0a5522d94 100644 --- a/scripts/devtools/.gitignore +++ b/scripts/devtools/.gitignore @@ -1 +1,2 @@ -.progress-estimator \ No newline at end of file +.build-metadata +.progress-estimator diff --git a/scripts/devtools/README.md b/scripts/devtools/README.md index e69de29bb2..7f2410c366 100644 --- a/scripts/devtools/README.md +++ b/scripts/devtools/README.md @@ -0,0 +1,30 @@ +# Releasing DevTools + +To release DevTools, do the following steps (in order): +1. [Prepare a release](#prepare-a-release) +2. [Build and test a release](#build-and-test-a-release) +3. [Publish a release](#publish-a-release) + +Each of the scripts can be run with a `--dry` flag to test without committing or publishing any changes. + +### Prepare a release +To increment version numbers and update the [CHANGELOG](https://github.com/facebook/react/blob/main/packages/react-devtools/CHANGELOG.md), run the `prepare-release` script: +```sh +./prepare-release.js +``` + +You'll need to follow the instructions at the end of the script to push the committed changes to the main fork on GitHub. + +### Build and test a release +To build and test a release, run the `build-and-test` script: +```sh +./build-and-test.js +``` + +### Publish a release +To publish a release to NPM, run the `publish-release` script: +```sh +./publish-release.js +``` + +You'll need to follow the instructions at the end of the script to upload the extension to Chrome, Edge, and Firefox stores. \ No newline at end of file diff --git a/scripts/devtools/build-and-test.js b/scripts/devtools/build-and-test.js new file mode 100755 index 0000000000..cfd41bd809 --- /dev/null +++ b/scripts/devtools/build-and-test.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +'use strict'; + +const chalk = require('chalk'); +const {exec} = require('child-process-promise'); +const inquirer = require('inquirer'); +const {homedir} = require('os'); +const {join, relative} = require('path'); +const {DRY_RUN, ROOT_PATH} = require('./configuration'); +const { + clear, + confirm, + confirmContinue, + execRead, + logger, + saveBuildMetadata, +} = require('./utils'); + +// This is the primary control function for this script. +async function main() { + clear(); + + await confirm('Have you run the prepare-release script?', () => { + const prepareReleaseScriptPath = join(__dirname, 'prepare-release.js'); + const pathToPrint = relative(process.cwd(), prepareReleaseScriptPath); + + console.log('Begin by running the prepare-release script:'); + console.log(chalk.bold.green(' ' + pathToPrint)); + }); + + const archivePath = await archiveGitRevision(); + const buildID = await downloadLatestReactBuild(); + + await buildAndTestInlinePackage(); + await buildAndTestStandalonePackage(); + await buildAndTestExtensions(); + + saveBuildMetadata({archivePath, buildID}); + + printFinalInstructions(); +} + +async function archiveGitRevision() { + const desktopPath = join(homedir(), 'Desktop'); + const archivePath = join(desktopPath, 'DevTools.tgz'); + + console.log(`Creating git archive at ${chalk.dim(archivePath)}`); + console.log(''); + + if (!DRY_RUN) { + await exec(`git archive main | gzip > ${archivePath}`, {cwd: ROOT_PATH}); + } + + return archivePath; +} + +async function buildAndTestExtensions() { + const extensionsPackagePath = join( + ROOT_PATH, + 'packages', + 'react-devtools-extensions' + ); + const buildExtensionsPromise = exec('yarn build', { + cwd: extensionsPackagePath, + }); + + await logger( + buildExtensionsPromise, + `Building browser extensions ${chalk.dim('(this may take a minute)')}`, + { + estimate: 60000, + } + ); + + console.log(''); + console.log(`Extensions have been build for Chrome, Edge, and Firefox.`); + console.log(''); + console.log('Smoke test each extension before continuing:'); + console.log(` ${chalk.bold.green('cd ' + extensionsPackagePath)}`); + console.log(''); + console.log(` ${chalk.dim('# Test Chrome extension')}`); + console.log(` ${chalk.bold.green('yarn test:chrome')}`); + console.log(''); + console.log(` ${chalk.dim('# Test Edge extension')}`); + console.log(` ${chalk.bold.green('yarn test:edge')}`); + console.log(''); + console.log(` ${chalk.dim('# Firefox Chrome extension')}`); + console.log(` ${chalk.bold.green('yarn test:firefox')}`); + + await confirmContinue(); +} + +async function buildAndTestStandalonePackage() { + const corePackagePath = join(ROOT_PATH, 'packages', 'react-devtools-core'); + const buildCorePromise = exec('yarn build', {cwd: corePackagePath}); + + await logger( + buildCorePromise, + `Building ${chalk.bold('react-devtools-core')} package.`, + { + estimate: 25000, + } + ); + + const standalonePackagePath = join(ROOT_PATH, 'packages', 'react-devtools'); + const safariFixturePath = join( + ROOT_PATH, + 'fixtures', + 'devtools', + 'standalone', + 'index.html' + ); + + console.log(''); + console.log( + `Test the ${chalk.bold('react-devtools-core')} target before continuing:` + ); + console.log(` ${chalk.bold.green('cd ' + standalonePackagePath)}`); + console.log(` ${chalk.bold.green('yarn start')}`); + console.log(''); + console.log( + 'The following fixture can be useful for testing Safari integration:' + ); + console.log(` ${chalk.dim(safariFixturePath)}`); + + await confirmContinue(); +} + +async function buildAndTestInlinePackage() { + const inlinePackagePath = join( + ROOT_PATH, + 'packages', + 'react-devtools-inline' + ); + const buildPromise = exec('yarn build', {cwd: inlinePackagePath}); + + await logger( + buildPromise, + `Building ${chalk.bold('react-devtools-inline')} package.`, + { + estimate: 10000, + } + ); + + const shellPackagePath = join(ROOT_PATH, 'packages', 'react-devtools-shell'); + + console.log(''); + console.log(`Built ${chalk.bold('react-devtools-inline')} target.`); + console.log(''); + console.log('Test this build before continuing:'); + console.log(` ${chalk.bold.green('cd ' + shellPackagePath)}`); + console.log(` ${chalk.bold.green('yarn start')}`); + + await confirmContinue(); +} + +async function downloadLatestReactBuild() { + const releaseScriptPath = join(ROOT_PATH, 'scripts', 'release'); + const installPromise = exec('yarn install', {cwd: releaseScriptPath}); + + await logger( + installPromise, + `Installing release script dependencies. ${chalk.dim( + '(this may take a minute if CI is still running)' + )}`, + { + estimate: 5000, + } + ); + + console.log(''); + + const {commit} = await inquirer.prompt([ + { + type: 'input', + name: 'commit', + message: 'Which React version (commit) should be used?', + default: 'main', + }, + ]); + console.log(''); + + const downloadScriptPath = join( + releaseScriptPath, + 'download-experimental-build.js' + ); + const downloadPromise = execRead( + `"${downloadScriptPath}" --commit=${commit}` + ); + + const output = await logger( + downloadPromise, + 'Downloading React artifacts from CI.', + {estimate: 15000} + ); + + const match = output.match('--build=([0-9]+)'); + if (match.length === 0) { + console.error(chalk.red(`No build ID found in "${output}"`)); + process.exit(1); + } + + const buildID = match[1]; + + console.log(''); + console.log(`Downloaded artiacts for CI build ${chalk.bold(buildID)}.`); + + return buildID; +} + +function printFinalInstructions() { + const publishReleaseScriptPath = join(__dirname, 'publish-release.js'); + const pathToPrint = relative(process.cwd(), publishReleaseScriptPath); + + console.log(''); + console.log('Continue by running the publish-release script:'); + console.log(chalk.bold.green(' ' + pathToPrint)); +} + +main(); diff --git a/scripts/devtools/configuration.js b/scripts/devtools/configuration.js new file mode 100644 index 0000000000..e90b764b36 --- /dev/null +++ b/scripts/devtools/configuration.js @@ -0,0 +1,46 @@ +'use strict'; + +const {join} = require('path'); + +const PACKAGE_PATHS = [ + 'packages/react-devtools/package.json', + 'packages/react-devtools-core/package.json', + 'packages/react-devtools-inline/package.json', + 'packages/react-devtools-scheduling-profiler/package.json', +]; + +const MANIFEST_PATHS = [ + 'packages/react-devtools-extensions/chrome/manifest.json', + 'packages/react-devtools-extensions/edge/manifest.json', + 'packages/react-devtools-extensions/firefox/manifest.json', +]; + +const NPM_PACKAGES = [ + 'react-devtools', + 'react-devtools-core', + 'react-devtools-inline', +]; + +const CHANGELOG_PATH = 'packages/react-devtools/CHANGELOG.md'; + +const PULL_REQUEST_BASE_URL = 'https://github.com/facebook/react/pull/'; + +const RELEASE_SCRIPT_TOKEN = ''; + +const ROOT_PATH = join(__dirname, '..', '..'); + +const DRY_RUN = process.argv.includes('--dry'); + +const BUILD_METADATA_TEMP_DIRECTORY = join(__dirname, '.build-metadata'); + +module.exports = { + BUILD_METADATA_TEMP_DIRECTORY, + CHANGELOG_PATH, + DRY_RUN, + MANIFEST_PATHS, + NPM_PACKAGES, + PACKAGE_PATHS, + PULL_REQUEST_BASE_URL, + RELEASE_SCRIPT_TOKEN, + ROOT_PATH, +}; diff --git a/scripts/devtools/prepare-release.js b/scripts/devtools/prepare-release.js new file mode 100755 index 0000000000..5905eec621 --- /dev/null +++ b/scripts/devtools/prepare-release.js @@ -0,0 +1,254 @@ +#!/usr/bin/env node + +'use strict'; + +const chalk = require('chalk'); +const {exec} = require('child-process-promise'); +const {readFileSync, writeFileSync} = require('fs'); +const {readJsonSync, writeJsonSync} = require('fs-extra'); +const inquirer = require('inquirer'); +const {join, relative} = require('path'); +const semver = require('semver'); +const { + CHANGELOG_PATH, + DRY_RUN, + MANIFEST_PATHS, + PACKAGE_PATHS, + PULL_REQUEST_BASE_URL, + RELEASE_SCRIPT_TOKEN, + ROOT_PATH, +} = require('./configuration'); +const { + checkNPMPermissions, + clear, + confirmContinue, + execRead, +} = require('./utils'); + +// This is the primary control function for this script. +async function main() { + clear(); + + await checkNPMPermissions(); + + const releaseType = await getReleaseType(); + + const path = join(ROOT_PATH, PACKAGE_PATHS[0]); + const previousVersion = readJsonSync(path).version; + const {major, minor, patch} = semver(previousVersion); + const nextVersion = + releaseType === 'minor' + ? `${major}.${minor + 1}.${patch}` + : `${major}.${minor}.${patch + 1}`; + + updatePackageVersions(previousVersion, nextVersion); + updateManifestVersions(previousVersion, nextVersion); + + console.log(''); + console.log( + `Packages and manifests have been updated from version ${chalk.bold( + previousVersion + )} to ${chalk.bold(nextVersion)}` + ); + console.log(''); + + const sha = await getPreviousCommitSha(); + const commitLog = await getCommitLog(sha); + + updateChangelog(nextVersion, commitLog); + + await reviewChangelogPrompt(); + + await commitPendingChanges(previousVersion, nextVersion); + + printFinalInstructions(); +} + +async function commitPendingChanges(previousVersion, nextVersion) { + console.log(''); + console.log('Committing revision and changelog.'); + console.log(chalk.dim(' git add .')); + console.log( + chalk.dim( + ` git commit -m "React DevTools ${previousVersion} -> ${nextVersion}"` + ) + ); + + if (!DRY_RUN) { + await exec(` + git add . + git commit -m "React DevTools ${previousVersion} -> ${nextVersion}" + `); + } + + console.log(''); + console.log(`Please push this commit before continuing:`); + console.log(` ${chalk.bold.green('git push')}`); + + await confirmContinue(); +} + +async function getCommitLog(sha) { + let formattedLog = ''; + + const rawLog = await execRead(` + git log --topo-order --pretty=format:'%s' ${sha}...HEAD -- packages/react-devtools* + `); + rawLog.split('\n').forEach(line => { + line = line.replace('[DevTools] ', ''); + + const match = line.match(/(.+) \(#([0-9]+)\)/); + if (match !== null) { + const title = match[1]; + const pr = match[2]; + + formattedLog += `\n* ${title} ([USERNAME](https://github.com/USERNAME) in [#${pr}](${PULL_REQUEST_BASE_URL}${pr}))`; + } else { + formattedLog += `\n* ${line}`; + } + }); + + return formattedLog; +} + +async function getPreviousCommitSha() { + const choices = []; + + const lines = await execRead(` + git log --max-count=5 --topo-order --pretty=format:'%H:::%s:::%as' HEAD -- ${PACKAGE_PATHS[0]} + `); + lines.split('\n').forEach((line, index) => { + const [hash, message, date] = line.split(':::'); + choices.push({ + name: `${chalk.bold(hash)} ${chalk.dim(date)} ${message}`, + value: hash, + short: date, + }); + }); + + const {sha} = await inquirer.prompt([ + { + type: 'list', + name: 'sha', + message: 'Which of the commits above marks the last DevTools release?', + choices, + default: choices[0].value, + }, + ]); + + return sha; +} + +async function getReleaseType() { + const {releaseType} = await inquirer.prompt([ + { + type: 'list', + name: 'releaseType', + message: 'Which type of release is this?', + choices: [ + { + name: 'Minor (new user facing functionality)', + value: 'minor', + short: 'Minor', + }, + {name: 'Patch (bug fixes only)', value: 'patch', short: 'Patch'}, + ], + default: 'patch', + }, + ]); + + return releaseType; +} + +function printFinalInstructions() { + const buildAndTestcriptPath = join(__dirname, 'build-and-test.js'); + const pathToPrint = relative(process.cwd(), buildAndTestcriptPath); + + console.log(''); + console.log('Continue by running the build-and-test script:'); + console.log(chalk.bold.green(' ' + pathToPrint)); +} + +async function reviewChangelogPrompt() { + console.log(''); + console.log( + 'The changelog has been updated with commits since the previous release:' + ); + console.log(` ${chalk.bold(CHANGELOG_PATH)}`); + console.log(''); + console.log('Please review the new changelog text for the following:'); + console.log(' 1. Organize the list into Features vs Bugfixes'); + console.log(' 1. Filter out any non-user-visible changes (e.g. typo fixes)'); + console.log(' 1. Combine related PRs into a single bullet list.'); + console.log( + ' 1. Replacing the "USERNAME" placeholder text with the GitHub username(s)' + ); + console.log(''); + console.log(` ${chalk.bold.green(`open ${CHANGELOG_PATH}`)}`); + + await confirmContinue(); +} + +function updateChangelog(nextVersion, commitLog) { + const path = join(ROOT_PATH, CHANGELOG_PATH); + const oldChangelog = readFileSync(path, 'utf8'); + + const [beginning, end] = oldChangelog.split(RELEASE_SCRIPT_TOKEN); + + const dateString = new Date().toLocaleDateString('en-us', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const header = `## ${nextVersion} (${dateString})`; + + const newChangelog = `${beginning}${RELEASE_SCRIPT_TOKEN}\n\n${header}\n${commitLog}${end}`; + + console.log(chalk.dim(' Updating changelog: ' + CHANGELOG_PATH)); + + if (!DRY_RUN) { + writeFileSync(path, newChangelog); + } +} + +function updateManifestVersions(previousVersion, nextVersion) { + MANIFEST_PATHS.forEach(partialPath => { + const path = join(ROOT_PATH, partialPath); + const json = readJsonSync(path); + json.version = nextVersion; + + if (json.hasOwnProperty('version_name')) { + json.version_name = nextVersion; + } + + console.log(chalk.dim(' Updating manifest JSON: ' + partialPath)); + + if (!DRY_RUN) { + writeJsonSync(path, json, {spaces: 2}); + } + }); +} + +function updatePackageVersions(previousVersion, nextVersion) { + PACKAGE_PATHS.forEach(partialPath => { + const path = join(ROOT_PATH, partialPath); + const json = readJsonSync(path); + json.version = nextVersion; + + for (let key in json.dependencies) { + if (key.startsWith('react-devtools')) { + const version = json.dependencies[key]; + + json.dependencies[key] = version.replace(previousVersion, nextVersion); + } + } + + console.log(chalk.dim(' Updating package JSON: ' + partialPath)); + + if (!DRY_RUN) { + writeJsonSync(path, json, {spaces: 2}); + } + }); +} + +main(); diff --git a/scripts/devtools/publish-release.js b/scripts/devtools/publish-release.js new file mode 100755 index 0000000000..5fcfe89cb7 --- /dev/null +++ b/scripts/devtools/publish-release.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +'use strict'; + +const chalk = require('chalk'); +const {exec} = require('child-process-promise'); +const {readJsonSync} = require('fs-extra'); +const inquirer = require('inquirer'); +const {join, relative} = require('path'); +const {DRY_RUN, NPM_PACKAGES, ROOT_PATH} = require('./configuration'); +const { + checkNPMPermissions, + clear, + confirm, + execRead, + logger, + readSavedBuildMetadata, +} = require('./utils'); + +// This is the primary control function for this script. +async function main() { + clear(); + + await confirm('Have you run the build-and-test script?', () => { + const buildAndTestScriptPath = join(__dirname, 'build-and-test.js'); + const pathToPrint = relative(process.cwd(), buildAndTestScriptPath); + + console.log('Begin by running the build-and-test script:'); + console.log(chalk.bold.green(' ' + pathToPrint)); + }); + + const {archivePath, buildID} = readSavedBuildMetadata(); + + await checkNPMPermissions(); + + await publishToNPM(); + + await printFinalInstructions(buildID, archivePath); +} + +async function printFinalInstructions(buildID, archivePath) { + console.log(''); + console.log( + 'You are now ready to publish the extension to Chrome, Edge, and Firefox:' + ); + console.log( + ` ${chalk.blue.underline( + 'https://fburl.com/publish-react-devtools-extensions' + )}` + ); + console.log(''); + console.log('When publishing to Firefox, remember the following:'); + console.log(` Build id: ${chalk.bold(buildID)}`); + console.log(` Git archive: ${chalk.bold(archivePath)}`); + console.log(''); + console.log('Also consider syncing this release to Facebook:'); + console.log(` ${chalk.bold.green('js1 upgrade react-devtools')}`); +} + +async function publishToNPM() { + const {otp} = await inquirer.prompt([ + { + type: 'input', + name: 'otp', + message: 'Please provide an NPM two-factor auth token:', + }, + ]); + + console.log(''); + + if (!otp) { + console.error(chalk.red(`Invalid OTP provided: "${chalk.bold(otp)}"`)); + process.exit(0); + } + + for (let index = 0; index < NPM_PACKAGES.length; index++) { + const npmPackage = NPM_PACKAGES[index]; + const packagePath = join(ROOT_PATH, 'packages', npmPackage); + const {version} = readJsonSync(join(packagePath, 'package.json')); + + // Check if this package version has already been published. + // If so we might be resuming from a previous run. + // We could infer this by comparing the build-info.json, + // But for now the easiest way is just to ask if this is expected. + const info = await execRead(`npm view ${npmPackage}@${version}`); + if (info) { + console.log(''); + console.log( + `${npmPackage} version ${chalk.bold( + version + )} has already been published.` + ); + + await confirm('Is this expected?'); + } + + if (DRY_RUN) { + console.log(`Publishing package ${chalk.bold(npmPackage)}`); + console.log(chalk.dim(` npm publish --otp=${otp}`)); + } else { + const publishPromise = exec(`npm publish --otp=${otp}`, { + cwd: packagePath, + }); + + await logger( + publishPromise, + `Publishing package ${chalk.bold(npmPackage)}`, + { + estimate: 2500, + } + ); + } + } +} + +main(); diff --git a/scripts/devtools/release.js b/scripts/devtools/release.js deleted file mode 100755 index d403c0cd02..0000000000 --- a/scripts/devtools/release.js +++ /dev/null @@ -1,553 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const chalk = require('chalk'); -const {exec} = require('child-process-promise'); -const {readFileSync, writeFileSync} = require('fs'); -const {readJsonSync, writeJsonSync} = require('fs-extra'); -const inquirer = require('inquirer'); -const {homedir} = require('os'); -const createLogger = require('progress-estimator'); -const {join} = require('path'); -const semver = require('semver'); - -const PACKAGE_PATHS = [ - 'packages/react-devtools/package.json', - 'packages/react-devtools-core/package.json', - 'packages/react-devtools-inline/package.json', - 'packages/react-devtools-scheduling-profiler/package.json', -]; - -const MANIFEST_PATHS = [ - 'packages/react-devtools-extensions/chrome/manifest.json', - 'packages/react-devtools-extensions/edge/manifest.json', - 'packages/react-devtools-extensions/firefox/manifest.json', -]; - -const NPM_PACKAGES = [ - 'react-devtools', - 'react-devtools-core', - 'react-devtools-inline', -]; - -const CHANGELOG_PATH = 'packages/react-devtools/CHANGELOG.md'; - -const PULL_REQUEST_BASE_URL = 'https://github.com/facebook/react/pull/'; - -const RELEASE_SCRIPT_TOKEN = ''; - -const ROOT_PATH = join(__dirname, '..', '..'); - -const DRY_RUN = process.argv.includes('--dry'); - -const logger = createLogger({ - storagePath: join(__dirname, '.progress-estimator'), -}); - -// This is the primary control function for this script. -async function main() { - await checkNPMPermissions(); - - const releaseType = await getReleaseType(); - - const path = join(ROOT_PATH, PACKAGE_PATHS[0]); - const previousVersion = readJsonSync(path).version; - const {major, minor, patch} = semver(previousVersion); - const nextVersion = - releaseType === 'minor' - ? `${major}.${minor + 1}.${patch}` - : `${major}.${minor}.${patch + 1}`; - - updatePackageVersions(previousVersion, nextVersion); - updateManifestVersions(previousVersion, nextVersion); - - console.log(''); - console.log( - `Packages and manifests have been updated from version ${chalk.bold( - previousVersion - )} to ${chalk.bold(nextVersion)}` - ); - console.log(''); - - const sha = await getPreviousCommitSha(); - const commitLog = await getCommitLog(sha); - - updateChangelog(nextVersion, commitLog); - - await reviewChangelogPrompt(); - - await commitPendingChanges(previousVersion, nextVersion); - - const archivePath = await archiveGitRevision(); - const buildID = await downloadLatestReactBuild(); - - await buildAndTestInlinePackage(); - await buildAndTestStandalonePackage(); - await buildAndTestExtensions(); - - await publishToNPM(); - - await printFinalInstructions(buildID, archivePath); -} - -async function archiveGitRevision() { - const desktopPath = join(homedir(), 'Desktop'); - const archivePath = join(desktopPath, 'DevTools.tgz'); - - console.log(`Creating git archive at ${chalk.dim(archivePath)}`); - console.log(''); - - if (!DRY_RUN) { - await exec(`git archive main | gzip > ${archivePath}`, {cwd: ROOT_PATH}); - } - - return archivePath; -} - -async function buildAndTestExtensions() { - const extensionsPackagePath = join( - ROOT_PATH, - 'packages', - 'react-devtools-extensions' - ); - const buildExtensionsPromise = exec('yarn build', { - cwd: extensionsPackagePath, - }); - - await logger( - buildExtensionsPromise, - `Building browser extensions ${chalk.dim('(this may take a minute)')}`, - { - estimate: 60000, - } - ); - - console.log(''); - console.log(`Extensions have been build for Chrome, Edge, and Firefox.`); - console.log(''); - console.log('Smoke test each extension before continuing:'); - console.log(` ${chalk.bold.green('cd ' + extensionsPackagePath)}`); - console.log(''); - console.log(` ${chalk.dim('# Test Chrome extension')}`); - console.log(` ${chalk.bold.green('yarn test:chrome')}`); - console.log(''); - console.log(` ${chalk.dim('# Test Edge extension')}`); - console.log(` ${chalk.bold.green('yarn test:edge')}`); - console.log(''); - console.log(` ${chalk.dim('# Firefox Chrome extension')}`); - console.log(` ${chalk.bold.green('yarn test:firefox')}`); - - await confirmContinue(); -} - -async function buildAndTestStandalonePackage() { - const corePackagePath = join(ROOT_PATH, 'packages', 'react-devtools-core'); - const buildCorePromise = exec('yarn build', {cwd: corePackagePath}); - - await logger( - buildCorePromise, - `Building ${chalk.bold('react-devtools-core')} package.`, - { - estimate: 25000, - } - ); - - const standalonePackagePath = join(ROOT_PATH, 'packages', 'react-devtools'); - const safariFixturePath = join( - ROOT_PATH, - 'fixtures', - 'devtools', - 'standalone', - 'index.html' - ); - - console.log(''); - console.log( - `Test the ${chalk.bold('react-devtools-core')} target before continuing:` - ); - console.log(` ${chalk.bold.green('cd ' + standalonePackagePath)}`); - console.log(` ${chalk.bold.green('yarn start')}`); - console.log(''); - console.log( - 'The following fixture can be useful for testing Safari integration:' - ); - console.log(` ${chalk.dim(safariFixturePath)}`); - - await confirmContinue(); -} - -async function buildAndTestInlinePackage() { - const inlinePackagePath = join( - ROOT_PATH, - 'packages', - 'react-devtools-inline' - ); - const buildPromise = exec('yarn build', {cwd: inlinePackagePath}); - - await logger( - buildPromise, - `Building ${chalk.bold('react-devtools-inline')} package.`, - { - estimate: 10000, - } - ); - - const shellPackagePath = join(ROOT_PATH, 'packages', 'react-devtools-shell'); - - console.log(''); - console.log(`Built ${chalk.bold('react-devtools-inline')} target.`); - console.log(''); - console.log('Test this build before continuing:'); - console.log(` ${chalk.bold.green('cd ' + shellPackagePath)}`); - console.log(` ${chalk.bold.green('yarn start')}`); - - await confirmContinue(); -} - -async function checkNPMPermissions() { - const currentUser = await execRead('npm whoami'); - const failedProjects = []; - - const checkProject = async project => { - const owners = (await execRead(`npm owner ls ${project}`)) - .split('\n') - .filter(owner => owner) - .map(owner => owner.split(' ')[0]); - - if (!owners.includes(currentUser)) { - failedProjects.push(project); - } - }; - - await logger( - Promise.all(NPM_PACKAGES.map(checkProject)), - `Checking NPM permissions for ${chalk.bold(currentUser)}.`, - {estimate: 2500} - ); - - console.log(''); - - if (failedProjects.length) { - console.error(chalk.bold('Insufficient NPM permissions')); - console.error(''); - console.error( - `NPM user {underline ${currentUser}} is not an owner for: ${chalk.bold( - failedProjects.join(', ') - )}` - ); - console.error( - 'Please contact a React team member to be added to the above project(s).' - ); - process.exit(1); - } -} - -async function commitPendingChanges(previousVersion, nextVersion) { - console.log(''); - console.log('Committing revision and changelog.'); - console.log(chalk.dim(' git add .')); - console.log( - chalk.dim( - ` git commit -m "React DevTools ${previousVersion} -> ${nextVersion}"` - ) - ); - - if (!DRY_RUN) { - await exec(` - git add . - git commit -m "React DevTools ${previousVersion} -> ${nextVersion}" - `); - } - - console.log(''); - console.log(`Please push this commit before continuing:`); - console.log(` ${chalk.bold.green('git push')}`); - - await confirmContinue(); -} - -async function confirmContinue() { - console.log(''); - - const {confirm} = await inquirer.prompt({ - name: 'confirm', - type: 'confirm', - message: 'Continue the release?', - }); - if (!confirm) { - process.exit(0); - } - - console.log(''); -} - -async function downloadLatestReactBuild() { - const releaseScriptPath = join(ROOT_PATH, 'scripts', 'release'); - const installPromise = exec('yarn install', {cwd: releaseScriptPath}); - - await logger(installPromise, 'Installing release script dependencies.', { - estimate: 5000, - }); - - console.log(''); - - const {commit} = await inquirer.prompt([ - { - type: 'input', - name: 'commit', - message: 'Which React version (commit) should be used?', - default: 'main', - }, - ]); - console.log(''); - - const downloadScriptPath = join( - releaseScriptPath, - 'download-experimental-build.js' - ); - const downloadPromise = execRead( - `"${downloadScriptPath}" --commit=${commit}` - ); - - const output = await logger( - downloadPromise, - 'Downloading React artifacts from CI.', - {estimate: 15000} - ); - - const match = output.match('--build=([0-9]+)'); - if (match.length === 0) { - console.error(`No build ID found in "${output}"`); - process.exit(1); - } - - const buildID = match[1]; - - console.log(''); - console.log(`Downloaded artiacts for CI build ${chalk.bold(buildID)}.`); - - await confirmContinue(); - - return buildID; -} - -async function execRead(command, options) { - const {stdout} = await exec(command, options); - - return stdout.trim(); -} - -async function getCommitLog(sha) { - let formattedLog = ''; - - const rawLog = await execRead(` - git log --topo-order --pretty=format:'%s' ${sha}...HEAD -- packages/react-devtools* - `); - rawLog.split('\n').forEach(line => { - line = line.replace('[DevTools] ', ''); - - const match = line.match(/(.+) \(#([0-9]+)\)/); - if (match !== null) { - const title = match[1]; - const pr = match[2]; - - formattedLog += `\n* ${title} ([USERNAME](https://github.com/USERNAME) in [#${pr}](${PULL_REQUEST_BASE_URL}${pr}))`; - } else { - formattedLog += `\n* ${line}`; - } - }); - - return formattedLog; -} - -async function getPreviousCommitSha() { - const choices = []; - - const lines = await execRead(` - git log --max-count=5 --topo-order --pretty=format:'%H:::%s:::%as' HEAD -- ${PACKAGE_PATHS[0]} - `); - lines.split('\n').forEach((line, index) => { - const [hash, message, date] = line.split(':::'); - choices.push({ - name: `${chalk.bold(hash)} ${chalk.dim(date)} ${message}`, - value: hash, - short: date, - }); - }); - - const {sha} = await inquirer.prompt([ - { - type: 'list', - name: 'sha', - message: 'Which of the commits above marks the last DevTools release?', - choices, - default: choices[0].value, - }, - ]); - - return sha; -} - -async function getReleaseType() { - const {releaseType} = await inquirer.prompt([ - { - type: 'list', - name: 'releaseType', - message: 'Which type of release is this?', - choices: [ - { - name: 'Minor (new user facing functionality)', - value: 'minor', - short: 'Minor', - }, - {name: 'Patch (bug fixes only)', value: 'patch', short: 'Patch'}, - ], - default: 'patch', - }, - ]); - - return releaseType; -} - -async function printFinalInstructions(buildID, archivePath) { - console.log(''); - console.log( - 'You are now ready to publish the extension to Chrome, Edge, and Firefox:' - ); - console.log( - ` ${chalk.blue.underline( - 'https://fburl.com/publish-react-devtools-extensions' - )}` - ); - console.log(''); - console.log('When publishing to Firefox, remember the following:'); - console.log(` Build id: ${chalk.bold(buildID)}`); - console.log(` Git archive: ${chalk.bold(archivePath)}`); - console.log(''); - console.log('Also consider syncing this release to Facebook:'); - console.log(` ${chalk.bold.green('js1 upgrade react-devtools')}`); -} - -async function publishToNPM() { - const {otp} = await inquirer.prompt([ - { - type: 'input', - name: 'otp', - message: 'Please provide an NPM two-factor auth token:', - }, - ]); - - console.log(''); - - if (!otp) { - console.error(`Invalid OTP provided: "${chalk.bold(otp)}"`); - process.exit(0); - } - - for (let index = 0; index < NPM_PACKAGES.length; index++) { - const npmPackage = NPM_PACKAGES[index]; - const packagePath = join(ROOT_PATH, 'packages', npmPackage); - - if (DRY_RUN) { - console.log(`Publishing package ${chalk.bold(npmPackage)}`); - console.log(chalk.dim(` npm publish --otp=${otp}`)); - } else { - const publishPromise = exec(`npm publish --otp=${otp}`, { - cwd: packagePath, - }); - - await logger( - publishPromise, - `Publishing package ${chalk.bold(npmPackage)}`, - { - estimate: 2500, - } - ); - } - } -} - -async function reviewChangelogPrompt() { - console.log(''); - console.log( - 'The changelog has been updated with commits since the previous release:' - ); - console.log(` ${chalk.bold(CHANGELOG_PATH)}`); - console.log(''); - console.log('Please review the new changelog text for the following:'); - console.log(' 1. Organize the list into Features vs Bugfixes'); - console.log(' 1. Filter out any non-user-visible changes (e.g. typo fixes)'); - console.log(' 1. Combine related PRs into a single bullet list.'); - console.log( - ' 1. Replacing the "USERNAME" placeholder text with the GitHub username(s)' - ); - console.log(''); - console.log(` ${chalk.bold.green(`open ${CHANGELOG_PATH}`)}`); - - await confirmContinue(); -} - -function updateChangelog(nextVersion, commitLog) { - const path = join(ROOT_PATH, CHANGELOG_PATH); - const oldChangelog = readFileSync(path, 'utf8'); - - const [beginning, end] = oldChangelog.split(RELEASE_SCRIPT_TOKEN); - - const dateString = new Date().toLocaleDateString('en-us', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - const header = `## ${nextVersion} (${dateString})`; - - const newChangelog = `${beginning}${RELEASE_SCRIPT_TOKEN}\n\n${header}\n${commitLog}${end}`; - - console.log(chalk.dim(' Updating changelog: ' + CHANGELOG_PATH)); - - if (!DRY_RUN) { - writeFileSync(path, newChangelog); - } -} - -function updateManifestVersions(previousVersion, nextVersion) { - MANIFEST_PATHS.forEach(partialPath => { - const path = join(ROOT_PATH, partialPath); - const json = readJsonSync(path); - json.version = nextVersion; - - if (json.hasOwnProperty('version_name')) { - json.version_name = nextVersion; - } - - console.log(chalk.dim(' Updating manifest JSON: ' + partialPath)); - - if (!DRY_RUN) { - writeJsonSync(path, json, {spaces: 2}); - } - }); -} - -function updatePackageVersions(previousVersion, nextVersion) { - PACKAGE_PATHS.forEach(partialPath => { - const path = join(ROOT_PATH, partialPath); - const json = readJsonSync(path); - json.version = nextVersion; - - for (let key in json.dependencies) { - if (key.startsWith('react-devtools')) { - const version = json.dependencies[key]; - - json.dependencies[key] = version.replace(previousVersion, nextVersion); - } - } - - console.log(chalk.dim(' Updating package JSON: ' + partialPath)); - - if (!DRY_RUN) { - writeJsonSync(path, json, {spaces: 2}); - } - }); -} - -main(); diff --git a/scripts/devtools/utils.js b/scripts/devtools/utils.js new file mode 100644 index 0000000000..17d0c1f40e --- /dev/null +++ b/scripts/devtools/utils.js @@ -0,0 +1,128 @@ +'use strict'; + +const chalk = require('chalk'); +const {exec} = require('child-process-promise'); +const {existsSync, mkdirSync} = require('fs'); +const {readJsonSync, writeJsonSync} = require('fs-extra'); +const inquirer = require('inquirer'); +const {join} = require('path'); +const createLogger = require('progress-estimator'); +const { + BUILD_METADATA_TEMP_DIRECTORY, + NPM_PACKAGES, +} = require('./configuration'); + +const logger = createLogger({ + storagePath: join(__dirname, '.progress-estimator'), +}); + +async function checkNPMPermissions() { + const currentUser = await execRead('npm whoami'); + const failedProjects = []; + + const checkProject = async project => { + const owners = (await execRead(`npm owner ls ${project}`)) + .split('\n') + .filter(owner => owner) + .map(owner => owner.split(' ')[0]); + + if (!owners.includes(currentUser)) { + failedProjects.push(project); + } + }; + + await logger( + Promise.all(NPM_PACKAGES.map(checkProject)), + `Checking NPM permissions for ${chalk.bold(currentUser)}.`, + {estimate: 2500} + ); + + console.log(''); + + if (failedProjects.length) { + console.error(chalk.red.bold('Insufficient NPM permissions')); + console.error(''); + console.error( + chalk.red( + `NPM user {underline ${currentUser}} is not an owner for: ${chalk.bold( + failedProjects.join(', ') + )}` + ) + ); + console.error( + chalk.red( + 'Please contact a React team member to be added to the above project(s).' + ) + ); + process.exit(1); + } +} + +function clear() { + console.clear(); +} + +async function confirm(message, exitFunction) { + console.log(''); + + const {confirmation} = await inquirer.prompt({ + name: 'confirmation', + type: 'confirm', + message, + }); + + console.log(''); + + if (!confirmation) { + if (typeof exitFunction === 'function') { + exitFunction(); + } + + process.exit(0); + } +} + +async function confirmContinue(exitFunction) { + await confirm('Continue the release?', exitFunction); +} + +async function execRead(command, options) { + const {stdout} = await exec(command, options); + + return stdout.trim(); +} + +function readSavedBuildMetadata() { + const path = join(BUILD_METADATA_TEMP_DIRECTORY, 'metadata'); + + if (!existsSync(path)) { + console.error(chalk.red('Expected to find build metadata at:')); + console.error(chalk.dim(` ${path}`)); + process.exit(1); + } + + const {archivePath, buildID} = readJsonSync(path); + + return {archivePath, buildID}; +} + +function saveBuildMetadata({archivePath, buildID}) { + const path = join(BUILD_METADATA_TEMP_DIRECTORY, 'metadata'); + + if (!existsSync(BUILD_METADATA_TEMP_DIRECTORY)) { + mkdirSync(BUILD_METADATA_TEMP_DIRECTORY); + } + + writeJsonSync(path, {archivePath, buildID}, {spaces: 2}); +} + +module.exports = { + checkNPMPermissions, + clear, + confirm, + confirmContinue, + execRead, + logger, + readSavedBuildMetadata, + saveBuildMetadata, +};