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..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
@@ -4,51 +4,169 @@
  * 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');
 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 getPackagesMock = jest.fn();
+const execSync = jest.fn();
+const spawnSync = jest.fn();
+const execMock = jest.fn();
+
+jest.mock('child_process', () => ({execSync, spawnSync}));
+jest.mock('shelljs', () => ({exec: execMock}));
+jest.mock('../../releases/utils/monorepo', () => ({
+  getPackages: getPackagesMock,
+}));
+
+const BUMP_COMMIT_MESSAGE =
+  'bumped packages versions\n\n#publish-packages-to-npm';
 
 describe('findAndPublishAllBumpedPackages', () => {
   beforeEach(() => {
-    // Silence logs.
     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.",
+        ],
+      ]
+    `);
   });
-  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,
-      });
+  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';
+    getPackagesMock.mockResolvedValue({
+      '@react-native/package-a': {
+        path: 'absolute/path/to/package-a',
+        packageJson: {
+          version: mockedPackageNewVersion,
+        },
+      },
     });
 
     spawnSync.mockImplementationOnce(() => ({
       stdout: `-  "version": "0.72.0"\n+  "version": "${mockedPackageNewVersion}"\n`,
     }));
 
+    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;
+      }
+    });
+    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(() => ({
+      stdout: `-  "version": "0.72.0"\n+  "version": "0.72.1"\n`,
+    }));
     spawnSync.mockImplementationOnce(() => ({
-      stdout: `This is my commit message\n\n${PUBLISH_PACKAGES_TAG}`,
+      stdout: `-  "version": "0.72.0"\n+  "version": "0.72.1"\n`,
+    }));
+    spawnSync.mockImplementationOnce(() => ({
+      stdout: '\n',
     }));
 
-    expect(() => findAndPublishAllBumpedPackages()).toThrow(
-      `Package version expected to be 0.x.y, but received ${mockedPackageNewVersion}`,
-    );
+    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', () => {
-  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 5cec500e4624..8353e6683645 100644
--- a/scripts/monorepo/find-and-publish-all-bumped-packages.js
+++ b/scripts/monorepo/find-and-publish-all-bumped-packages.js
@@ -4,139 +4,123 @@
  * 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');
+const {getPackages} = require('../releases/utils/monorepo');
 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) {
-  // 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;
 
-const findAndPublishAllBumpedPackages = () => {
-  console.log('Traversing all packages inside /packages...');
+  try {
+    commitMessage = execSync('git log -1 --pretty=%B').toString();
+  } catch {
+    console.error('Failed to read Git commit message, exiting.');
+    process.exitCode = 1;
+    return;
+  }
 
-  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 (!commitMessage.includes(PUBLISH_PACKAGES_TAG)) {
+    console.log(
+      'Current commit does not include #publish-packages-to-npm keyword, skipping.',
+    );
+    return;
+  }
 
-      if (commitDiffStderr) {
-        console.log(
-          `\u274c Failed to get latest committed changes for ${packageManifest.name}:`,
-        );
-        console.log(commitDiffStderr);
+  const tags = getTagsFromCommitMessage(commitMessage);
 
-        process.exit(1);
-      }
+  console.log('Traversing all packages inside /packages...');
 
-      const previousVersionPatternMatches = diff.match(
-        /- {2}"version": "([0-9]+.[0-9]+.[0-9]+)"/,
+  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 (!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'},
-      );
+      process.exit(1);
+    }
+
+    const previousVersionPatternMatches = diff
+      .toString()
+      .match(/- {2}"version": "([0-9]+.[0-9]+.[0-9]+)"/);
 
-      if (commitMessageStderr) {
-        console.log(
-          `\u274c Failed to get latest commit message for ${packageManifest.name}:`,
-        );
-        console.log(commitMessageStderr);
+    if (!previousVersionPatternMatches) {
+      console.log(`\uD83D\uDD0E No version bump for ${package.name}`);
 
-        process.exit(1);
-      }
+      return;
+    }
 
-      const hasSpecificPublishTag =
-        commitMessage.includes(PUBLISH_PACKAGES_TAG);
+    const [, previousVersion] = previousVersionPatternMatches;
+    const nextVersion = package.packageJson.version;
 
-      if (!hasSpecificPublishTag) {
-        throw new Error(
-          `Package ${packageManifest.name} was updated, but not through CI script`,
-        );
-      }
+    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 tags = getTagsFromCommitMessage(commitMessage);
-
-      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(0);
-};
+      process.exit(1);
+    } else {
+      console.log(
+        `\u2705 Successfully published new version of ${package.name}`,
+      );
+    }
+  }
+}
+
+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) {
-  findAndPublishAllBumpedPackages();
+  // eslint-disable-next-line no-void
+  void findAndPublishAllBumpedPackages();
 }
 
 module.exports = {
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,
+};
