diff --git a/.circleci/configurations/jobs.yml b/.circleci/configurations/jobs.yml index d21c54aee889c7..42a0e0c183785b 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/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js b/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js deleted file mode 100644 index 7ef3f9ca0de7e4..00000000000000 --- a/scripts/monorepo/__tests__/find-and-publish-all-bumped-packages-test.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * 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. - * - * @format - */ - -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'); - -jest.mock('child_process', () => ({spawnSync: jest.fn()})); -jest.mock('../for-each-package', () => jest.fn()); - -describe('findAndPublishAllBumpedPackages', () => { - it('throws an error if updated version is not 0.x.y', () => { - const mockedPackageNewVersion = '1.0.0'; - - forEachPackage.mockImplementationOnce(callback => { - callback('absolute/path/to/package', 'to/package', { - version: mockedPackageNewVersion, - }); - }); - - spawnSync.mockImplementationOnce(() => ({ - stdout: `- "version": "0.72.0"\n+ "version": "${mockedPackageNewVersion}"\n`, - })); - - spawnSync.mockImplementationOnce(() => ({ - stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`, - })); - - expect(() => findAndPublishAllBumpedPackages()).toThrow( - `Package version expected to be 0.x.y, but received ${mockedPackageNewVersion}`, - ); - }); -}); diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/monorepo/find-and-publish-all-bumped-packages.js deleted file mode 100644 index 45f5f33e207766..00000000000000 --- a/scripts/monorepo/find-and-publish-all-bumped-packages.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 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. - * - * @format - */ - -const path = require('path'); -const {spawnSync} = require('child_process'); - -const {PUBLISH_PACKAGES_TAG} = require('./constants'); -const forEachPackage = require('./for-each-package'); -const {publishPackage} = require('../npm-utils'); - -const ROOT_LOCATION = path.join(__dirname, '..', '..'); -const NPM_CONFIG_OTP = process.env.NPM_CONFIG_OTP; - -const findAndPublishAllBumpedPackages = () => { - console.log('Traversing all packages inside /packages...'); - - forEachPackage( - (packageAbsolutePath, packageRelativePathFromRoot, packageManifest) => { - if (packageManifest.private) { - 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'}, - ); - - if (commitDiffStderr) { - console.log( - `\u274c Failed to get latest committed changes for ${packageManifest.name}:`, - ); - console.log(commitDiffStderr); - - process.exit(1); - } - - const previousVersionPatternMatches = diff.match( - /- {2}"version": "([0-9]+.[0-9]+.[0-9]+)"/, - ); - - if (!previousVersionPatternMatches) { - console.log(`\uD83D\uDD0E No version bump for ${packageManifest.name}`); - - return; - } - - const {stdout: commitMessage, stderr: commitMessageStderr} = spawnSync( - 'git', - [ - 'log', - '-n', - '1', - '--format=format:%B', - `${packageRelativePathFromRoot}/package.json`, - ], - {cwd: ROOT_LOCATION, shell: true, stdio: 'pipe', encoding: 'utf-8'}, - ); - - 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; - - console.log( - `\uD83D\uDCA1 ${packageManifest.name} was updated: ${previousVersion} -> ${nextVersion}`, - ); - - 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(0); -}; - -findAndPublishAllBumpedPackages(); diff --git a/scripts/monorepo/for-each-package.js b/scripts/monorepo/for-each-package.js index f3d865dfdacffd..402cf9235ebd05 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-ci/README.md b/scripts/releases-ci/README.md new file mode 100644 index 00000000000000..8c6ca38d002115 --- /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/releases-ci/__tests__/publish-updated-packages-test.js b/scripts/releases-ci/__tests__/publish-updated-packages-test.js new file mode 100644 index 00000000000000..68c82d3d1dc978 --- /dev/null +++ b/scripts/releases-ci/__tests__/publish-updated-packages-test.js @@ -0,0 +1,264 @@ +/** + * 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-local + * @format + * @oncall react_native + */ + +const {publishUpdatedPackages} = require('../publish-updated-packages'); + +const getPackagesMock = jest.fn(); +const execSync = jest.fn(); +const execMock = jest.fn(); +const fetchMock = jest.fn(); + +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'; + +describe('publishUpdatedPackages', () => { + 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 publishUpdatedPackages(); + + 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 publishUpdatedPackages(); + + 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.x', async () => { + execSync.mockImplementation((command: string) => { + switch (command) { + case 'git log -1 --pretty=%B': + return BUMP_COMMIT_MESSAGE; + } + }); + const mockedPackageNewVersion = '1.0.0'; + getPackagesMock.mockResolvedValue({ + '@react-native/package-a': { + path: 'absolute/path/to/package-a', + packageJson: { + version: mockedPackageNewVersion, + }, + }, + }); + + fetchMock.mockResolvedValueOnce({ + json: () => Promise.resolve({versions: {}}), + }); + + await expect(publishUpdatedPackages()).rejects.toThrow( + `Package version expected to be 0.x.x, but received ${mockedPackageNewVersion}`, + ); + }); + + test('should publish all updated packages', async () => { + 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', + }, + }, + '@react-native/package-c': { + name: '@react-native/package-c', + path: 'absolute/path/to/package-c', + packageJson: { + version: '0.72.0', + }, + }, + }); + fetchMock.mockResolvedValue({ + json: () => + Promise.resolve({ + versions: {'0.72.0': {}}, + }), + }); + execMock.mockImplementation(() => ({code: 0})); + + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await publishUpdatedPackages(); + + 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 [ + "npm publish", + Object { + "cwd": "absolute/path/to/package-a", + }, + ], + Array [ + "npm publish", + Object { + "cwd": "absolute/path/to/package-b", + }, + ], + ] + `); + }); + + 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 publishUpdatedPackages(); + + 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 publishUpdatedPackages(); + + expect(consoleLog).toHaveBeenLastCalledWith('--- Retrying once! ---'); + expect(process.exitCode).toBe(1); + }); + }); +}); diff --git a/scripts/releases-ci/publish-updated-packages.js b/scripts/releases-ci/publish-updated-packages.js new file mode 100644 index 00000000000000..57580df64b4fc6 --- /dev/null +++ b/scripts/releases-ci/publish-updated-packages.js @@ -0,0 +1,153 @@ +/** + * 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-local + * @format + * @oncall react_native + */ + +const {PUBLISH_PACKAGES_TAG} = require('../monorepo/constants'); +const {publishPackage} = require('../npm-utils'); +const {getPackages} = require('../releases/utils/monorepo'); +const {parseArgs} = require('@pkgjs/parseargs'); +const {execSync} = require('child_process'); + +const NPM_CONFIG_OTP = process.env.NPM_CONFIG_OTP; + +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 { + 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('Discovering updated packages'); + + const packages = await getPackages({ + includeReactNative: false, + }); + const packagesToUpdate = []; + + 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}`, + ); + } + + const response = await fetch( + 'https://registry.npmjs.org/' + package.name, + ); + const {versions: versionsInRegistry} = await response.json(); + + if (version in versionsInRegistry) { + console.log( + `- Skipping ${package.name} (${version} already present on npm)`, + ); + return; + } + + packagesToUpdate.push(package.name); + }), + ); + + console.log('Done ✅'); + console.log('Publishing updated packages to npm'); + + const tags = getTagsFromCommitMessage(commitMessage); + const failedPackages = []; + + for (const packageName of packagesToUpdate) { + const package = packages[packageName]; + console.log( + `- Publishing ${package.name} (${package.packageJson.version})`, + ); + + 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 main(); +} + +module.exports = { + publishUpdatedPackages, +}; diff --git a/scripts/releases/utils/monorepo.js b/scripts/releases/utils/monorepo.js new file mode 100644 index 00000000000000..e33f0bc82d83fd --- /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, +};