From 36ba51c66c65a1d017d86ae799c33c74aab3488e Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 12 Feb 2024 04:42:27 -0800 Subject: [PATCH 1/6] Add Flow, add positive test case for monorepo publish step (#42936) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/42936 Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D53607809 fbshipit-source-id: 990826fda5538af9a13e3f24978295a2f3b0c8c3 # Conflicts: # scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js # scripts/monorepo/find-and-publish-all-bumped-packages.js --- ...nd-and-publish-all-bumped-packages-test.js | 80 +++++++++++++++++-- .../find-and-publish-all-bumped-packages.js | 26 +++--- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js index 7ef3f9ca0de7..885afbe4a1c9 100644 --- a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js +++ b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js @@ -4,20 +4,30 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * + * @flow strict-local * @format + * @oncall react_native */ -const {spawnSync} = require('child_process'); - const {PUBLISH_PACKAGES_TAG} = require('../constants'); -const forEachPackage = require('../for-each-package'); -const findAndPublishAllBumpedPackages = require('../find-and-publish-all-bumped-packages'); +const { + findAndPublishAllBumpedPackages, +} = require('../find-and-publish-all-bumped-packages'); + +const spawnSync = jest.fn(); +const forEachPackage = jest.fn(); +const execMock = jest.fn(); -jest.mock('child_process', () => ({spawnSync: jest.fn()})); -jest.mock('../for-each-package', () => jest.fn()); +jest.mock('child_process', () => ({spawnSync})); +jest.mock('shelljs', () => ({exec: execMock})); +jest.mock('../for-each-package', () => forEachPackage); describe('findAndPublishAllBumpedPackages', () => { - it('throws an error if updated version is not 0.x.y', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + test('should throw an error if updated version is not 0.x.y', async () => { const mockedPackageNewVersion = '1.0.0'; forEachPackage.mockImplementationOnce(callback => { @@ -34,8 +44,62 @@ describe('findAndPublishAllBumpedPackages', () => { stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`, })); - expect(() => findAndPublishAllBumpedPackages()).toThrow( + await expect(findAndPublishAllBumpedPackages()).rejects.toThrow( `Package version expected to be 0.x.y, but received ${mockedPackageNewVersion}`, ); }); + + test('should publish all changed packages', async () => { + forEachPackage.mockImplementationOnce(callback => { + callback('absolute/path/to/package-a', 'to/package-a', { + version: '0.72.1', + }); + callback('absolute/path/to/package-b', 'to/package-b', { + version: '0.72.1', + }); + callback('absolute/path/to/package-c', 'to/package-b', { + version: '0.72.0', + }); + }); + + spawnSync.mockImplementationOnce(() => ({ + stdout: `- "version": "0.72.0"\n+ "version": "0.72.1"\n`, + })); + spawnSync.mockImplementationOnce(() => ({ + stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`, + })); + spawnSync.mockImplementationOnce(() => ({ + stdout: `- "version": "0.72.0"\n+ "version": "0.72.1"\n`, + })); + spawnSync.mockImplementationOnce(() => ({ + stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`, + })); + spawnSync.mockImplementationOnce(() => ({ + stdout: '\n', + })); + spawnSync.mockImplementationOnce(() => ({ + stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`, + })); + + execMock.mockImplementation(() => ({code: 0})); + + await findAndPublishAllBumpedPackages(); + + expect(execMock.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "npm publish", + Object { + "cwd": "absolute/path/to/package-a", + }, + ], + Array [ + "npm publish", + Object { + "cwd": "absolute/path/to/package-b", + }, + ], + ] + `); + }); }); diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/monorepo/find-and-publish-all-bumped-packages.js index 45f5f33e2077..59549c03c532 100644 --- a/scripts/monorepo/find-and-publish-all-bumped-packages.js +++ b/scripts/monorepo/find-and-publish-all-bumped-packages.js @@ -4,7 +4,9 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * + * @flow strict-local * @format + * @oncall react_native */ const path = require('path'); @@ -17,12 +19,12 @@ const {publishPackage} = require('../npm-utils'); const ROOT_LOCATION = path.join(__dirname, '..', '..'); const NPM_CONFIG_OTP = process.env.NPM_CONFIG_OTP; -const findAndPublishAllBumpedPackages = () => { +async function findAndPublishAllBumpedPackages() { console.log('Traversing all packages inside /packages...'); forEachPackage( (packageAbsolutePath, packageRelativePathFromRoot, packageManifest) => { - if (packageManifest.private) { + if (packageManifest.private === true) { console.log(`\u23ED Skipping private package ${packageManifest.name}`); return; @@ -49,9 +51,9 @@ const findAndPublishAllBumpedPackages = () => { process.exit(1); } - const previousVersionPatternMatches = diff.match( - /- {2}"version": "([0-9]+.[0-9]+.[0-9]+)"/, - ); + const previousVersionPatternMatches = diff + .toString() + .match(/- {2}"version": "([0-9]+.[0-9]+.[0-9]+)"/); if (!previousVersionPatternMatches) { console.log(`\uD83D\uDD0E No version bump for ${packageManifest.name}`); @@ -59,7 +61,7 @@ const findAndPublishAllBumpedPackages = () => { return; } - const {stdout: commitMessage, stderr: commitMessageStderr} = spawnSync( + const {stdout, stderr: commitMessageStderr} = spawnSync( 'git', [ 'log', @@ -70,6 +72,7 @@ const findAndPublishAllBumpedPackages = () => { ], {cwd: ROOT_LOCATION, shell: true, stdio: 'pipe', encoding: 'utf-8'}, ); + const commitMessage = stdout.toString(); if (commitMessageStderr) { console.log( @@ -117,8 +120,13 @@ const findAndPublishAllBumpedPackages = () => { } }, ); +} - process.exit(0); -}; +if (require.main === module) { + // eslint-disable-next-line no-void + void findAndPublishAllBumpedPackages(); +} -findAndPublishAllBumpedPackages(); +module.exports = { + findAndPublishAllBumpedPackages, +}; From d832eb3fbd00f2b6285ff73576bb4f62145f8899 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 12 Feb 2024 04:42:27 -0800 Subject: [PATCH 2/6] Update exit cases for monorepo publish script (#42937) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/42937 Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D53607810 fbshipit-source-id: 18e79f23060ee70e96bd8ac6e9995b0a8ba300b3 # Conflicts: # scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js # scripts/monorepo/find-and-publish-all-bumped-packages.js --- ...nd-and-publish-all-bumped-packages-test.js | 76 +++++++++++++++---- .../find-and-publish-all-bumped-packages.js | 51 +++++-------- 2 files changed, 80 insertions(+), 47 deletions(-) diff --git a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js index 885afbe4a1c9..be4c291b503a 100644 --- a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js +++ b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js @@ -9,25 +9,77 @@ * @oncall react_native */ -const {PUBLISH_PACKAGES_TAG} = require('../constants'); const { findAndPublishAllBumpedPackages, } = require('../find-and-publish-all-bumped-packages'); +const execSync = jest.fn(); const spawnSync = jest.fn(); const forEachPackage = jest.fn(); const execMock = jest.fn(); -jest.mock('child_process', () => ({spawnSync})); +jest.mock('child_process', () => ({execSync, spawnSync})); jest.mock('shelljs', () => ({exec: execMock})); jest.mock('../for-each-package', () => forEachPackage); +const BUMP_COMMIT_MESSAGE = + 'bumped packages versions\n\n#publish-packages-to-npm'; + describe('findAndPublishAllBumpedPackages', () => { beforeEach(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.resetAllMocks(); + }); + + test('should exit with error if not in a Git repo', async () => { + execSync.mockImplementation((command: string) => { + switch (command) { + case 'git log -1 --pretty=%B': + throw new Error(); + } + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await findAndPublishAllBumpedPackages(); + + expect(consoleError.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "Failed to read Git commit message, exiting.", + ], + ] + `); + }); + + test("should exit when commit message does not include '#publish-packages-to-npm'", async () => { + execSync.mockImplementation((command: string) => { + switch (command) { + case 'git log -1 --pretty=%B': + return 'A non-bumping commit'; + } + }); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await findAndPublishAllBumpedPackages(); + + expect(consoleLog.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "Current commit does not include #publish-packages-to-npm keyword, skipping.", + ], + ] + `); }); test('should throw an error if updated version is not 0.x.y', async () => { + execSync.mockImplementation((command: string) => { + switch (command) { + case 'git log -1 --pretty=%B': + return BUMP_COMMIT_MESSAGE; + } + }); const mockedPackageNewVersion = '1.0.0'; forEachPackage.mockImplementationOnce(callback => { @@ -40,16 +92,19 @@ describe('findAndPublishAllBumpedPackages', () => { stdout: `- "version": "0.72.0"\n+ "version": "${mockedPackageNewVersion}"\n`, })); - spawnSync.mockImplementationOnce(() => ({ - stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`, - })); - await expect(findAndPublishAllBumpedPackages()).rejects.toThrow( `Package version expected to be 0.x.y, but received ${mockedPackageNewVersion}`, ); }); test('should publish all changed packages', async () => { + execSync.mockImplementation((command: string) => { + switch (command) { + case 'git log -1 --pretty=%B': + return BUMP_COMMIT_MESSAGE; + } + }); + forEachPackage.mockImplementationOnce(callback => { callback('absolute/path/to/package-a', 'to/package-a', { version: '0.72.1', @@ -65,21 +120,12 @@ describe('findAndPublishAllBumpedPackages', () => { spawnSync.mockImplementationOnce(() => ({ stdout: `- "version": "0.72.0"\n+ "version": "0.72.1"\n`, })); - spawnSync.mockImplementationOnce(() => ({ - stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`, - })); spawnSync.mockImplementationOnce(() => ({ stdout: `- "version": "0.72.0"\n+ "version": "0.72.1"\n`, })); - spawnSync.mockImplementationOnce(() => ({ - stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`, - })); spawnSync.mockImplementationOnce(() => ({ stdout: '\n', })); - spawnSync.mockImplementationOnce(() => ({ - stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`, - })); execMock.mockImplementation(() => ({code: 0})); diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/monorepo/find-and-publish-all-bumped-packages.js index 59549c03c532..96783b9a1f59 100644 --- a/scripts/monorepo/find-and-publish-all-bumped-packages.js +++ b/scripts/monorepo/find-and-publish-all-bumped-packages.js @@ -14,12 +14,30 @@ const {spawnSync} = require('child_process'); const {PUBLISH_PACKAGES_TAG} = require('./constants'); const forEachPackage = require('./for-each-package'); -const {publishPackage} = require('../npm-utils'); +const {execSync, spawnSync} = require('child_process'); +const path = require('path'); const ROOT_LOCATION = path.join(__dirname, '..', '..'); const NPM_CONFIG_OTP = process.env.NPM_CONFIG_OTP; async function findAndPublishAllBumpedPackages() { + let commitMessage; + + try { + commitMessage = execSync('git log -1 --pretty=%B').toString(); + } catch { + console.error('Failed to read Git commit message, exiting.'); + process.exitCode = 1; + return; + } + + if (!commitMessage.includes(PUBLISH_PACKAGES_TAG)) { + console.log( + 'Current commit does not include #publish-packages-to-npm keyword, skipping.', + ); + return; + } + console.log('Traversing all packages inside /packages...'); forEachPackage( @@ -61,37 +79,6 @@ async function findAndPublishAllBumpedPackages() { return; } - const {stdout, stderr: commitMessageStderr} = spawnSync( - 'git', - [ - 'log', - '-n', - '1', - '--format=format:%B', - `${packageRelativePathFromRoot}/package.json`, - ], - {cwd: ROOT_LOCATION, shell: true, stdio: 'pipe', encoding: 'utf-8'}, - ); - const commitMessage = stdout.toString(); - - if (commitMessageStderr) { - console.log( - `\u274c Failed to get latest commit message for ${packageManifest.name}:`, - ); - console.log(commitMessageStderr); - - process.exit(1); - } - - const hasSpecificPublishTag = - commitMessage.includes(PUBLISH_PACKAGES_TAG); - - if (!hasSpecificPublishTag) { - throw new Error( - `Package ${packageManifest.name} was updated, but not through CI script`, - ); - } - const [, previousVersion] = previousVersionPatternMatches; const nextVersion = packageManifest.version; From 7b21ec148a60a60553d5ce5077783c0a2d125335 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 12 Feb 2024 04:42:27 -0800 Subject: [PATCH 3/6] Refactor package discovery in publish script (#42938) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/42938 Substitutes the `forEachPackage` util with a replacement async `getPackages` function. This will be used further in the next diff. The new function aims to be more erganomic/versatile than `forEachPackage` by returning a package mapping (see updated test mock). The API aligns roughly with `yarn workspaces list` and [Lerna's `detectProjects`](https://lerna.js.org/docs/api-reference/utilities#detectprojects). This also aligns with / replaces similar attempts in our existing scripts: - [`getPackagesToPublish`](https://github.com/facebook/react-native/blob/2ca7bec0c2a7d821ceaaf39840a6cdc5eceb8678/scripts/monorepo/get-and-update-packages.js#L56) - [`getPublicPackages`](https://github.com/facebook/react-native/blob/2ca7bec0c2a7d821ceaaf39840a6cdc5eceb8678/scripts/releases/set-version/index.js#L19) Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D53607806 fbshipit-source-id: 00ec34edadab863dc8f2f0c7852f6e835a5dddf5 # Conflicts: # scripts/monorepo.js # scripts/monorepo/find-and-publish-all-bumped-packages.js # scripts/monorepo/for-each-package.js --- ...nd-and-publish-all-bumped-packages-test.js | 48 ++++--- .../find-and-publish-all-bumped-packages.js | 117 +++++++++--------- scripts/monorepo/for-each-package.js | 2 + scripts/releases/utils/monorepo.js | 92 ++++++++++++++ 4 files changed, 181 insertions(+), 78 deletions(-) create mode 100644 scripts/releases/utils/monorepo.js diff --git a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js index be4c291b503a..9a84002df952 100644 --- a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js +++ b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js @@ -13,14 +13,16 @@ const { findAndPublishAllBumpedPackages, } = require('../find-and-publish-all-bumped-packages'); +const getPackagesMock = jest.fn(); const execSync = jest.fn(); const spawnSync = jest.fn(); -const forEachPackage = jest.fn(); const execMock = jest.fn(); jest.mock('child_process', () => ({execSync, spawnSync})); jest.mock('shelljs', () => ({exec: execMock})); -jest.mock('../for-each-package', () => forEachPackage); +jest.mock('../../releases/utils/monorepo', () => ({ + getPackages: getPackagesMock, +})); const BUMP_COMMIT_MESSAGE = 'bumped packages versions\n\n#publish-packages-to-npm'; @@ -81,11 +83,13 @@ describe('findAndPublishAllBumpedPackages', () => { } }); const mockedPackageNewVersion = '1.0.0'; - - forEachPackage.mockImplementationOnce(callback => { - callback('absolute/path/to/package', 'to/package', { - version: mockedPackageNewVersion, - }); + getPackagesMock.mockResolvedValue({ + '@react-native/package-a': { + path: 'absolute/path/to/package-a', + packageJson: { + version: mockedPackageNewVersion, + }, + }, }); spawnSync.mockImplementationOnce(() => ({ @@ -104,17 +108,25 @@ describe('findAndPublishAllBumpedPackages', () => { return BUMP_COMMIT_MESSAGE; } }); - - forEachPackage.mockImplementationOnce(callback => { - callback('absolute/path/to/package-a', 'to/package-a', { - version: '0.72.1', - }); - callback('absolute/path/to/package-b', 'to/package-b', { - version: '0.72.1', - }); - callback('absolute/path/to/package-c', 'to/package-b', { - version: '0.72.0', - }); + getPackagesMock.mockResolvedValue({ + '@react-native/package-a': { + path: 'absolute/path/to/package-a', + packageJson: { + version: '0.72.1', + }, + }, + '@react-native/package-b': { + path: 'absolute/path/to/package-b', + packageJson: { + version: '0.72.1', + }, + }, + '@react-native/package-c': { + path: 'absolute/path/to/package-c', + packageJson: { + version: '0.72.0', + }, + }, }); spawnSync.mockImplementationOnce(() => ({ diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/monorepo/find-and-publish-all-bumped-packages.js index 96783b9a1f59..7b9b95a562be 100644 --- a/scripts/monorepo/find-and-publish-all-bumped-packages.js +++ b/scripts/monorepo/find-and-publish-all-bumped-packages.js @@ -9,11 +9,9 @@ * @oncall react_native */ -const path = require('path'); -const {spawnSync} = require('child_process'); - +const {publishPackage} = require('../npm-utils'); +const {getPackages} = require('../releases/utils/monorepo'); const {PUBLISH_PACKAGES_TAG} = require('./constants'); -const forEachPackage = require('./for-each-package'); const {execSync, spawnSync} = require('child_process'); const path = require('path'); @@ -40,73 +38,72 @@ async function findAndPublishAllBumpedPackages() { console.log('Traversing all packages inside /packages...'); - forEachPackage( - (packageAbsolutePath, packageRelativePathFromRoot, packageManifest) => { - if (packageManifest.private === true) { - console.log(`\u23ED Skipping private package ${packageManifest.name}`); - - return; - } - - const {stdout: diff, stderr: commitDiffStderr} = spawnSync( - 'git', - [ - 'log', - '-p', - '--format=""', - 'HEAD~1..HEAD', - `${packageRelativePathFromRoot}/package.json`, - ], - {cwd: ROOT_LOCATION, shell: true, stdio: 'pipe', encoding: 'utf-8'}, + const packages = await getPackages({ + includeReactNative: false, + }); + + for (const package of Object.values(packages)) { + const {stdout: diff, stderr: commitDiffStderr} = spawnSync( + 'git', + [ + 'log', + '-p', + '--format=""', + 'HEAD~1..HEAD', + `${package.path}/package.json`, + ], + {cwd: ROOT_LOCATION, shell: true, stdio: 'pipe', encoding: 'utf-8'}, + ); + + if (commitDiffStderr) { + console.log( + `\u274c Failed to get latest committed changes for ${package.name}:`, ); + console.log(commitDiffStderr); + + process.exit(1); + } - if (commitDiffStderr) { - console.log( - `\u274c Failed to get latest committed changes for ${packageManifest.name}:`, - ); - console.log(commitDiffStderr); + const previousVersionPatternMatches = diff + .toString() + .match(/- {2}"version": "([0-9]+.[0-9]+.[0-9]+)"/); - process.exit(1); - } + if (!previousVersionPatternMatches) { + console.log(`\uD83D\uDD0E No version bump for ${package.name}`); - const previousVersionPatternMatches = diff - .toString() - .match(/- {2}"version": "([0-9]+.[0-9]+.[0-9]+)"/); + return; + } - if (!previousVersionPatternMatches) { - console.log(`\uD83D\uDD0E No version bump for ${packageManifest.name}`); + const [, previousVersion] = previousVersionPatternMatches; + const nextVersion = package.packageJson.version; - return; - } + console.log( + `\uD83D\uDCA1 ${package.name} was updated: ${previousVersion} -> ${nextVersion}`, + ); - const [, previousVersion] = previousVersionPatternMatches; - const nextVersion = packageManifest.version; + if (!nextVersion.startsWith('0.')) { + throw new Error( + `Package version expected to be 0.x.y, but received ${nextVersion}`, + ); + } + const result = publishPackage(package.path, { + tags, + otp: NPM_CONFIG_OTP, + }); + if (result.code !== 0) { console.log( - `\uD83D\uDCA1 ${packageManifest.name} was updated: ${previousVersion} -> ${nextVersion}`, + `\u274c Failed to publish version ${nextVersion} of ${package.name}. npm publish exited with code ${result.code}:`, ); + console.log(result.stderr); - if (!nextVersion.startsWith('0.')) { - throw new Error( - `Package version expected to be 0.x.y, but received ${nextVersion}`, - ); - } - - const result = publishPackage(packageAbsolutePath, {otp: NPM_CONFIG_OTP}); - if (result.code !== 0) { - console.log( - `\u274c Failed to publish version ${nextVersion} of ${packageManifest.name}. npm publish exited with code ${result.code}:`, - ); - console.log(result.stderr); - - process.exit(1); - } else { - console.log( - `\u2705 Successfully published new version of ${packageManifest.name}`, - ); - } - }, - ); + process.exit(1); + } else { + console.log( + `\u2705 Successfully published new version of ${package.name}`, + ); + } + } } if (require.main === module) { diff --git a/scripts/monorepo/for-each-package.js b/scripts/monorepo/for-each-package.js index f3d865dfdacf..402cf9235ebd 100644 --- a/scripts/monorepo/for-each-package.js +++ b/scripts/monorepo/for-each-package.js @@ -38,6 +38,8 @@ const getDirectories = source => * * @param {forEachPackageCallback} callback The callback which will be called for each package * @param {{includeReactNative: (boolean|undefined)}} [options={}] description + * + * @deprecated Use scripts/releases/utils/monorepo.js#getPackages instead */ const forEachPackage = (callback, options = DEFAULT_OPTIONS) => { const {includeReactNative} = options; diff --git a/scripts/releases/utils/monorepo.js b/scripts/releases/utils/monorepo.js new file mode 100644 index 000000000000..e33f0bc82d83 --- /dev/null +++ b/scripts/releases/utils/monorepo.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + * @oncall react_native + */ + +const fs = require('fs'); +const glob = require('glob'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '../../..'); +const WORKSPACES_CONFIG = 'packages/*'; + +/*:: +export type PackageJson = { + name: string, + private?: boolean, + version: string, + dependencies: Record, + devDependencies: Record, + ... +}; + +type PackagesFilter = $ReadOnly<{ + includeReactNative: boolean, + includePrivate?: boolean, +}>; + +type ProjectInfo = { + [packageName: string]: { + // The name of the package + name: string, + + // The absolute path to the package + path: string, + + // The parsed package.json contents + packageJson: PackageJson, + }, +}; +*/ + +/** + * Locates monrepo packages and returns a mapping of package names to their + * metadata. Considers Yarn workspaces under `packages/`. + */ +async function getPackages( + filter /*: PackagesFilter */, +) /*: Promise */ { + const {includeReactNative, includePrivate = false} = filter; + + const packagesEntries = await Promise.all( + glob + .sync(`${WORKSPACES_CONFIG}/package.json`, { + cwd: REPO_ROOT, + absolute: true, + ignore: includeReactNative + ? [] + : ['packages/react-native/package.json'], + }) + .map(async packageJsonPath => { + const packagePath = path.dirname(packageJsonPath); + const packageJson = JSON.parse( + await fs.promises.readFile(packageJsonPath, 'utf-8'), + ); + + return [ + packageJson.name, + { + name: packageJson.name, + path: packagePath, + packageJson, + }, + ]; + }), + ); + + return Object.fromEntries( + packagesEntries.filter( + ([_, {packageJson}]) => !packageJson.private || includePrivate, + ), + ); +} + +module.exports = { + getPackages, +}; From 3152a4df7300d17368762f2b5fac057ecc1d9c8b Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 12 Feb 2024 10:43:48 -0800 Subject: [PATCH 4/6] Use npm as source of truth for updated packages (make publish script rerunnable) (#42944) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/42944 Updates `find-and-publish-all-bumped-packages` to use the npm registry as the source of truth, similar to tools like Lerna (`lerna publish from-package`). **This enables safe reruns of the publish script**, and replaces the previous Git-diff-detection implementation. Changelog: [Internal] Reviewed By: lunaleaps Differential Revision: D53607807 fbshipit-source-id: 135808b7ce36cf463c9f53a8059500b83f8b6679 # Conflicts: # scripts/monorepo/find-and-publish-all-bumped-packages.js --- ...nd-and-publish-all-bumped-packages-test.js | 49 +++++++---- .../find-and-publish-all-bumped-packages.js | 86 ++++++++----------- 2 files changed, 68 insertions(+), 67 deletions(-) diff --git a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js index 9a84002df952..d3e6d437804b 100644 --- a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js +++ b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js @@ -15,14 +15,16 @@ const { const getPackagesMock = jest.fn(); const execSync = jest.fn(); -const spawnSync = jest.fn(); const execMock = jest.fn(); +const fetchMock = jest.fn(); -jest.mock('child_process', () => ({execSync, spawnSync})); +jest.mock('child_process', () => ({execSync})); jest.mock('shelljs', () => ({exec: execMock})); jest.mock('../../releases/utils/monorepo', () => ({ getPackages: getPackagesMock, })); +// $FlowIgnore[cannot-write] +global.fetch = fetchMock; const BUMP_COMMIT_MESSAGE = 'bumped packages versions\n\n#publish-packages-to-npm'; @@ -75,7 +77,7 @@ describe('findAndPublishAllBumpedPackages', () => { `); }); - test('should throw an error if updated version is not 0.x.y', async () => { + test('should throw an error if updated version is not 0.x.x', async () => { execSync.mockImplementation((command: string) => { switch (command) { case 'git log -1 --pretty=%B': @@ -92,16 +94,16 @@ describe('findAndPublishAllBumpedPackages', () => { }, }); - spawnSync.mockImplementationOnce(() => ({ - stdout: `- "version": "0.72.0"\n+ "version": "${mockedPackageNewVersion}"\n`, - })); + fetchMock.mockResolvedValueOnce({ + json: () => Promise.resolve({versions: {}}), + }); await expect(findAndPublishAllBumpedPackages()).rejects.toThrow( - `Package version expected to be 0.x.y, but received ${mockedPackageNewVersion}`, + `Package version expected to be 0.x.x, but received ${mockedPackageNewVersion}`, ); }); - test('should publish all changed packages', async () => { + test('should publish all updated packages', async () => { execSync.mockImplementation((command: string) => { switch (command) { case 'git log -1 --pretty=%B': @@ -110,39 +112,48 @@ describe('findAndPublishAllBumpedPackages', () => { }); getPackagesMock.mockResolvedValue({ '@react-native/package-a': { + name: '@react-native/package-a', path: 'absolute/path/to/package-a', packageJson: { version: '0.72.1', }, }, '@react-native/package-b': { + name: '@react-native/package-b', path: 'absolute/path/to/package-b', packageJson: { version: '0.72.1', }, }, '@react-native/package-c': { + name: '@react-native/package-c', path: 'absolute/path/to/package-c', packageJson: { version: '0.72.0', }, }, }); - - spawnSync.mockImplementationOnce(() => ({ - stdout: `- "version": "0.72.0"\n+ "version": "0.72.1"\n`, - })); - spawnSync.mockImplementationOnce(() => ({ - stdout: `- "version": "0.72.0"\n+ "version": "0.72.1"\n`, - })); - spawnSync.mockImplementationOnce(() => ({ - stdout: '\n', - })); - + fetchMock.mockResolvedValue({ + json: () => + Promise.resolve({ + versions: {'0.72.0': {}}, + }), + }); execMock.mockImplementation(() => ({code: 0})); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + await findAndPublishAllBumpedPackages(); + expect(consoleLog.mock.calls.flat().join('\n')).toMatchInlineSnapshot(` + "Discovering updated packages + - Skipping @react-native/package-c (0.72.0 already present on npm) + Done ✅ + Publishing updated packages to npm + - Publishing @react-native/package-a (0.72.1) + - Publishing @react-native/package-b (0.72.1) + Done ✅" + `); expect(execMock.mock.calls).toMatchInlineSnapshot(` Array [ Array [ diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/monorepo/find-and-publish-all-bumped-packages.js index 7b9b95a562be..52e69b89cc53 100644 --- a/scripts/monorepo/find-and-publish-all-bumped-packages.js +++ b/scripts/monorepo/find-and-publish-all-bumped-packages.js @@ -12,10 +12,8 @@ const {publishPackage} = require('../npm-utils'); const {getPackages} = require('../releases/utils/monorepo'); const {PUBLISH_PACKAGES_TAG} = require('./constants'); -const {execSync, spawnSync} = require('child_process'); -const path = require('path'); +const {execSync} = require('child_process'); -const ROOT_LOCATION = path.join(__dirname, '..', '..'); const NPM_CONFIG_OTP = process.env.NPM_CONFIG_OTP; async function findAndPublishAllBumpedPackages() { @@ -36,74 +34,66 @@ async function findAndPublishAllBumpedPackages() { return; } - console.log('Traversing all packages inside /packages...'); + console.log('Discovering updated packages'); const packages = await getPackages({ includeReactNative: false, }); + const packagesToUpdate = []; - for (const package of Object.values(packages)) { - const {stdout: diff, stderr: commitDiffStderr} = spawnSync( - 'git', - [ - 'log', - '-p', - '--format=""', - 'HEAD~1..HEAD', - `${package.path}/package.json`, - ], - {cwd: ROOT_LOCATION, shell: true, stdio: 'pipe', encoding: 'utf-8'}, - ); + await Promise.all( + Object.values(packages).map(async package => { + const version = package.packageJson.version; + + if (!version.startsWith('0.')) { + throw new Error( + `Package version expected to be 0.x.x, but received ${version}`, + ); + } - if (commitDiffStderr) { - console.log( - `\u274c Failed to get latest committed changes for ${package.name}:`, + const response = await fetch( + 'https://registry.npmjs.org/' + package.name, ); - console.log(commitDiffStderr); + const {versions: versionsInRegistry} = await response.json(); - process.exit(1); - } + if (version in versionsInRegistry) { + console.log( + `- Skipping ${package.name} (${version} already present on npm)`, + ); + return; + } - const previousVersionPatternMatches = diff - .toString() - .match(/- {2}"version": "([0-9]+.[0-9]+.[0-9]+)"/); + packagesToUpdate.push(package.name); + }), + ); - if (!previousVersionPatternMatches) { - console.log(`\uD83D\uDD0E No version bump for ${package.name}`); + console.log('Done ✅'); + console.log('Publishing updated packages to npm'); - return; - } - - const [, previousVersion] = previousVersionPatternMatches; - const nextVersion = package.packageJson.version; + const tags = getTagsFromCommitMessage(commitMessage); + for (const packageName of packagesToUpdate) { + const package = packages[packageName]; console.log( - `\uD83D\uDCA1 ${package.name} was updated: ${previousVersion} -> ${nextVersion}`, + `- Publishing ${package.name} (${package.packageJson.version})`, ); - if (!nextVersion.startsWith('0.')) { - throw new Error( - `Package version expected to be 0.x.y, but received ${nextVersion}`, - ); - } - const result = publishPackage(package.path, { tags, otp: NPM_CONFIG_OTP, }); - if (result.code !== 0) { - console.log( - `\u274c Failed to publish version ${nextVersion} of ${package.name}. npm publish exited with code ${result.code}:`, - ); - console.log(result.stderr); - process.exit(1); - } else { - console.log( - `\u2705 Successfully published new version of ${package.name}`, + if (result.code !== 0) { + console.error( + `Failed to publish ${package.name}. npm publish exited with code ${result.code}:`, ); + console.error(result.stderr); + process.exitCode = 1; + return; } } + + console.log('Done ✅'); } if (require.main === module) { From 613b197ebdd9a10d432ee3db8dbb925686d8e0b2 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 12 Feb 2024 10:43:48 -0800 Subject: [PATCH 5/6] Add retry to monorepo publish script (#42964) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/42964 We've seen npm publishes fail occasionally in CI as part of this script, most recently in S391653. This change adds a single retry, per package, during the execution of this script, in an attempt to reduce the chance of manual interventions after a broken pipeline. Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D53607808 fbshipit-source-id: 526d9c33d51ec57702efba3c199bad313c1bf2d4 # Conflicts: # scripts/monorepo/find-and-publish-all-bumped-packages.js --- ...nd-and-publish-all-bumped-packages-test.js | 92 +++++++++++++++++++ .../find-and-publish-all-bumped-packages.js | 46 +++++++--- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js index d3e6d437804b..0a8780e7d0ca 100644 --- a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js +++ b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js @@ -171,4 +171,96 @@ describe('findAndPublishAllBumpedPackages', () => { ] `); }); + + describe('retry behaviour', () => { + beforeEach(() => { + execSync.mockImplementation((command: string) => { + switch (command) { + case 'git log -1 --pretty=%B': + return BUMP_COMMIT_MESSAGE; + } + }); + getPackagesMock.mockResolvedValue({ + '@react-native/package-a': { + name: '@react-native/package-a', + path: 'absolute/path/to/package-a', + packageJson: { + version: '0.72.1', + }, + }, + '@react-native/package-b': { + name: '@react-native/package-b', + path: 'absolute/path/to/package-b', + packageJson: { + version: '0.72.1', + }, + }, + }); + fetchMock.mockResolvedValue({ + json: () => + Promise.resolve({ + versions: {'0.72.0': {}}, + }), + }); + }); + + test('should retry once if `npm publish` fails', async () => { + execMock.mockImplementationOnce(() => ({code: 0})); + execMock.mockImplementationOnce(() => ({ + code: 1, + stderr: '503 Service Unavailable', + })); + execMock.mockImplementationOnce(() => ({code: 0})); + + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await findAndPublishAllBumpedPackages(); + + expect(consoleError.mock.calls.flat().join('\n')).toMatchInlineSnapshot(` + "Failed to publish @react-native/package-b. npm publish exited with code 1: + 503 Service Unavailable" + `); + expect(execMock.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "npm publish", + Object { + "cwd": "absolute/path/to/package-a", + }, + ], + Array [ + "npm publish", + Object { + "cwd": "absolute/path/to/package-b", + }, + ], + Array [ + "npm publish", + Object { + "cwd": "absolute/path/to/package-b", + }, + ], + ] + `); + }); + + test('should exit with error if one or more packages fail after retry', async () => { + execMock.mockImplementationOnce(() => ({code: 0})); + execMock.mockImplementation(() => ({ + code: 1, + stderr: '503 Service Unavailable', + })); + + const consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => {}); + + await findAndPublishAllBumpedPackages(); + + expect(consoleLog).toHaveBeenLastCalledWith('--- Retrying once! ---'); + expect(process.exitCode).toBe(1); + }); + }); }); diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/monorepo/find-and-publish-all-bumped-packages.js index 52e69b89cc53..3eb39211f302 100644 --- a/scripts/monorepo/find-and-publish-all-bumped-packages.js +++ b/scripts/monorepo/find-and-publish-all-bumped-packages.js @@ -71,6 +71,7 @@ async function findAndPublishAllBumpedPackages() { console.log('Publishing updated packages to npm'); const tags = getTagsFromCommitMessage(commitMessage); + const failedPackages = []; for (const packageName of packagesToUpdate) { const package = packages[packageName]; @@ -78,24 +79,45 @@ async function findAndPublishAllBumpedPackages() { `- Publishing ${package.name} (${package.packageJson.version})`, ); - const result = publishPackage(package.path, { - tags, - otp: NPM_CONFIG_OTP, - }); - - if (result.code !== 0) { - console.error( - `Failed to publish ${package.name}. npm publish exited with code ${result.code}:`, - ); - console.error(result.stderr); - process.exitCode = 1; - return; + try { + runPublish(package.name, package.path, tags); + } catch { + console.log('--- Retrying once! ---'); + try { + runPublish(package.name, package.path, tags); + } catch (e) { + failedPackages.push(package.name); + } } } + if (failedPackages.length) { + process.exitCode = 1; + return; + } + console.log('Done ✅'); } +function runPublish( + packageName /*: string */, + packagePath /*: string */, + tags /*: Array */, +) { + const result = publishPackage(packagePath, { + tags, + otp: NPM_CONFIG_OTP, + }); + + if (result.code !== 0) { + console.error( + `Failed to publish ${packageName}. npm publish exited with code ${result.code}:`, + ); + console.error(result.stderr); + throw new Error(result.stderr); + } +} + if (require.main === module) { // eslint-disable-next-line no-void void findAndPublishAllBumpedPackages(); From 15501fe0d284335ff23b9ef38439ec475b639819 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Tue, 13 Feb 2024 02:41:58 -0800 Subject: [PATCH 6/6] Rename and document monorepo publish script (#42989) Summary: Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D53607805 fbshipit-source-id: babe9bbd7fb5e7016b567193727c6a441bee60a3 # Conflicts: # scripts/__tests__/publish-updated-packages-test.js # scripts/publish-updated-packages.js # scripts/releases-ci/README.md --- .circleci/configurations/jobs.yml | 2 +- scripts/releases-ci/README.md | 11 +++++++ .../publish-updated-packages-test.js} | 18 +++++----- .../publish-updated-packages.js} | 33 ++++++++++++++++--- 4 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 scripts/releases-ci/README.md rename scripts/{monorepo/__tests__/find-and-publish-all-bumped-packages-test.js => releases-ci/__tests__/publish-updated-packages-test.js} (93%) rename scripts/{monorepo/find-and-publish-all-bumped-packages.js => releases-ci/publish-updated-packages.js} (82%) diff --git a/.circleci/configurations/jobs.yml b/.circleci/configurations/jobs.yml index d21c54aee889..42a0e0c18378 100644 --- a/.circleci/configurations/jobs.yml +++ b/.circleci/configurations/jobs.yml @@ -1321,4 +1321,4 @@ jobs: command: echo "//registry.npmjs.org/:_authToken=${CIRCLE_NPM_TOKEN}" > ~/.npmrc - run: name: Find and publish all bumped packages - command: node ./scripts/monorepo/find-and-publish-all-bumped-packages.js + command: node ./scripts/releases-ci/publish-updated-packages.js diff --git a/scripts/releases-ci/README.md b/scripts/releases-ci/README.md new file mode 100644 index 000000000000..8c6ca38d0021 --- /dev/null +++ b/scripts/releases-ci/README.md @@ -0,0 +1,11 @@ +# scripts/releases-ci + +CI-only release scripts — intended to run from a CI workflow (CircleCI or GitHub Actions). + +## Commands + +For information on command arguments, run `node --help`. + +### `publish-updated-packages` + +Publishes all updated packages (excluding `react-native`) to npm. Triggered when a commit on a release branch contains `#publish-packages-to-npm`. diff --git a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js b/scripts/releases-ci/__tests__/publish-updated-packages-test.js similarity index 93% rename from scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js rename to scripts/releases-ci/__tests__/publish-updated-packages-test.js index 0a8780e7d0ca..68c82d3d1dc9 100644 --- a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js +++ b/scripts/releases-ci/__tests__/publish-updated-packages-test.js @@ -9,9 +9,7 @@ * @oncall react_native */ -const { - findAndPublishAllBumpedPackages, -} = require('../find-and-publish-all-bumped-packages'); +const {publishUpdatedPackages} = require('../publish-updated-packages'); const getPackagesMock = jest.fn(); const execSync = jest.fn(); @@ -29,7 +27,7 @@ global.fetch = fetchMock; const BUMP_COMMIT_MESSAGE = 'bumped packages versions\n\n#publish-packages-to-npm'; -describe('findAndPublishAllBumpedPackages', () => { +describe('publishUpdatedPackages', () => { beforeEach(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); jest.resetAllMocks(); @@ -46,7 +44,7 @@ describe('findAndPublishAllBumpedPackages', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await findAndPublishAllBumpedPackages(); + await publishUpdatedPackages(); expect(consoleError.mock.calls).toMatchInlineSnapshot(` Array [ @@ -66,7 +64,7 @@ describe('findAndPublishAllBumpedPackages', () => { }); const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); - await findAndPublishAllBumpedPackages(); + await publishUpdatedPackages(); expect(consoleLog.mock.calls).toMatchInlineSnapshot(` Array [ @@ -98,7 +96,7 @@ describe('findAndPublishAllBumpedPackages', () => { json: () => Promise.resolve({versions: {}}), }); - await expect(findAndPublishAllBumpedPackages()).rejects.toThrow( + await expect(publishUpdatedPackages()).rejects.toThrow( `Package version expected to be 0.x.x, but received ${mockedPackageNewVersion}`, ); }); @@ -143,7 +141,7 @@ describe('findAndPublishAllBumpedPackages', () => { const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); - await findAndPublishAllBumpedPackages(); + await publishUpdatedPackages(); expect(consoleLog.mock.calls.flat().join('\n')).toMatchInlineSnapshot(` "Discovering updated packages @@ -216,7 +214,7 @@ describe('findAndPublishAllBumpedPackages', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await findAndPublishAllBumpedPackages(); + await publishUpdatedPackages(); expect(consoleError.mock.calls.flat().join('\n')).toMatchInlineSnapshot(` "Failed to publish @react-native/package-b. npm publish exited with code 1: @@ -257,7 +255,7 @@ describe('findAndPublishAllBumpedPackages', () => { .spyOn(console, 'log') .mockImplementation(() => {}); - await findAndPublishAllBumpedPackages(); + await publishUpdatedPackages(); expect(consoleLog).toHaveBeenLastCalledWith('--- Retrying once! ---'); expect(process.exitCode).toBe(1); diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/releases-ci/publish-updated-packages.js similarity index 82% rename from scripts/monorepo/find-and-publish-all-bumped-packages.js rename to scripts/releases-ci/publish-updated-packages.js index 3eb39211f302..57580df64b4f 100644 --- a/scripts/monorepo/find-and-publish-all-bumped-packages.js +++ b/scripts/releases-ci/publish-updated-packages.js @@ -9,14 +9,39 @@ * @oncall react_native */ +const {PUBLISH_PACKAGES_TAG} = require('../monorepo/constants'); const {publishPackage} = require('../npm-utils'); const {getPackages} = require('../releases/utils/monorepo'); -const {PUBLISH_PACKAGES_TAG} = require('./constants'); +const {parseArgs} = require('@pkgjs/parseargs'); const {execSync} = require('child_process'); const NPM_CONFIG_OTP = process.env.NPM_CONFIG_OTP; -async function findAndPublishAllBumpedPackages() { +const config = { + options: { + help: {type: 'boolean'}, + }, +}; + +async function main() { + const { + values: {help}, + } = parseArgs(config); + + if (help) { + console.log(` + Usage: node ./scripts/releases/publish-updated-packages.js + + Publishes all updated packages (excluding react-native) to npm. This script + is intended to run from a CI workflow. + `); + return; + } + + await publishUpdatedPackages(); +} + +async function publishUpdatedPackages() { let commitMessage; try { @@ -120,9 +145,9 @@ function runPublish( if (require.main === module) { // eslint-disable-next-line no-void - void findAndPublishAllBumpedPackages(); + void main(); } module.exports = { - findAndPublishAllBumpedPackages, + publishUpdatedPackages, };