From 0ab9a0975ead958f0fc05e37839ab5578360c2b8 Mon Sep 17 00:00:00 2001
From: Alex Hunt <huntie@meta.com>
Date: Fri, 9 Feb 2024 05:41:42 -0800
Subject: [PATCH 1/3] [skip ci] Add Flow, add positive test case for monorepo
 publish step (#42936)

Summary:

Changelog: [Internal]

Reviewed By: cipolleschi

Differential Revision: D53607809
---
 ...nd-and-publish-all-bumped-packages-test.js | 73 +++++++++++++++++--
 .../find-and-publish-all-bumped-packages.js   | 24 +++---
 2 files changed, 79 insertions(+), 18 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 15c4c683d416..1ec4e6ca4e14 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,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 {PUBLISH_PACKAGES_TAG} = require('../constants');
@@ -12,18 +14,21 @@ const {
   findAndPublishAllBumpedPackages,
   getTagsFromCommitMessage,
 } = require('../find-and-publish-all-bumped-packages');
-const forEachPackage = require('../for-each-package');
-const {spawnSync} = require('child_process');
 
-jest.mock('child_process', () => ({spawnSync: jest.fn()}));
-jest.mock('../for-each-package', () => jest.fn());
+const spawnSync = jest.fn();
+const forEachPackage = jest.fn();
+const execMock = jest.fn();
+
+jest.mock('child_process', () => ({spawnSync}));
+jest.mock('shelljs', () => ({exec: execMock}));
+jest.mock('../for-each-package', () => forEachPackage);
 
 describe('findAndPublishAllBumpedPackages', () => {
   beforeEach(() => {
-    // Silence logs.
     jest.spyOn(console, 'log').mockImplementation(() => {});
   });
-  it('throws an error if updated version is not 0.x.y', () => {
+
+  test('should throw an error if updated version is not 0.x.y', async () => {
     const mockedPackageNewVersion = '1.0.0';
 
     forEachPackage.mockImplementationOnce(callback => {
@@ -40,10 +45,64 @@ 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",
+          },
+        ],
+      ]
+    `);
+  });
 });
 
 describe('getTagsFromCommitMessage', () => {
diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/monorepo/find-and-publish-all-bumped-packages.js
index 5cec500e4624..02d29493c9a3 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 {publishPackage} = require('../npm-utils');
@@ -16,7 +18,7 @@ const path = require('path');
 const ROOT_LOCATION = path.join(__dirname, '..', '..');
 const NPM_CONFIG_OTP = process.env.NPM_CONFIG_OTP;
 
-function getTagsFromCommitMessage(msg) {
+function getTagsFromCommitMessage(msg /*: string */) /*: Array<string> */ {
   // ex message we're trying to parse tags out of
   // `_some_message_here_${PUBLISH_PACKAGES_TAG}&tagA&tagB\n`;
   return msg
@@ -26,12 +28,12 @@ function getTagsFromCommitMessage(msg) {
     .slice(1);
 }
 
-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;
@@ -58,9 +60,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}`);
@@ -68,7 +70,7 @@ const findAndPublishAllBumpedPackages = () => {
         return;
       }
 
-      const {stdout: commitMessage, stderr: commitMessageStderr} = spawnSync(
+      const {stdout, stderr: commitMessageStderr} = spawnSync(
         'git',
         [
           'log',
@@ -79,6 +81,7 @@ const findAndPublishAllBumpedPackages = () => {
         ],
         {cwd: ROOT_LOCATION, shell: true, stdio: 'pipe', encoding: 'utf-8'},
       );
+      const commitMessage = stdout.toString();
 
       if (commitMessageStderr) {
         console.log(
@@ -131,12 +134,11 @@ const findAndPublishAllBumpedPackages = () => {
       }
     },
   );
-
-  process.exit(0);
-};
+}
 
 if (require.main === module) {
-  findAndPublishAllBumpedPackages();
+  // eslint-disable-next-line no-void
+  void findAndPublishAllBumpedPackages();
 }
 
 module.exports = {

From c50dcb162aa2d15847f627a4097e532759c25199 Mon Sep 17 00:00:00 2001
From: Alex Hunt <huntie@meta.com>
Date: Fri, 9 Feb 2024 05:41:42 -0800
Subject: [PATCH 2/3] [skip ci] Update exit cases for monorepo publish script
 (#42937)

Summary:

Changelog: [Internal]

Reviewed By: cipolleschi

Differential Revision: D53607810
---
 ...nd-and-publish-all-bumped-packages-test.js | 81 +++++++++++++++----
 .../find-and-publish-all-bumped-packages.js   | 74 +++++++----------
 2 files changed, 94 insertions(+), 61 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 1ec4e6ca4e14..dee607934ab2 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,26 +9,78 @@
  * @oncall react_native
  */
 
-const {PUBLISH_PACKAGES_TAG} = require('../constants');
 const {
   findAndPublishAllBumpedPackages,
   getTagsFromCommitMessage,
 } = 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 => {
@@ -41,16 +93,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',
@@ -66,21 +121,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}));
 
@@ -106,8 +152,9 @@ describe('findAndPublishAllBumpedPackages', () => {
 });
 
 describe('getTagsFromCommitMessage', () => {
-  it('should parse tags out', () => {
-    const commitMsg = `This may be any commit message before it like tag a \n\n${PUBLISH_PACKAGES_TAG}&tagA&tagB&tagA\n`;
+  test('should parse tags', () => {
+    const commitMsg = `${BUMP_COMMIT_MESSAGE}&tagA&tagB&tagA\n`;
+
     expect(getTagsFromCommitMessage(commitMsg)).toEqual([
       'tagA',
       'tagB',
diff --git a/scripts/monorepo/find-and-publish-all-bumped-packages.js b/scripts/monorepo/find-and-publish-all-bumped-packages.js
index 02d29493c9a3..0e7a86efcf51 100644
--- a/scripts/monorepo/find-and-publish-all-bumped-packages.js
+++ b/scripts/monorepo/find-and-publish-all-bumped-packages.js
@@ -12,23 +12,32 @@
 const {publishPackage} = require('../npm-utils');
 const {PUBLISH_PACKAGES_TAG} = require('./constants');
 const forEachPackage = require('./for-each-package');
-const {spawnSync} = require('child_process');
+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;
 
-function getTagsFromCommitMessage(msg /*: string */) /*: Array<string> */ {
-  // ex message we're trying to parse tags out of
-  // `_some_message_here_${PUBLISH_PACKAGES_TAG}&tagA&tagB\n`;
-  return msg
-    .substring(msg.indexOf(PUBLISH_PACKAGES_TAG))
-    .trim()
-    .split('&')
-    .slice(1);
-}
-
 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;
+  }
+
+  const tags = getTagsFromCommitMessage(commitMessage);
+
   console.log('Traversing all packages inside /packages...');
 
   forEachPackage(
@@ -70,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;
 
@@ -114,8 +92,6 @@ async function findAndPublishAllBumpedPackages() {
         );
       }
 
-      const tags = getTagsFromCommitMessage(commitMessage);
-
       const result = publishPackage(packageAbsolutePath, {
         tags,
         otp: NPM_CONFIG_OTP,
@@ -136,6 +112,16 @@ async function findAndPublishAllBumpedPackages() {
   );
 }
 
+function getTagsFromCommitMessage(msg /*: string */) /*: Array<string> */ {
+  // ex message we're trying to parse tags out of
+  // `_some_message_here_${PUBLISH_PACKAGES_TAG}&tagA&tagB\n`;
+  return msg
+    .substring(msg.indexOf(PUBLISH_PACKAGES_TAG))
+    .trim()
+    .split('&')
+    .slice(1);
+}
+
 if (require.main === module) {
   // eslint-disable-next-line no-void
   void findAndPublishAllBumpedPackages();

From 7499cbc6220fb6ff9f9cd24025587220f8506a65 Mon Sep 17 00:00:00 2001
From: Alex Hunt <huntie@meta.com>
Date: Fri, 9 Feb 2024 05:41:42 -0800
Subject: [PATCH 3/3] Refactor package discovery in publish script (#42938)

Summary:

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
---
 ...nd-and-publish-all-bumped-packages-test.js |  48 +++++---
 .../find-and-publish-all-bumped-packages.js   | 116 +++++++++---------
 scripts/monorepo/for-each-package.js          |   2 +
 scripts/releases/utils/monorepo.js            |  92 ++++++++++++++
 4 files changed, 180 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 dee607934ab2..488e00c50eea 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
@@ -14,14 +14,16 @@ const {
   getTagsFromCommitMessage,
 } = 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';
@@ -82,11 +84,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(() => ({
@@ -105,17 +109,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 0e7a86efcf51..8353e6683645 100644
--- a/scripts/monorepo/find-and-publish-all-bumped-packages.js
+++ b/scripts/monorepo/find-and-publish-all-bumped-packages.js
@@ -10,8 +10,8 @@
  */
 
 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,76 +40,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);
 
-      if (commitDiffStderr) {
-        console.log(
-          `\u274c Failed to get latest committed changes for ${packageManifest.name}:`,
-        );
-        console.log(commitDiffStderr);
+      process.exit(1);
+    }
 
-        process.exit(1);
-      }
+    const previousVersionPatternMatches = diff
+      .toString()
+      .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 ${package.name}`);
 
-      if (!previousVersionPatternMatches) {
-        console.log(`\uD83D\uDD0E No version bump for ${packageManifest.name}`);
+      return;
+    }
 
-        return;
-      }
+    const [, previousVersion] = previousVersionPatternMatches;
+    const nextVersion = package.packageJson.version;
+
+    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, {
-        tags,
-        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}`,
+      );
+    }
+  }
 }
 
 function getTagsFromCommitMessage(msg /*: string */) /*: Array<string> */ {
diff --git a/scripts/monorepo/for-each-package.js b/scripts/monorepo/for-each-package.js
index aed8abe6112b..da6d9b3ead17 100644
--- a/scripts/monorepo/for-each-package.js
+++ b/scripts/monorepo/for-each-package.js
@@ -40,6 +40,8 @@ const getDirectories = (source /*: string */) /*: Array<string> */ =>
     .map(directory => directory.name.toString());
 /**
  * Iterate through every package inside /packages (ignoring react-native) and call provided callback for each of them
+ *
+ * @deprecated Use scripts/releases/utils/monorepo.js#getPackages instead
  */
 const forEachPackage = (
   callback /*: (string, string, PackageJSON) => void */,
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,
+};
