From 36ba51c66c65a1d017d86ae799c33c74aab3488e Mon Sep 17 00:00:00 2001
From: Alex Hunt <huntie@meta.com>
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 <huntie@meta.com>
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 <huntie@meta.com>
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<string, string>,
+  devDependencies: Record<string, string>,
+  ...
+};
+
+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<ProjectInfo> */ {
+  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 <huntie@meta.com>
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 <huntie@meta.com>
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<string> */,
+) {
+  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 <huntie@meta.com>
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 <command> --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,
 };
