From 4d93597ab945d174f7a5fa3dd2fe2785dff97561 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 14 Sep 2026 20:29:58 -0700 Subject: [PATCH 01/38] fix(ci): forward-port release integration fixes from 0.83 Port dbb0b399d38, 4972b56e8cc, 661fd616842, 7518263dc8e, 7ee8719173f and 7684cf0b871 from #3022 with main-compatible adaptations. Use the codegen workspace version on fork-point branches. Prefer the private-package policy from bfb629a66d5 over an RNTester-specific ignore. Gate only registry-dependent init, propagate registry failures, and retain local test-app coverage. Validated with seven Node 22 tests and actionlint. Independently reviewed for fork suitability, merge durability and later equivalents. --- .changeset/config.json | 4 ++ .../check-release-published.test.mjs | 23 ++++++++++++ .../__tests__/export-versions.test.mjs | 37 +++++++++++++++++++ .github/scripts/change.mts | 2 +- .github/scripts/check-release-published.mjs | 27 ++++++++++++++ .github/scripts/export-versions.mts | 10 ++++- .github/workflows/microsoft-pr.yml | 35 +++++++++++++++++- ...soft-react-native-test-app-integration.yml | 20 ++++++---- ...microsoft-test-react-native-macos-init.yml | 10 +++-- 9 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 .github/scripts/__tests__/check-release-published.test.mjs create mode 100644 .github/scripts/__tests__/export-versions.test.mjs create mode 100644 .github/scripts/check-release-published.mjs diff --git a/.changeset/config.json b/.changeset/config.json index 524d843b7dfa..5df6ff41ef8e 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,5 +5,9 @@ "changelog": "@changesets/cli/changelog", "commit": false, "ignore": [], + "privatePackages": { + "version": false, + "tag": false + }, "linked": [] } diff --git a/.github/scripts/__tests__/check-release-published.test.mjs b/.github/scripts/__tests__/check-release-published.test.mjs new file mode 100644 index 000000000000..4a7708269d3f --- /dev/null +++ b/.github/scripts/__tests__/check-release-published.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {isReleasePublished} from '../check-release-published.mjs'; + +test('a successful registry response distinguishes released and new minors', () => { + const query = () => JSON.stringify(['0.81.9', '0.83.0-rc.0', '0.84.0']); + assert.equal(isReleasePublished('0.83', query), true); + assert.equal(isReleasePublished('0.85', query), false); + assert.equal(isReleasePublished('0.8', query), false); + assert.equal(isReleasePublished('0.83', () => '[]'), false); +}); + +test('registry failures propagate instead of skipping integration', () => { + const error = new Error('Registry unavailable'); + assert.throws(() => isReleasePublished('0.83', () => { throw error; }), error); +}); + +test('invalid registry data and invalid minor versions fail', () => { + for (const response of ['not JSON', '{}', 'null', '[null]']) { + assert.throws(() => isReleasePublished('0.83', () => response)); + } + assert.throws(() => isReleasePublished('undefined', () => '[]')); +}); diff --git a/.github/scripts/__tests__/export-versions.test.mjs b/.github/scripts/__tests__/export-versions.test.mjs new file mode 100644 index 000000000000..df91b3b91594 --- /dev/null +++ b/.github/scripts/__tests__/export-versions.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; + +const script = new URL('../export-versions.mts', import.meta.url); + +for (const {name, peerVersion, codegenVersion, expected} of [ + {name: 'main uses the codegen workspace version', codegenVersion: '0.83.0-main', expected: '0.83'}, + {name: 'stable prefers its explicit React Native peer', peerVersion: '0.83.10', codegenVersion: '0.83.0-main', expected: '0.83'}, + {name: 'the peer takes precedence over a different workspace minor', peerVersion: '0.84.1', codegenVersion: '0.83.0-main', expected: '0.84'}, + {name: 'later fork points use their own workspace version', codegenVersion: '0.87.0-main', expected: '0.87'}, +]) { + test(name, t => { + const root = mkdtempSync(join(tmpdir(), 'rnm-export-versions-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + mkdirSync(join(root, '.github/scripts'), {recursive: true}); + mkdirSync(join(root, 'packages/react-native'), {recursive: true}); + mkdirSync(join(root, 'packages/react-native-codegen'), {recursive: true}); + const target = join(root, '.github/scripts/export-versions.mts'); + copyFileSync(script, target); + writeFileSync(join(root, 'packages/react-native/package.json'), JSON.stringify({ + dependencies: {'@react-native/codegen': 'workspace:*'}, + peerDependencies: {react: '^19.2.0', ...(peerVersion ? {'react-native': peerVersion} : {})}, + })); + writeFileSync(join(root, 'packages/react-native-codegen/package.json'), JSON.stringify({version: codegenVersion})); + const output = join(root, 'github-output'); + const env = {...process.env, GITHUB_OUTPUT: output}; + execFileSync(process.execPath, [target], {env}); + assert.equal(readFileSync(output, 'utf8'), `react_version=^19.2.0\nreact_native_version=${expected}\n`); + delete env.GITHUB_OUTPUT; + assert.equal(execFileSync(process.execPath, [target], {env, encoding: 'utf8'}), + `react_version=^19.2.0\nreact_native_version=${expected}\n`); + }); +} diff --git a/.github/scripts/change.mts b/.github/scripts/change.mts index fa0d858f5a9b..b07c738c29c2 100644 --- a/.github/scripts/change.mts +++ b/.github/scripts/change.mts @@ -40,7 +40,7 @@ async function getBaseBranch(): Promise { const repoPath = repoUrl.match(/github\.com[:/](.+?)(?:\.git)?$/)?.[1] ?? ''; const remotes = (await $`git remote -v`.quiet()).stdout; - const remote = (repoPath && remotes.match(new RegExp(`^(\\S+)\\s+.*${repoPath}`, 'm'))?.[1]) ?? 'origin'; + const remote = (repoPath && remotes.match(new RegExp(`^(\\S+)\\s+.*${repoPath}`, 'm'))?.[1]) || 'origin'; // In CI, use the PR target branch (e.g., origin/0.81-stable) if (process.env['GITHUB_BASE_REF']) { diff --git a/.github/scripts/check-release-published.mjs b/.github/scripts/check-release-published.mjs new file mode 100644 index 000000000000..946b0fd5682a --- /dev/null +++ b/.github/scripts/check-release-published.mjs @@ -0,0 +1,27 @@ +import {execFileSync} from 'node:child_process'; +import {appendFileSync} from 'node:fs'; +import {pathToFileURL} from 'node:url'; + +export function isReleasePublished(minor, query = execFileSync) { + if (!/^\d+\.\d+$/.test(minor)) { + throw new Error(`Invalid React Native minor: ${minor}`); + } + // Query the package, not a potentially missing version range. A successful + // response with no matching version is distinct from a failed registry query. + const versions = JSON.parse(query('npm', ['view', 'react-native-macos', 'versions', '--json'], { + encoding: 'utf8', + timeout: 60000, + })); + if (!Array.isArray(versions) || !versions.every(version => typeof version === 'string')) { + throw new Error('Invalid npm versions response'); + } + return versions.some(version => version.startsWith(`${minor}.`)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const published = isReleasePublished(process.argv[2]); + console.log(`react-native-macos ${process.argv[2]}.x published: ${published}`); + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `published=${published}\n`); + } +} diff --git a/.github/scripts/export-versions.mts b/.github/scripts/export-versions.mts index f2658034cb8e..b23bba76fa9d 100644 --- a/.github/scripts/export-versions.mts +++ b/.github/scripts/export-versions.mts @@ -7,6 +7,7 @@ * react_native_version – the coerced major.minor React Native version (e.g. "0.79") */ import * as fs from "node:fs"; +import codegenManifest from "../../packages/react-native-codegen/package.json" with { type: "json" }; import manifest from "../../packages/react-native/package.json" with { type: "json" }; function coerce(version: string): string { @@ -24,7 +25,12 @@ function exportValue(name: string, value: string): void { } } -const { dependencies, peerDependencies } = manifest; +const { peerDependencies } = manifest; exportValue("react_version", peerDependencies["react"]); -exportValue("react_native_version", coerce(dependencies["@react-native/codegen"])); +// Stable branches declare upstream compatibility explicitly. Fork-point branches +// retain it in the codegen workspace version, not the "workspace:*" dependency. +const reactNativeVersion = + (peerDependencies as Record)["react-native"] ?? + codegenManifest.version; +exportValue("react_native_version", coerce(reactNativeVersion)); diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index 6b7e015073e1..c082f6242651 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -123,6 +123,9 @@ jobs: - name: Run Jest tests run: yarn test-ci + + - name: Test CI release helpers + run: node --test .github/scripts/__tests__/*.test.mjs - name: Run Flow type checker run: yarn flow-check @@ -143,15 +146,44 @@ jobs: permissions: {} uses: ./.github/workflows/microsoft-prebuild-macos-core.yml + # react-native-macos-init resolves a registry package, so its integration test + # requires a published release in the target minor. Registry errors must fail + # this check rather than silently bypass the integration test. + check-release-published: + name: "Check release published" + permissions: {} + if: ${{ endsWith(github.base_ref, '-stable') }} + runs-on: ubuntu-latest + outputs: + published: ${{ steps.check.outputs.published }} + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Export versions + id: versions + run: node .github/scripts/export-versions.mts + - name: Determine if the target react-native-macos minor is published + id: check + env: + REACT_NATIVE_MINOR: ${{ steps.versions.outputs.react_native_version }} + run: | + node .github/scripts/check-release-published.mjs "$REACT_NATIVE_MINOR" + test-react-native-macos-init: name: "Test react-native-macos init" permissions: {} - if: ${{ endsWith(github.base_ref, '-stable') }} + needs: check-release-published + if: ${{ endsWith(github.base_ref, '-stable') && needs.check-release-published.outputs.published == 'true' }} uses: ./.github/workflows/microsoft-test-react-native-macos-init.yml react-native-test-app-integration: name: "Test react-native-test-app integration" permissions: {} + # This workflow supplies local tarballs, so publication is not its prerequisite. if: ${{ endsWith(github.base_ref, '-stable') }} uses: ./.github/workflows/microsoft-react-native-test-app-integration.yml @@ -168,6 +200,7 @@ jobs: - javascript-tests - build-rntester - prebuild-macos-core + - check-release-published - test-react-native-macos-init - react-native-test-app-integration steps: diff --git a/.github/workflows/microsoft-react-native-test-app-integration.yml b/.github/workflows/microsoft-react-native-test-app-integration.yml index 7c85f8c86935..1d0995d371fc 100644 --- a/.github/workflows/microsoft-react-native-test-app-integration.yml +++ b/.github/workflows/microsoft-react-native-test-app-integration.yml @@ -39,10 +39,12 @@ jobs: run: node .github/scripts/export-versions.mts - name: Pack local react-native-macos - working-directory: packages/react-native run: | set -eox pipefail - yarn pack -o ${{ runner.temp }}/react-native-macos.tgz + # The @react-native-macos/virtualized-lists workspace dependency is not + # published at the 1000.0.0 dev version, so pack it too and override it below. + (cd packages/react-native && yarn pack -o ${{ runner.temp }}/react-native-macos.tgz) + (cd packages/virtualized-lists && yarn pack -o ${{ runner.temp }}/virtualized-lists.tgz) - name: Clone react-native-test-app run: | @@ -53,18 +55,22 @@ jobs: run: | node ../app/scripts/internal/set-react-version.mts ${{ steps.versions.outputs.react_native_version }} --overrides '{ "react-native-macos": "file:${{ runner.temp }}/react-native-macos.tgz" }' - - name: Pin @types/react to avoid duplicate react-native-macos + - name: Pin dependencies to avoid resolution conflicts working-directory: react-native-test-app run: | - # The test app tree carries both @types/react 19.1.x (example-macos) - # and 19.2.x (app), both satisfying react-native-macos's peer. Under - # Yarn's pnpm nodeLinker this virtualizes react-native-macos twice and - # trips the metro duplicate-dependency checker. Pin to a single version. + # 1. The test app tree carries both @types/react 19.1.x (example-macos) + # and 19.2.x (app), both satisfying react-native-macos's peer. Under + # Yarn's pnpm nodeLinker this virtualizes react-native-macos twice and + # trips the metro duplicate-dependency checker. Pin to a single version. + # 2. The packed react-native-macos depends on the unpublished + # @react-native-macos/virtualized-lists@1000.0.0; --overrides only covers + # the direct react-native-macos dep, so force the transitive one here. node -e " const fs = require('fs'); const root = JSON.parse(fs.readFileSync('package.json', 'utf8')); root.resolutions = root.resolutions || {}; root.resolutions['@types/react'] = '~19.1.0'; + root.resolutions['@react-native-macos/virtualized-lists'] = 'file:${{ runner.temp }}/virtualized-lists.tgz'; fs.writeFileSync('package.json', JSON.stringify(root, null, 2) + '\n'); " diff --git a/.github/workflows/microsoft-test-react-native-macos-init.yml b/.github/workflows/microsoft-test-react-native-macos-init.yml index e9cfd19d0fe4..f58a88a468e9 100644 --- a/.github/workflows/microsoft-test-react-native-macos-init.yml +++ b/.github/workflows/microsoft-test-react-native-macos-init.yml @@ -51,17 +51,19 @@ jobs: working-directory: ${{ runner.temp }} - name: Pack local react-native-macos - working-directory: packages/react-native run: | set -eox pipefail - # Use a tarball instead of a direct path to avoid symlinks - yarn pack -o ${{ runner.temp }}/react-native-macos.tgz + # Use tarballs instead of direct paths to avoid symlinks. The + # @react-native-macos/virtualized-lists workspace dependency is not + # published at the 1000.0.0 dev version, so pack and install it too. + (cd packages/react-native && yarn pack -o ${{ runner.temp }}/react-native-macos.tgz) + (cd packages/virtualized-lists && yarn pack -o ${{ runner.temp }}/virtualized-lists.tgz) - name: Install local react-native-macos working-directory: ${{ runner.temp }}/testcli run: | set -eox pipefail - npm install ${{ runner.temp }}/react-native-macos.tgz + npm install ${{ runner.temp }}/virtualized-lists.tgz ${{ runner.temp }}/react-native-macos.tgz - name: Apply macOS template working-directory: ${{ runner.temp }}/testcli From a9765212c575cd1afda404a94623821d6ef22a6d Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 14 Sep 2026 20:35:41 -0700 Subject: [PATCH 02/38] fix(codegen): match package-relative source paths Forward-port c0073885ad61 from the 0.86 stack. This canonicalizes the fixes carried on 0.84 release (5f8d15da898) and 0.85 release (2b57c6c809b). Absolute checkout paths containing hidden or __tests__ ancestors otherwise copy raw Flow sources or ignore the entire package. Preserve absolute paths for file operations, but match package-relative paths. Independent review exercised six before/after VM cases using real micromatch, Babel and Prettier, including fixture exclusion, Flow sidecars and asset copies. --- packages/react-native-codegen/scripts/build.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-native-codegen/scripts/build.js b/packages/react-native-codegen/scripts/build.js index c8a5c6350f14..fb221fe14e88 100644 --- a/packages/react-native-codegen/scripts/build.js +++ b/packages/react-native-codegen/scripts/build.js @@ -60,17 +60,18 @@ function getBuildPath(file, buildFolder) { async function buildFile(file, silent) { const destPath = getBuildPath(file, BUILD_DIR); + const relativeFile = path.relative(PACKAGE_DIR, file); fs.mkdirSync(path.dirname(destPath), {recursive: true}); - if (micromatch.isMatch(file, IGNORE_PATTERN)) { + if (micromatch.isMatch(relativeFile, IGNORE_PATTERN)) { silent || process.stdout.write( styleText('dim', ' \u2022 ') + path.relative(PACKAGE_DIR, file) + ' (ignore)\n', ); - } else if (!micromatch.isMatch(file, JS_FILES_PATTERN)) { + } else if (!micromatch.isMatch(relativeFile, JS_FILES_PATTERN)) { fs.createReadStream(file).pipe(fs.createWriteStream(destPath)); silent || process.stdout.write( From 87adb52c70ac548bb95eb8d186ebf36fe20f3048 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Mon, 14 Sep 2026 20:51:59 -0700 Subject: [PATCH 03/38] fix(types): canonicalize checkout-relative API imports Adapt 434e93cdad27 from the local 0.87 stable stack for main. Restrict normalization to relative paths, match complete workspace package names, and select the deepest node_modules package. This repairs generation of checkout-dependent imports found in the 0.84, 0.85 and 0.86 release snapshots without copying release artifacts onto main. Independent review passed 19 focused tests including real snapshots and post-processing output, targeted ESLint and Prettier. Full type generation remains a release validation step. --- .../js-api/build-types/buildApiSnapshot.js | 9 ++- .../canonicalizeLocalPackageImports-test.js | 80 +++++++++++++++++++ .../canonicalizeLocalPackageImports.js | 65 +++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 scripts/js-api/build-types/transforms/typescript/__tests__/canonicalizeLocalPackageImports-test.js create mode 100644 scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js diff --git a/scripts/js-api/build-types/buildApiSnapshot.js b/scripts/js-api/build-types/buildApiSnapshot.js index 33e380764854..9f1f50f0644a 100644 --- a/scripts/js-api/build-types/buildApiSnapshot.js +++ b/scripts/js-api/build-types/buildApiSnapshot.js @@ -37,7 +37,11 @@ const inputFilesPostTransforms: $ReadOnlyArray> = [ const postTransforms = ( options: BuildApiSnapshotOptions, + packages: $ReadOnlyArray<{directory: string, name: string}>, ): $ReadOnlyArray> => [ + require('./transforms/typescript/canonicalizeLocalPackageImports')( + packages.map(pkg => pkg.name), + ), require('./transforms/typescript/simplifyTypes'), require('./transforms/typescript/sortProperties'), require('./transforms/typescript/sortUnions'), @@ -85,7 +89,7 @@ async function buildAPISnapshot(options: BuildApiSnapshotOptions) { console.log(styleText('yellow', ' >') + ' Applying additional transforms'); const apiSnapshot = apiSnapshotTemplate( - await getProcessedSnapshotResult(tempDirectory, options), + await getProcessedSnapshotResult(tempDirectory, options, packages), ) as string; console.log(styleText('yellow', ' >') + ' Removing temp dir'); @@ -242,6 +246,7 @@ async function rewriteLocalImports( async function getProcessedSnapshotResult( tempDirectory: string, options: BuildApiSnapshotOptions, + packages: $ReadOnlyArray<{directory: string, name: string}>, ): Promise { const rollupPath = path.join( tempDirectory, @@ -259,7 +264,7 @@ async function getProcessedSnapshotResult( const transformedRollup = await applyBabelTransformsSeq( cleanedRollup, - postTransforms(options), + postTransforms(options, packages), ); return ( diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/canonicalizeLocalPackageImports-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/canonicalizeLocalPackageImports-test.js new file mode 100644 index 000000000000..7a90e22aa729 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/canonicalizeLocalPackageImports-test.js @@ -0,0 +1,80 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const canonicalizeLocalPackageImports = require('../canonicalizeLocalPackageImports'); +const babel = require('@babel/core'); + +async function transform(code: string): Promise { + const result = await babel.transformAsync(code, { + plugins: [ + '@babel/plugin-syntax-typescript', + canonicalizeLocalPackageImports([ + 'react-native-macos', + '@react-native-macos/virtualized-lists', + ]), + ], + }); + return result.code; +} + +describe('canonicalizeLocalPackageImports', () => { + test('normalizes nested node_modules imports and exports', async () => { + const result = await transform(` + import type {Foo} from "../../../jest-preset/node_modules/react-native-macos/node_modules/@react-native-macos/virtualized-lists"; + export {Bar} from "../../node_modules/react-native-macos/src/bar"; + export * from "../../../react-native/node_modules/@react-native-macos/virtualized-lists"; + `); + + expect(result).toBe( + [ + 'import type { Foo } from "@react-native-macos/virtualized-lists";', + 'export { Bar } from "react-native-macos/src/bar";', + 'export * from "@react-native-macos/virtualized-lists";', + ].join('\n'), + ); + }); + + test.each([ + '../../node_modules/react-native-macos-extra', + '../../node_modules/react-native-macos.extra', + '../../node_modules/@react-native-macos/virtualized-lists-extra', + '../../node_modules/react-native-macos/node_modules/unrelated', + 'https://host/node_modules/react-native-macos', + '/absolute/node_modules/react-native-macos', + 'some-package/node_modules/react-native-macos', + '../ordinary/react-native-macos', + 'react-native-macos', + ])('preserves non-local or non-matching source %s', async source => { + expect(await transform(`export * from "${source}";`)).toBe( + `export * from "${source}";`, + ); + }); + + test('preserves ordinary strings and exports without sources', async () => { + expect( + await transform( + 'const value = "../node_modules/react-native-macos"; export {value};', + ), + ).toBe( + 'const value = "../node_modules/react-native-macos";\nexport { value };', + ); + }); + + test('is independent of checkout prefixes and idempotent', async () => { + const first = await transform( + 'export * from "../../one/node_modules/react-native-macos/src/api";', + ); + const second = await transform( + 'export * from "../../../two/node_modules/react-native-macos/src/api";', + ); + expect(first).toBe(second); + expect(await transform(first)).toBe(first); + }); +}); diff --git a/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js new file mode 100644 index 000000000000..9c97ce7f8118 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js @@ -0,0 +1,65 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {PluginObj} from '@babel/core'; +import type {NodePath} from '@babel/traverse'; +import type { + ExportAllDeclaration, + ExportNamedDeclaration, + ImportDeclaration, +} from '@babel/types'; + +function canonicalizeSource( + source: string, + packageNames: $ReadOnlyArray, +): string { + if (!source.startsWith('./') && !source.startsWith('../')) { + return source; + } + const marker = '/node_modules/'; + const markerIndex = source.lastIndexOf(marker); + if (markerIndex === -1) { + return source; + } + const candidate = source.slice(markerIndex + marker.length); + return packageNames.some( + name => candidate === name || candidate.startsWith(name + '/'), + ) + ? candidate + : source; +} + +function canonicalizeLocalPackageImports( + packageNames: $ReadOnlyArray, +): PluginObj { + function canonicalizeNodeSource( + nodePath: NodePath< + ExportAllDeclaration | ExportNamedDeclaration | ImportDeclaration, + >, + ) { + if (nodePath.node.source != null) { + nodePath.node.source.value = canonicalizeSource( + nodePath.node.source.value, + packageNames, + ); + } + } + + return { + name: 'canonicalize-local-package-imports', + visitor: { + ExportAllDeclaration: canonicalizeNodeSource, + ExportNamedDeclaration: canonicalizeNodeSource, + ImportDeclaration: canonicalizeNodeSource, + }, + }; +} + +module.exports = canonicalizeLocalPackageImports; From 2b97335f628a8eea89a07c41fb5a61074e3fd7cd Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 15 Sep 2026 17:32:48 -0500 Subject: [PATCH 04/38] fix(pods): resolve dependency package fallback Forward-port e4c208d864d7 from the local 0.87 stable stack. ReactNativeDependencies.podspec is one directory below its package root, not two. Independent review verified Ruby syntax, the real checkout fallback, standard react-native and react-native-macos package layouts, and unchanged successful Node resolution. --- .../third-party-podspecs/ReactNativeDependencies.podspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec index e08e6c2b0995..a0cffd1930e3 100644 --- a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec +++ b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec @@ -13,8 +13,8 @@ begin )', __dir__]).strip ) rescue => e - # Fallback to the parent directory if the above command fails (e.g when building locally in OOT Platform) - react_native_path = File.join(__dir__, "..", "..") + # Fallback to the package directory if the above command fails (e.g when building locally in OOT Platform) + react_native_path = File.join(__dir__, "..") end # package.json From 07921f8b3747cf9213d4db276b09ce73fd5bf82c Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 15 Sep 2026 17:34:20 -0500 Subject: [PATCH 05/38] fix(prebuild): fail invalid platform requests Forward-port 03d2ca33950c from the local 0.87 stable stack. Both prebuild entry points return after invalid configuration, so set the process exit code before returning. Independent review verified 24 subprocess probes across parent and patched CLIs, including invalid and valid platform cases, plus syntax and whitespace checks. --- packages/react-native/scripts/ios-prebuild/cli.js | 1 + scripts/releases/ios-prebuild/cli.js | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/react-native/scripts/ios-prebuild/cli.js b/packages/react-native/scripts/ios-prebuild/cli.js index 86b639d00ad8..5f3886a2651f 100644 --- a/packages/react-native/scripts/ios-prebuild/cli.js +++ b/packages/react-native/scripts/ios-prebuild/cli.js @@ -100,6 +100,7 @@ async function getCLIConfiguration() /*: Promise Date: Wed, 16 Sep 2026 15:19:17 -0500 Subject: [PATCH 06/38] fix(text): preserve explicit custom font weights Forward-port upstream React Native #57483 (3e5e3c29ba48) from the release lines. The omitted-middle ternary returned a boolean rather than the requested numeric weight. Preserve the fork's AppKit family lookup. Independent review verified upstream patch parity and nine native weight cases including Light, Medium and Bold family selection. --- .../ios/react/renderer/textlayoutmanager/RCTFontUtils.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm index 93c3a33a9cec..d3e7f18f1439 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm @@ -385,7 +385,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f #else // [macOS fontNames = RCTFontNamesForFamilyName(font.familyName); #endif // macOS] - fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font); + fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font); } else { // Failback to system font. font = RCTDefaultFontWithFontProperties(fontProperties); From 581aa73e2a964df3121d5b2ecf3e4c8a8d4c3073 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 16:37:30 -0500 Subject: [PATCH 07/38] fix(release): publish prepared Changesets versions with one tag Keep automatic Changesets version PRs and GitHub trusted publishing. Reject unprepared versions and incomplete dependency graphs, align coupled package changelogs, guard stale version PR runs and serialize queued publications. Preserve the reviewed one-tag policy: latest for the newest stable line, branch tag for older stable lines, next for prereleases. Existing versions skip without tag mutation. Retain but disable ADO publication. Independent review passed 30 Node 22 tests including real Changesets version execution. Installed actionlint lacks the documented queue:max field; all other workflow checks passed. --- .ado/jobs/npm-publish.yml | 2 + .ado/publish.yml | 3 + .ado/scripts/configure-publish.mts | 274 ++---------- .../__tests__/publishing-contract.test.mjs | 419 ++++++++++++++++++ .../changeset-version-with-postbump.mts | 105 ++++- .github/scripts/check-version-head.mjs | 20 + .github/scripts/publishing-contract.md | 53 +++ .github/scripts/publishing-contract.mjs | 198 +++++++++ .../microsoft-changesets-version.yml | 12 + .github/workflows/microsoft-npm-publish.yml | 39 +- package.json | 2 + yarn.lock | 2 + 12 files changed, 860 insertions(+), 269 deletions(-) create mode 100644 .github/scripts/__tests__/publishing-contract.test.mjs create mode 100644 .github/scripts/check-version-head.mjs create mode 100644 .github/scripts/publishing-contract.md create mode 100644 .github/scripts/publishing-contract.mjs diff --git a/.ado/jobs/npm-publish.yml b/.ado/jobs/npm-publish.yml index 0fa3d7196c68..5370dfee8f17 100644 --- a/.ado/jobs/npm-publish.yml +++ b/.ado/jobs/npm-publish.yml @@ -1,6 +1,8 @@ jobs: - job: NPMPublish displayName: NPM Publish + # Also disable direct template consumers until ADO publication is re-enabled. + condition: false pool: name: cxeiss-ubuntu-20-04-large image: cxe-ubuntu-20-04-1es-pt diff --git a/.ado/publish.yml b/.ado/publish.yml index 090cd21cd91e..2a0dab4f252a 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -51,5 +51,8 @@ extends: stages: - stage: NPM dependsOn: [] + # GitHub Trusted Publishing owns releases. Retain the ADO publication + # path for explicit re-enablement after its contract/auth are reviewed. + condition: false jobs: - template: /.ado/jobs/npm-publish.yml@self diff --git a/.ado/scripts/configure-publish.mts b/.ado/scripts/configure-publish.mts index 31b336441f56..256168447af3 100644 --- a/.ado/scripts/configure-publish.mts +++ b/.ado/scripts/configure-publish.mts @@ -1,237 +1,45 @@ #!/usr/bin/env node -import { $, argv, echo, fs } from 'zx'; -import { resolve } from 'node:path'; - -const isGitHubActions = process.env['GITHUB_ACTIONS'] === 'true'; - -const NPM_TAG_NEXT = 'next'; - -export type ReleaseState = 'STABLE_IS_LATEST' | 'STABLE_IS_NEW' | 'STABLE_IS_OLD'; - -export interface ReleaseStateInfo { - state: ReleaseState; - currentVersion: number; - latestVersion: number; - nextVersion: number; -} - -export interface TagInfo { - npmTags: string[]; - prerelease?: string; -} - -interface Options { - 'mock-branch'?: string; - tag?: string; - verbose?: boolean; -} - -function enablePublishingOnAzurePipelines() { - echo(`##vso[task.setvariable variable=publish_react_native_macos]1`); -} - -function enablePublishingOnGitHubActions() { - if (process.env['GITHUB_OUTPUT']) { - fs.appendFileSync(process.env['GITHUB_OUTPUT'], `publish_react_native_macos=1\n`); - } -} - -export function isMainBranch(branch: string): boolean { - return branch === 'main'; -} - -export function isStableBranch(branch: string): boolean { - return /^\d+\.\d+-stable$/.test(branch); -} - -export function versionToNumber(version: string): number { - const [major, minor] = version.split('-')[0].split('.'); - return Number(major) * 1000 + Number(minor); -} - -function getTargetBranch(): string | undefined { - // Azure Pipelines - if (process.env['TF_BUILD'] === 'True') { - const targetBranch = process.env['SYSTEM_PULLREQUEST_TARGETBRANCH']; - return targetBranch?.replace(/^refs\/heads\//, ''); - } - - // GitHub Actions - if (process.env['GITHUB_ACTIONS'] === 'true') { - return process.env['GITHUB_BASE_REF']; - } - - return undefined; -} - -async function getCurrentBranch(options: Options): Promise { - const targetBranch = getTargetBranch(); - if (targetBranch) { - return targetBranch; - } - - // Azure DevOps Pipelines - if (process.env['TF_BUILD'] === 'True') { - const sourceBranch = process.env['BUILD_SOURCEBRANCHNAME']; - if (sourceBranch) { - return sourceBranch.replace(/^refs\/heads\//, ''); - } - } - - // GitHub Actions - if (process.env['GITHUB_ACTIONS'] === 'true') { - const headRef = process.env['GITHUB_HEAD_REF']; - if (headRef) return headRef; - - const ref = process.env['GITHUB_REF']; - if (ref) return ref.replace(/^refs\/heads\//, ''); - } - - if (options['mock-branch']) { - return options['mock-branch']; - } - - const result = await $`git rev-parse --abbrev-ref HEAD`; - return result.stdout.trim(); -} - -function getPublishedVersionSync(tag: 'latest' | 'next'): number { - const result = $.sync`npm view react-native-macos@${tag} version`; - return versionToNumber(result.stdout.trim()); -} - -export function getReleaseState( - branch: string, - getVersion: (tag: 'latest' | 'next') => number = getPublishedVersionSync, -): ReleaseStateInfo { - if (!isStableBranch(branch)) { - throw new Error('Expected a stable branch'); - } - - const latestVersion = getVersion('latest'); - const nextVersion = getVersion('next'); - const currentVersion = versionToNumber(branch); - - let state: ReleaseState; - if (currentVersion === latestVersion) { - state = 'STABLE_IS_LATEST'; - } else if (currentVersion < latestVersion) { - state = 'STABLE_IS_OLD'; +import {execFileSync} from 'node:child_process'; +import {appendFileSync} from 'node:fs'; +import {parseArgs} from 'node:util'; +import { + createPublishPlan, + isStableBranch, + publishPrepared, + readChangesetStatus, + readWorkspaces, +} from '../../.github/scripts/publishing-contract.mjs'; + +const {values: options} = parseArgs({options: { + 'mock-branch': {type: 'string'}, + verbose: {type: 'boolean'}, + publish: {type: 'boolean'}, +}}); +const branch = process.env.GITHUB_REF_NAME ?? process.env.BUILD_SOURCEBRANCHNAME ?? + options['mock-branch'] ?? execFileSync('git', ['branch', '--show-current'], {encoding: 'utf8'}).trim(); +const isPullRequest = Boolean(process.env.GITHUB_BASE_REF || + process.env.SYSTEM_PULLREQUEST_TARGETBRANCH || process.env.BUILD_REASON === 'PullRequest'); + +function output(name: string, value: string) { + if (process.env.TF_BUILD === 'True') console.log(`##vso[task.setvariable variable=${name}]${value}`); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); +} + +output('publish_react_native_macos', '0'); +if (!isStableBranch(branch) || isPullRequest) { + console.log(`Publication disabled for ${isPullRequest ? 'pull requests' : branch}`); +} else { + const plan = await createPublishPlan({ + branch, + status: await readChangesetStatus(), + workspaces: readWorkspaces(), + }); + if (options.verbose) console.log(JSON.stringify(plan, null, 2)); + output('publishTag', plan.tag ?? ''); + if (plan.packages.length) { + output('publish_react_native_macos', '1'); + if (options.publish) publishPrepared(plan); } else { - state = 'STABLE_IS_NEW'; - } - - return { state, currentVersion, latestVersion, nextVersion }; -} - -export function getPublishTags( - stateInfo: ReleaseStateInfo, - branch: string, - tag: string = NPM_TAG_NEXT, -): TagInfo { - const { state, currentVersion, nextVersion } = stateInfo; - - switch (state) { - case 'STABLE_IS_LATEST': - // Patching the current latest version - return { npmTags: ['latest', branch] }; - - case 'STABLE_IS_OLD': - // Patching an older stable version - return { npmTags: [branch] }; - - case 'STABLE_IS_NEW': { - if (tag === 'latest') { - // Promoting this branch to latest - const npmTags = ['latest', branch]; - if (currentVersion > nextVersion) { - npmTags.push(NPM_TAG_NEXT); - } - return { npmTags }; - } - - // Publishing a release candidate - if (currentVersion < nextVersion) { - throw new Error( - `Current version cannot be a release candidate because it is too old: ${currentVersion} < ${nextVersion}`, - ); - } - - return { npmTags: [NPM_TAG_NEXT], prerelease: 'rc' }; - } - } -} - -async function enablePublishing(tagInfo: TagInfo, options: Options) { - const [primaryTag, ...additionalTags] = tagInfo.npmTags; - - // Output publishTag for subsequent pipeline steps - echo(`##vso[task.setvariable variable=publishTag]${primaryTag}`); - if (process.env['GITHUB_OUTPUT']) { - fs.appendFileSync(process.env['GITHUB_OUTPUT'], `publishTag=${primaryTag}\n`); - } - - // Output additional tags - if (additionalTags.length > 0) { - const tagsValue = additionalTags.join(','); - echo(`##vso[task.setvariable variable=additionalTags]${tagsValue}`); - if (process.env['GITHUB_OUTPUT']) { - fs.appendFileSync(process.env['GITHUB_OUTPUT'], `additionalTags=${tagsValue}\n`); - } - } - - // Don't enable publishing in PRs - if (!getTargetBranch()) { - if (isGitHubActions) { - enablePublishingOnGitHubActions(); - } else if (process.env['TF_BUILD'] === 'True') { - enablePublishingOnAzurePipelines(); - } else { - echo('ℹ️ Local run — publishing not enabled'); - } - } -} - -const isDirectRun = - process.argv[1] != null && - resolve(process.argv[1]) === new URL(import.meta.url).pathname; - -if (isDirectRun) { - // Parse CLI args using zx's argv (minimist) - const options: Options = { - 'mock-branch': argv['mock-branch'] as string | undefined, - tag: typeof argv['tag'] === 'string' ? argv['tag'] : NPM_TAG_NEXT, - verbose: Boolean(argv['verbose']), - }; - - const branch = await getCurrentBranch(options); - if (!branch) { - echo('❌ Could not get current branch'); - process.exit(1); - } - - const log = options.verbose ? (msg: string) => echo(`ℹ️ ${msg}`) : () => {}; - - try { - if (isMainBranch(branch)) { - // Nightlies are currently disabled — skip publishing from main - echo('ℹ️ On main branch — nightly publishing is currently disabled'); - } else if (isStableBranch(branch)) { - const stateInfo = getReleaseState(branch); - log(`react-native-macos@latest: ${stateInfo.latestVersion}`); - log(`react-native-macos@next: ${stateInfo.nextVersion}`); - log(`Current version: ${stateInfo.currentVersion}`); - log(`Release state: ${stateInfo.state}`); - - const tagInfo = getPublishTags(stateInfo, branch, options.tag); - log(`Expected npm tags: ${tagInfo.npmTags.join(', ')}`); - - await enablePublishing(tagInfo, options); - } else { - echo(`ℹ️ Branch '${branch}' is not main or a stable branch — skipping`); - } - } catch (e) { - echo(`❌ ${(e as Error).message}`); - process.exit(1); + console.log(plan.reason ?? 'All prepared versions are already published'); } } diff --git a/.github/scripts/__tests__/publishing-contract.test.mjs b/.github/scripts/__tests__/publishing-contract.test.mjs new file mode 100644 index 000000000000..076dc522aa37 --- /dev/null +++ b/.github/scripts/__tests__/publishing-contract.test.mjs @@ -0,0 +1,419 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; +import { + createPublishPlan, + parseVersion, + publishedMetadata, + publishPrepared, + publishTag, + readChangesetStatus, + readWorkspaces, + validateRelease, + canAdvanceTag, +} from '../publishing-contract.mjs'; +import {releaseAlignmentChangeset, versionWithPostbump, withReleaseConfig} from '../changeset-version-with-postbump.mts'; +import {isCurrentHead} from '../check-version-head.mjs'; + +const core = 'react-native-macos'; +const lists = '@react-native-macos/virtualized-lists'; +const branch = '0.83-stable'; +const clean = {changesets: [], releases: []}; +const require = createRequire(import.meta.url); +const metadata = (versions = [], tags = {}) => ({versions, tags}); + +function graph(version = '0.83.2') { + return [ + {name: core, version, dependencies: {[lists]: 'workspace:*', '@react-native/codegen': '0.83.1'}}, + {name: lists, version}, + {name: '@react-native/codegen', version: '0.83.1', private: true}, + {name: '@react-native-macos/internal', version: '1000.0.0', private: true}, + {name: 'react-native-macos-init', version: '2.1.3'}, + {name: '@react-native/unrelated', version: '0.83.0'}, + ]; +} + +function plan(overrides = {}) { + return createPublishPlan({workspaces: graph(), branch, status: clean, + getMetadata: async () => metadata(), ...overrides}); +} + +test('pending normal, empty, and release-only Changesets skip registry and publication', async () => { + for (const status of [ + {changesets: [{id: 'fix', releases: [{name: core, type: 'patch'}]}], releases: []}, + {changesets: [{id: 'empty', releases: []}], releases: []}, + {changesets: [], releases: [{name: core, newVersion: '0.83.3'}]}, + ]) { + const result = await plan({status, workspaces: graph('1000.0.0'), + getMetadata: () => assert.fail('Registry queried with pending Changesets')}); + publishPrepared(result, () => assert.fail('Published with pending Changesets')); + assert.deepEqual(result.packages, []); + } +}); + +test('only unpublished coupled packages publish, in runtime dependency order', async () => { + const queried = []; + const result = await plan({getMetadata: async name => { + queried.push(name); + return metadata(['0.83.1']); + }}); + assert.deepEqual(queried, [core, lists]); + assert.deepEqual(result.packages, [{name: lists, version: '0.83.2', tag: 'latest'}, {name: core, version: '0.83.2', tag: 'latest'}]); + const calls = []; + publishPrepared(result, (...args) => calls.push(args)); + assert.deepEqual(calls.map(([command, args]) => [command, args]), [lists, core].map(name => [ + 'yarn', ['workspace', name, 'npm', 'publish', '--provenance', '--tag', 'latest', '--tolerate-republish'], + ])); +}); + +test('partial publication retries only the missing package with one publish-time tag', async () => { + const result = await plan({getMetadata: async name => metadata(name === lists ? ['0.83.2'] : [])}); + assert.deepEqual(result.packages, [{name: core, version: '0.83.2', tag: 'latest'}]); + const calls = []; + publishPrepared(result, (command, args) => calls.push([command, args])); + assert.deepEqual(calls, [['yarn', ['workspace', core, 'npm', 'publish', '--provenance', '--tag', 'latest', '--tolerate-republish']]]); + const complete = await plan({getMetadata: async () => metadata(['0.83.2'], {latest: '0.83.2'})}); + publishPrepared(complete, () => assert.fail('Republished an existing version')); + assert.deepEqual(complete, {packages: [], tag: 'latest'}); +}); + +test('registry failures prevent all publication, including a failure on the last package', async () => { + await assert.rejects(plan({getMetadata: async name => { + if (name === lists) throw new Error('Registry unavailable'); + return metadata(); + }}), /Registry unavailable/); +}); + +test('invalid Changesets status fails before registry access', async () => { + await assert.rejects(plan({status: {}, + getMetadata: () => assert.fail('Queried registry without Changesets status')}), /Invalid Changesets status/); +}); + +test('tag selection uses actual versions and published stable lines, not next or latest aliases', () => { + assert.equal(publishTag('0.83.0', branch, ['0.82.9', '0.84.0-rc.1']), 'latest'); + assert.equal(publishTag('0.83.2', branch, ['0.83.1']), 'latest'); + assert.equal(publishTag('0.83.2', branch, ['0.84.0']), branch); + assert.equal(publishTag('0.83.2-rc.1', branch, ['0.84.0']), 'next'); + assert.equal(publishTag('0.83.2+build.1', branch, []), 'latest'); + assert.equal(publishTag('1.0.0', '1.0-stable', ['0.999.0']), 'latest'); +}); + +test('placeholder, malformed, branch-mismatched, and package-mismatched versions fail before registry access', async () => { + for (const version of ['1000.0.0', '1000.0.0-rc.1', '0.83', '0.83.01', '0.83.1-01', '0.84.0']) { + await assert.rejects(plan({workspaces: graph(version), + getMetadata: () => assert.fail('Queried invalid release')}), /version|match/i); + } + const workspaces = graph(); + workspaces[1].version = '0.83.1'; + assert.throws(() => validateRelease(workspaces, branch), /does not match/); + assert.throws(() => validateRelease(graph(), 'main'), /stable branch/); + assert.throws(() => validateRelease(graph().slice(1), branch), /Missing public/); + assert.throws(() => parseVersion('garbage'), /Invalid release/); +}); + +test('runtime private/local dependencies fail; explicit upstream registry ranges and private dev dependencies pass', () => { + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + for (const range of ['workspace:*', 'workspace:^', 'workspace:0.83.1', 'file:../codegen', 'link:../codegen', '1000.0.0']) { + const workspaces = graph(); + workspaces[0][field] = {'@react-native/codegen': range}; + assert.throws(() => validateRelease(workspaces, branch), /runtime|unreleasable/); + } + } + const workspaces = graph(); + workspaces[0].devDependencies = {'@react-native/codegen': 'workspace:*'}; + assert.equal(validateRelease(workspaces, branch).length, 2); + workspaces[0].optionalDependencies = {'@react-native-macos/internal': '0.83.2'}; + assert.throws(() => validateRelease(workspaces, branch), /private runtime dependency/); + delete workspaces[0].optionalDependencies; + workspaces[0].dependencies[lists] = '0.83.1'; + assert.throws(() => validateRelease(workspaces, branch), /mismatched runtime dependency/); +}); + +test('runtime graph cycles fail before publish', async () => { + const workspaces = graph(); + workspaces[1].dependencies = {[core]: 'workspace:*'}; + await assert.rejects(plan({workspaces}), /cycle/); +}); + +test('registry adapter distinguishes missing packages from auth, network, and malformed responses', async () => { + const result = await publishedMetadata(lists, async url => { + assert.equal(url, 'https://registry.npmjs.org/%40react-native-macos%2Fvirtualized-lists'); + return Response.json({versions: {'0.83.1': {}}, 'dist-tags': {latest: '0.82.0', next: '0.84.0-rc.1'}}); + }); + assert.deepEqual(result, metadata(['0.83.1'], {latest: '0.82.0', next: '0.84.0-rc.1'})); + assert.deepEqual(await publishedMetadata(core, async () => new Response(null, {status: 404})), metadata()); + for (const status of [401, 403, 429, 500]) { + await assert.rejects(publishedMetadata(core, async () => new Response(null, {status})), /Registry query failed/); + } + for (const metadata of [{}, {versions: []}, {versions: 'invalid'}]) { + await assert.rejects(publishedMetadata(core, async () => Response.json(metadata)), /Invalid registry metadata/); + } + await assert.rejects(publishedMetadata(core, async () => {throw new Error('offline');}), /offline/); +}); + +function releaseFixture(t) { + const root = mkdtempSync(join(tmpdir(), 'rnm-release-api-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + mkdirSync(join(root, '.changeset')); + writeFileSync(join(root, 'package.json'), JSON.stringify({name: 'release-fixture', private: true, workspaces: ['packages/*']})); + const config = JSON.parse(readFileSync(new URL('../../../.changeset/config.json', import.meta.url), 'utf8')); + config.baseBranch = 'origin/nonexistent'; + config.changelog = require.resolve('@changesets/cli/changelog'); + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify(config)); + const workspaces = graph(); + for (const [index, pkg] of workspaces.entries()) { + mkdirSync(join(root, `packages/p${index}`), {recursive: true}); + writeFileSync(join(root, `packages/p${index}/package.json`), JSON.stringify(pkg)); + } + return {root, workspaces}; +} + +test('real get-release-plan reads a prepared graph without a Git base or pending changesets', async t => { + const {root, workspaces} = releaseFixture(t); + const status = await readChangesetStatus(root); + assert.deepEqual(status.changesets, []); + assert.deepEqual(status.releases, []); + assert.equal((await plan({workspaces, status})).packages.length, 2); + writeFileSync(join(root, '.changeset/empty.md'), '---\n{}\n---\n'); + const pending = await readChangesetStatus(root); + assert.equal(pending.changesets.length, 1); + assert.deepEqual((await plan({workspaces, status: pending})).packages, []); +}); + +for (const changed of [core, lists]) { + test(`real Changesets version aligns both changelogs for a ${changed}-only patch`, async t => { + const {root, workspaces} = releaseFixture(t); + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${changed}": patch\n---\n\nFix release fixture.\n`); + const status = await readChangesetStatus(root); + const alignment = releaseAlignmentChangeset(workspaces, status); + if (alignment) writeFileSync(join(root, '.changeset/align.md'), alignment); + const aligned = await readChangesetStatus(root); + for (const name of [core, lists]) { + assert.equal(aligned.releases.find(pkg => pkg.name === name).newVersion, '0.83.3'); + } + const originalConfig = readFileSync(join(root, '.changeset/config.json'), 'utf8'); + await withReleaseConfig(() => { + execFileSync(process.execPath, [require.resolve('@changesets/cli/bin.js'), 'version'], { + cwd: root, encoding: 'utf8', env: {...process.env, CI: 'true'}, + }); + }, root); + assert.equal(readFileSync(join(root, '.changeset/config.json'), 'utf8'), originalConfig); + for (const index of [0, 1]) { + const pkg = JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8')); + assert.equal(pkg.version, '0.83.3'); + assert.match(readFileSync(join(root, `packages/p${index}/CHANGELOG.md`), 'utf8'), /^## 0\.83\.3$/m); + } + assert.equal(JSON.parse(readFileSync(join(root, 'packages/p4/package.json'), 'utf8')).version, '2.1.3'); + assert.deepEqual((await readChangesetStatus(root)).changesets, []); + }); +} + +test('workspace discovery follows Yarn metadata, including workspaces outside packages/', t => { + const root = mkdtempSync(join(tmpdir(), 'rnm-package-graph-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + const packages = graph(); + const locations = packages.map((pkg, index) => `tools/workspace-${index}`); + for (const [index, location] of locations.entries()) { + mkdirSync(join(root, location), {recursive: true}); + writeFileSync(join(root, location, 'package.json'), JSON.stringify(packages[index])); + } + assert.deepEqual(readWorkspaces(root, (command, args) => { + assert.equal(command, 'yarn'); + assert.deepEqual(args, ['workspaces', 'list', '--json']); + return locations.map(location => JSON.stringify({location})).join('\n') + '\n'; + }), packages); +}); + +test('new public coupled workspaces receive one upload tag; init/private/upstream packages stay excluded', async () => { + const workspaces = graph(); + workspaces.push({name: '@react-native-macos/new-package', version: '0.83.2'}); + const result = await plan({workspaces}); + assert.deepEqual(result.packages, [ + {name: lists, version: '0.83.2', tag: 'latest'}, + {name: core, version: '0.83.2', tag: 'latest'}, + {name: '@react-native-macos/new-package', version: '0.83.2', tag: 'latest'}, + ]); +}); + +test('postbump applies shared constraints before artifacts and lockfile', async () => { + let workspaces = graph('0.83.1'); + const events = []; + await versionWithPostbump({branch, getWorkspaces: () => workspaces, + withConfig: callback => callback(), + prepareAlignment: () => () => {}, + run: (command, args) => { + events.push(args.join(' ')); + if (args[0] === 'changeset') workspaces = graph('0.83.2'); + }, + updateArtifacts: async version => {events.push(`artifacts ${version}`);}, + }); + assert.deepEqual(events, ['changeset version', 'constraints --fix', 'artifacts 0.83.2', 'install --mode update-lockfile']); +}); + +test('postbump rejects an invalid graph before artifacts or lockfile', async () => { + await assert.rejects(versionWithPostbump({branch, getWorkspaces: () => graph('1000.0.0'), + withConfig: callback => callback(), + prepareAlignment: () => () => {}, + run: (command, args) => assert.notEqual(args[0], 'install'), + updateArtifacts: () => assert.fail('Updated artifacts with an invalid graph'), + }), /Invalid release version/); +}); + +test('an init-only bump does not regenerate React Native artifacts', async () => { + const workspaces = graph(); + await versionWithPostbump({branch, getWorkspaces: () => workspaces, + withConfig: callback => callback(), + prepareAlignment: () => () => {}, + run: (command, args) => { + if (args[0] === 'changeset') workspaces.find(pkg => pkg.name === 'react-native-macos-init').version = '2.1.4'; + }, + updateArtifacts: () => assert.fail('Updated artifacts for init'), + }); +}); + +test('CLI skips main and pull requests without installed dependencies or registry access', () => { + const script = new URL('../../../.ado/scripts/configure-publish.mts', import.meta.url); + for (const env of [{GITHUB_REF_NAME: 'main'}, {GITHUB_REF_NAME: branch, GITHUB_BASE_REF: branch}]) { + const output = execFileSync(process.execPath, [script.pathname, '--publish'], { + env: {...process.env, ...env, GITHUB_OUTPUT: '', TF_BUILD: ''}, encoding: 'utf8', + }); + assert.match(output, /Publication disabled/); + } +}); + +test('patch alignment includes every coupled package; stable minor and major bumps fail', () => { + for (const name of [core, lists]) { + assert.match(releaseAlignmentChangeset(graph(), {releases: [{name, type: 'patch'}]}), + new RegExp(`"${name === core ? lists : core}": patch`)); + for (const type of ['minor', 'major']) { + assert.throws(() => releaseAlignmentChangeset(graph(), {releases: [{name, type}]}), /only patch/); + } + } + for (const releases of [ + [], + [{name: core, type: 'patch'}, {name: lists, type: 'patch'}], + [{name: 'react-native-macos-init', type: 'major'}], + [{name: '@react-native-macos/internal', type: 'major'}], + ]) { + assert.equal(releaseAlignmentChangeset(graph(), {releases}), undefined); + } +}); + +test('postbump removes its temporary core Changeset if Changesets fails', async () => { + let cleaned = false; + await assert.rejects(versionWithPostbump({branch, getWorkspaces: graph, + withConfig: callback => callback(), + prepareAlignment: () => () => {cleaned = true;}, + run: () => {throw new Error('Changesets failed');}, + updateArtifacts: () => assert.fail('Updated artifacts after failure'), + }), /Changesets failed/); + assert.equal(cleaned, true); +}); + +test('ADO stage and reusable job both retain a hard-false publication condition', () => { + for (const path of ['../../../.ado/publish.yml', '../../../.ado/jobs/npm-publish.yml']) { + assert.match(readFileSync(new URL(path, import.meta.url), 'utf8'), /^\s+condition: false$/m); + } +}); + +test('full SemVer prevents tag regression by patch, prerelease number, line, and current pointer', async () => { + for (const [version, tag, versions, tags] of [ + ['0.83.2', 'latest', ['0.83.10'], {}], + ['0.83.2', branch, ['0.83.3', '0.84.0'], {}], + ['0.83.2-rc.2', 'next', ['0.83.2-rc.10'], {}], + ['0.83.2-rc.10', 'next', ['0.84.0-rc.1'], {}], + ['0.83.2-rc.2', 'next', [], {next: '0.83.2'}], + ['0.83.2', 'latest', ['0.83.1'], {latest: '0.83.3'}], + ]) { + assert.equal(canAdvanceTag(version, tag, metadata(versions, tags)), false); + } + assert.equal(canAdvanceTag('0.83.2-rc.10', 'next', metadata(['0.83.2-rc.2'])), true); + assert.equal(canAdvanceTag('0.83.2+build.2', 'latest', metadata(['0.83.2+build.1'])), true); + assert.equal(canAdvanceTag('0.83.2', branch, metadata(['0.84.0'])), true); + for (const version of ['0.83.2', '0.83.2-rc.2']) { + await assert.rejects(plan({workspaces: graph(version), getMetadata: async name => + metadata(name === lists ? [version.includes('-') ? '0.83.2-rc.10' : '0.83.10'] : [])}), /non-monotonic/); + } +}); + +test('tag choice is per package when a partial newer-line publication exists', async () => { + const result = await plan({getMetadata: async name => metadata(name === lists ? ['0.84.0'] : ['0.82.0'])}); + assert.deepEqual(result.packages.map(pkg => [pkg.name, pkg.tag]), [[lists, branch], [core, 'latest']]); +}); + +test('existing old versions neither republish nor regress any tag', async () => { + const result = await plan({getMetadata: async () => metadata(['0.83.2', '0.83.10'], {latest: '0.83.10', [branch]: '0.83.10'})}); + assert.deepEqual(result.packages, []); + publishPrepared(result, () => assert.fail('Mutated an existing old version')); +}); + +test('existing versions skip cleanly with absent, older, or different tag pointers', async () => { + for (const tags of [{}, {latest: '0.83.1'}, {next: '0.83.2'}, {latest: '0.84.0'}]) { + const state = metadata(['0.83.2'], tags); + const before = structuredClone(state); + const result = await plan({getMetadata: async () => state}); + publishPrepared(result, () => assert.fail('Published or retagged an existing version')); + assert.deepEqual(result, {packages: [], tag: 'latest'}); + assert.deepEqual(state, before); + } +}); + +test('newest stable, old-line patch, and prerelease each use one publish call without separate tag mutations', async () => { + for (const [version, versions, tag] of [ + ['0.83.0', ['0.82.9'], 'latest'], + ['0.83.2', ['0.83.0', '0.84.0'], branch], + ['0.83.3-rc.1', ['0.83.2'], 'next'], + ]) { + const result = await plan({workspaces: graph(version), getMetadata: async () => metadata(versions)}); + const calls = []; + publishPrepared(result, (command, args) => calls.push([command, args])); + assert.deepEqual(calls, [lists, core].map(name => [ + 'yarn', ['workspace', name, 'npm', 'publish', '--provenance', '--tag', tag, '--tolerate-republish'], + ])); + assert.equal(result.tag, tag); + } +}); + +test('postbump rejects incomplete alignment before constraints and rejects later version overrides', async () => { + for (const override of [false, true]) { + let workspaces = graph(); + await assert.rejects(versionWithPostbump({branch, getWorkspaces: () => workspaces, + withConfig: callback => callback(), prepareAlignment: () => () => {}, + run: (command, args) => { + if (args[0] === 'changeset') { + if (override) workspaces = graph('0.83.3'); + else workspaces[0].version = '0.83.3'; + } + if (args[0] === 'constraints') { + assert.equal(override, true, 'Constraints hid incomplete Changesets alignment'); + workspaces = graph('0.83.4'); + } + assert.notEqual(args[0], 'install'); + }, updateArtifacts: () => assert.fail('Generated inconsistent artifacts'), + }), /does not match|Constraints changed/); + } +}); + +test('temporary Changesets config restores original bytes on failure', async t => { + const {root} = releaseFixture(t); + const path = join(root, '.changeset/config.json'); + const original = readFileSync(path, 'utf8'); + await assert.rejects(withReleaseConfig(() => {throw new Error('failure');}, root), /failure/); + assert.equal(readFileSync(path, 'utf8'), original); +}); + +test('stale-head check accepts only the event SHA on the same stable branch and fails closed', () => { + const env = {GITHUB_REF: 'refs/heads/0.83-stable', GITHUB_SHA: 'a'.repeat(40)}; + assert.equal(isCurrentHead(env, (command, args) => { + assert.equal(command, 'git'); + assert.deepEqual(args, ['ls-remote', '--exit-code', 'origin', env.GITHUB_REF]); + return `${env.GITHUB_SHA}\t${env.GITHUB_REF}\n`; + }), true); + assert.equal(isCurrentHead(env, () => `${'b'.repeat(40)}\t${env.GITHUB_REF}`), false); + assert.equal(isCurrentHead({...env, GITHUB_REF: 'refs/heads/main'}, () => assert.fail()), false); + assert.throws(() => isCurrentHead(env, () => {throw new Error('network');}), /network/); +}); diff --git a/.github/scripts/changeset-version-with-postbump.mts b/.github/scripts/changeset-version-with-postbump.mts index 18ebde4b0756..68f4f58b4844 100644 --- a/.github/scripts/changeset-version-with-postbump.mts +++ b/.github/scripts/changeset-version-with-postbump.mts @@ -1,19 +1,96 @@ #!/usr/bin/env node -import { $, echo, fs } from 'zx'; -import { updateReactNativeArtifacts } from '../../scripts/releases/set-rn-artifacts-version.js'; +import {execFileSync} from 'node:child_process'; +import {randomUUID} from 'node:crypto'; +import {readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {readChangesetStatus, readWorkspaces, releasePackages, validateRelease, validateReleaseVersions} from './publishing-contract.mjs'; +import {isCurrentHead} from './check-version-head.mjs'; -// Step 1: Run changeset version to bump package.json files and update CHANGELOGs -echo('📦 Running changeset version...'); -await $`yarn changeset version`; +// Stable branches accept patch releases only. Every coupled package must be in +// the Changesets plan so each changelog describes the version actually published. +export function releaseAlignmentChangeset(workspaces, status) { + const names = new Set(releasePackages(workspaces).map(pkg => pkg.name)); + const bumped = new Set(); + for (const release of status.releases) { + if (!names.has(release.name)) continue; + if (!['none', 'patch'].includes(release.type)) { + throw new Error(`Stable release policy permits only patch bumps: ${release.name} ${release.type}`); + } + if (release.type === 'patch') bumped.add(release.name); + } + const missing = [...names].filter(name => !bumped.has(name)); + return bumped.size && missing.length + ? `---\n${missing.map(name => `"${name}": patch`).join('\n')}\n---\n\nAlign the React Native macOS release with its public workspace packages.\n` + : undefined; +} -// Step 2: Update native artifacts to match the new react-native version -echo('\n🔄 Updating React Native native artifacts...'); -const { version } = fs.readJsonSync('packages/react-native/package.json'); -await updateReactNativeArtifacts(version); -echo('✅ Native artifacts updated'); +async function prepareReleaseAlignment(workspaces) { + const contents = releaseAlignmentChangeset(workspaces, await readChangesetStatus()); + if (!contents) return () => {}; + const path = `.changeset/rnm-alignment-${randomUUID()}.md`; + writeFileSync(path, contents, {flag: 'wx'}); + return () => rmSync(path, {force: true}); +} -// Step 4: Update yarn.lock to reflect all changes -echo('\n🔒 Updating yarn.lock...'); -await $`yarn install --mode update-lockfile`; +export async function withReleaseConfig(callback, root = process.cwd()) { + const path = join(root, '.changeset/config.json'); + const original = readFileSync(path, 'utf8'); + const config = JSON.parse(original); + // Match the API adapter. Otherwise the CLI refuses explicit registry deps on + // private upstream workspaces, even though they are not local release edges. + writeFileSync(path, JSON.stringify({...config, bumpVersionsWithWorkspaceProtocolOnly: true}, null, 2) + '\n'); + try { + return await callback(); + } finally { + writeFileSync(path, original); + } +} -echo('\n✅ Version bump complete!'); +export async function versionWithPostbump({ + run = execFileSync, + getWorkspaces = readWorkspaces, + prepareAlignment = prepareReleaseAlignment, + withConfig = withReleaseConfig, + branch = process.env.GITHUB_REF_NAME ?? execFileSync('git', ['branch', '--show-current'], {encoding: 'utf8'}).trim(), + updateArtifacts = async (version: string) => { + const {updateReactNativeArtifacts} = await import('../../scripts/releases/set-rn-artifacts-version.js'); + await updateReactNativeArtifacts(version); + }, +} = {}) { + const before = getWorkspaces(); + validateReleaseVersions(before, branch); + const oldVersion = before.find(pkg => pkg.name === 'react-native-macos')?.version; + await withConfig(async () => { + const cleanup = await prepareAlignment(before); + try { + run('yarn', ['changeset', 'version'], {stdio: 'inherit'}); + } finally { + cleanup(); + } + }); + + // Reject incomplete Changesets alignment before constraints can hide it. + const versioned = validateReleaseVersions(getWorkspaces(), branch); + // Apply shared dependency/private-workspace constraints before artifacts. These + // constraints must not change public release versions after changelog generation. + run('yarn', ['constraints', '--fix'], {stdio: 'inherit'}); + const packages = validateRelease(getWorkspaces(), branch); + for (const pkg of versioned) { + if (packages.find(candidate => candidate.name === pkg.name)?.version !== pkg.version) { + throw new Error(`Constraints changed the Changesets version of ${pkg.name}`); + } + } + const {version} = packages.find(pkg => pkg.name === 'react-native-macos'); + if (version !== oldVersion) await updateArtifacts(version); + + run('yarn', ['install', '--mode', 'update-lockfile'], {stdio: 'inherit'}); + console.log('Version bump complete'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await versionWithPostbump(); + if (process.env.GITHUB_ACTIONS === 'true' && !isCurrentHead()) { + throw new Error('Stable branch advanced during the version bump; a newer workflow must update the PR'); + } +} diff --git a/.github/scripts/check-version-head.mjs b/.github/scripts/check-version-head.mjs new file mode 100644 index 000000000000..9f55dde0c00a --- /dev/null +++ b/.github/scripts/check-version-head.mjs @@ -0,0 +1,20 @@ +import {execFileSync} from 'node:child_process'; +import {appendFileSync} from 'node:fs'; +import {pathToFileURL} from 'node:url'; + +export function isCurrentHead(env = process.env, run = execFileSync) { + if (!/^refs\/heads\/\d+\.\d+-stable$/.test(env.GITHUB_REF ?? '') || !env.GITHUB_SHA) { + return false; + } + const remote = run('git', ['ls-remote', '--exit-code', 'origin', env.GITHUB_REF], { + encoding: 'utf8', timeout: 60000, + }).trim(); + const [sha, ref] = remote.split(/\s+/); + return sha === env.GITHUB_SHA && ref === env.GITHUB_REF; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const current = isCurrentHead(); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `current=${current}\n`); + console.log(current ? 'Version workflow matches the stable branch head' : 'Skip stale or non-stable version workflow'); +} diff --git a/.github/scripts/publishing-contract.md b/.github/scripts/publishing-contract.md new file mode 100644 index 000000000000..7dacacb1cfc0 --- /dev/null +++ b/.github/scripts/publishing-contract.md @@ -0,0 +1,53 @@ +# React Native macOS publication contract + +- `microsoft-changesets-version.yml` automatically creates the Changesets version PR. +- `microsoft-npm-publish.yml` publishes prepared versions on stable-branch pushes through Yarn 4.12 Trusted Publishing. +- `.ado/publish.yml` and `.ado/jobs/npm-publish.yml` retain hard-false conditions. ADO publication remains disabled until explicitly re-enabled. + +## Scope and preparation + +The automatic release consists of `react-native-macos` and public `@react-native-macos/*` workspaces discovered through Yarn. `react-native-macos-init` has an independent release process and is excluded, even when its local version is unpublished. + +The version wrapper permits only patch bumps for the coupled release packages on stable branches. It adds a temporary Changeset for every coupled package absent from a patch plan. Changesets then generates every release version and changelog together. The wrapper rejects mismatched versions before `yarn constraints --fix`, and rejects any subsequent public version override by those constraints. Shared dependency constraints still run before native artifacts and the lockfile. An init-only bump does not regenerate core artifacts. + +Pending Changesets come from the declared `@changesets/get-release-plan` API, with no `sinceRef`; the CLI's default base-branch comparison is not used. The API and version wrapper use `bumpVersionsWithWorkspaceProtocolOnly: true`, because explicit registry references to private upstream workspaces are external dependencies. The wrapper restores the original Changesets configuration after the CLI returns, including on failure. + +Stable branches must already have their initial release version and React Native peer configured. The publication script does not turn `1000.0.0` into a release. All public release packages must match the core version and branch. Private runtime workspace links are invalid; explicit registry references to the separately published upstream `@react-native/*` packages are valid. Development-only private workspace links are allowed. + +## Publication and tags + +Any pending Changeset, including an empty Changeset, skips publication. Registry failures fail the run. The script validates the complete package graph and queries every selected package before publication. It publishes only absent versions in dependency order, so retries skip versions already published. A release consists of multiple npm writes, not an atomic registry transaction. + +A new upload receives exactly one npm tag, matching the single-tag policy in `9cc1f0aeca8`. A real prerelease uses `next`. A stable version uses `latest` unless that package already has a stable version from a newer release line. An older release line uses its branch tag, such as `0.83-stable`. Full SemVer comparison checks each package's published versions and current tag pointers. An unpublished older patch or prerelease fails before any package is published if its tag would regress. + +For example, `0.83.0` publishes with `latest` when it is the newest stable line. After `0.84.0` exists, a new `0.83` patch publishes with `0.83-stable`. Yarn applies that one tag during publication; there is no separate tag call. + +Existing versions skip successfully, including partial retries and versions with absent or different tags. The workflow does not promote existing versions or repair tags. The original `.ado/scripts/apply-additional-tags.mjs` remains retained and inactive behind the disabled ADO route. Trusted Publishing needs no additional credentials for this single-tag policy. + +## Concurrency + +Publication remains push-triggered. Its global concurrency group uses `queue: max`, which [GitHub.com documents](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) as up to 100 pending runs. Overflow runs are canceled; queue order is the order runs start waiting, not necessarily push order. Registry monotonicity checks therefore remain necessary. No manual trigger was added. + +Changesets uses a separate per-branch concurrency group with `cancel-in-progress: true`. It checks the remote stable head before the action and again after the version script, to reject stale reruns or a branch advance during preparation. A branch can still advance after the final check; concurrency cancellation limits that race but is not a compare-and-swap update of the version PR. + +Each selected package needs an npm Trusted Publisher for: + +- Repository: `microsoft/react-native-macos` +- Workflow: `microsoft-npm-publish.yml` +- Environment: `npm-publish` +- Direct publication permission + +These remote package settings cannot be established by the local tests. See [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/). + +## Checks + +Run with Node 22.22.0: + +```sh +node --test .github/scripts/__tests__/publishing-contract.test.mjs +actionlint .github/workflows/microsoft-npm-publish.yml .github/workflows/microsoft-changesets-version.yml +``` + +The tests use real `get-release-plan` and real Changesets version commands on temporary package graphs, including absent Git base refs and both core-only and scoped-only changes. They verify both generated changelogs. Registry publication remains mocked; tests verify one tag per upload and no separate tag mutations. + +`actionlint` 1.7.12 does not recognize the documented `queue` property. Its unfiltered run reports that one syntax diagnostic; use a newer supporting release when available. This is a local lint compatibility limitation, not proof of a successful GitHub workflow run. diff --git a/.github/scripts/publishing-contract.mjs b/.github/scripts/publishing-contract.mjs new file mode 100644 index 000000000000..7515de74de5b --- /dev/null +++ b/.github/scripts/publishing-contract.mjs @@ -0,0 +1,198 @@ +import {execFileSync} from 'node:child_process'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {join} from 'node:path'; + +const require = createRequire(import.meta.url); +const semver = require('semver'); + +export const registry = 'https://registry.npmjs.org'; + +export function parseVersion(version) { + const parsed = typeof version === 'string' && semver.parse(version); + if (!parsed || parsed.major === 1000 || !/^\d/.test(version)) { + throw new Error(`Invalid release version: ${version}`); + } + return {major: parsed.major, minor: parsed.minor, prerelease: parsed.prerelease.length > 0}; +} + +export function isStableBranch(branch) { + return /^(0|[1-9]\d*)\.(0|[1-9]\d*)-stable$/.test(branch); +} + +export function readWorkspaces(root = process.cwd(), run = execFileSync) { + const output = run('yarn', ['workspaces', 'list', '--json'], { + cwd: root, + encoding: 'utf8', + }); + return output.trim().split('\n').map(line => { + const {location} = JSON.parse(line); + return JSON.parse(readFileSync(join(root, location, 'package.json'), 'utf8')); + }); +} + +// The init CLI has its own version and release process. Never include all public +// workspaces: only these packages follow the React Native macOS release line. +export function releasePackages(workspaces) { + return workspaces.filter(pkg => !pkg.private && ( + pkg.name === 'react-native-macos' || pkg.name.startsWith('@react-native-macos/') + )); +} + +export function validateReleaseVersions(workspaces, branch) { + if (!isStableBranch(branch)) { + throw new Error(`Expected a stable branch, got: ${branch}`); + } + const packages = releasePackages(workspaces); + const core = packages.find(pkg => pkg.name === 'react-native-macos'); + if (!core) { + throw new Error('Missing public react-native-macos workspace'); + } + const version = parseVersion(core.version); + if (`${version.major}.${version.minor}-stable` !== branch) { + throw new Error(`Version ${core.version} does not match ${branch}`); + } + for (const pkg of packages) { + parseVersion(pkg.version); + if (pkg.version !== core.version) { + throw new Error(`${pkg.name}@${pkg.version} does not match ${core.version}`); + } + } + return packages; +} + +export function validateRelease(workspaces, branch) { + const packages = validateReleaseVersions(workspaces, branch); + const core = packages.find(pkg => pkg.name === 'react-native-macos'); + const byName = new Map(workspaces.map(pkg => [pkg.name, pkg])); + const selected = new Set(packages.map(pkg => pkg.name)); + for (const pkg of packages) { + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + for (const [name, range] of Object.entries(pkg[field] ?? {})) { + const dependency = byName.get(name); + // Private upstream workspaces are valid registry dependencies only when + // the manifest contains a registry range, not a local workspace link. + if (dependency?.private && !name.startsWith('@react-native/')) { + throw new Error(`${pkg.name} has a private runtime dependency: ${name}`); + } + if (/(^|[^\d])1000\./.test(range) || /^(file|link|portal):/.test(range)) { + throw new Error(`${pkg.name} has an unreleasable ${field} entry: ${name}@${range}`); + } + if (range.startsWith('workspace:') && (!dependency || dependency.private || !selected.has(name))) { + throw new Error(`${pkg.name} has a private or out-of-scope runtime workspace dependency: ${name}`); + } + if (selected.has(name) && ![ + core.version, `^${core.version}`, `~${core.version}`, + 'workspace:*', 'workspace:^', 'workspace:~', + `workspace:${core.version}`, `workspace:^${core.version}`, `workspace:~${core.version}`, + ].includes(range)) { + throw new Error(`${pkg.name} has a mismatched runtime dependency: ${name}@${range}`); + } + } + } + } + return packages; +} + +export async function readChangesetStatus(root = process.cwd()) { + // Unlike `changeset status`, this API does not default to config.baseBranch. + // Omit sinceRef to inspect ALL pending Changesets, including empty changesets. + const getReleasePlan = require('@changesets/get-release-plan').default; + // Explicit registry dependencies on private upstream workspaces are external + // releases. Only workspace: links participate in dependency bump propagation. + const status = await getReleasePlan(root, undefined, {bumpVersionsWithWorkspaceProtocolOnly: true}); + if (!Array.isArray(status.changesets) || !Array.isArray(status.releases)) { + throw new Error('Invalid Changesets status'); + } + return status; +} + +export async function publishedMetadata(name, fetchRegistry = fetch) { + const response = await fetchRegistry(`${registry}/${encodeURIComponent(name)}`, { + signal: AbortSignal.timeout(60000), + }); + if (response.status === 404) return {versions: [], tags: {}}; + if (!response.ok) throw new Error(`Registry query failed for ${name}: ${response.status}`); + const metadata = await response.json(); + if (!metadata?.versions || typeof metadata.versions !== 'object' || Array.isArray(metadata.versions) || + !metadata['dist-tags'] || typeof metadata['dist-tags'] !== 'object' || Array.isArray(metadata['dist-tags'])) { + throw new Error(`Invalid registry metadata for ${name}`); + } + for (const version of [...Object.keys(metadata.versions), ...Object.values(metadata['dist-tags'])]) { + parseVersion(version); + } + return {versions: Object.keys(metadata.versions), tags: metadata['dist-tags']}; +} + +export function publishTag(version, branch, published) { + const current = parseVersion(version); + if (current.prerelease) return 'next'; + const newerLine = published.some(value => { + const other = parseVersion(value); + return !other.prerelease && (other.major > current.major || + (other.major === current.major && other.minor > current.minor)); + }); + return newerLine ? branch : 'latest'; +} + +// Never move a tag backwards, even if its current pointer is stale or absent. +// Compare full SemVer (including numeric prerelease identifiers), per package. +export function canAdvanceTag(version, tag, metadata) { + const target = parseVersion(version); + const candidates = metadata.versions.filter(value => { + const other = parseVersion(value); + if (tag === 'next') return other.prerelease; + if (tag === 'latest') return !other.prerelease; + return !other.prerelease && other.major === target.major && other.minor === target.minor; + }); + if (metadata.tags[tag]) candidates.push(metadata.tags[tag]); + return candidates.every(value => semver.gte(version, value)); +} + +export async function createPublishPlan({workspaces, branch, status, getMetadata = publishedMetadata}) { + if (!Array.isArray(status.changesets) || !Array.isArray(status.releases)) { + throw new Error('Invalid Changesets status'); + } + // Even an empty changeset must first pass through the automatic version PR. + if (status.changesets.length || status.releases.length) { + return {packages: [], reason: 'Pending Changesets; waiting for the version PR'}; + } + const packages = validateRelease(workspaces, branch); + const core = packages.find(pkg => pkg.name === 'react-native-macos'); + const published = new Map(); + for (const pkg of packages) published.set(pkg.name, await getMetadata(pkg.name)); + const tag = publishTag(core.version, branch, published.get(core.name).versions); + // Validate the complete graph before the first publish, including on retries. + const ordered = []; + const visiting = new Set(); + const visited = new Set(); + const byName = new Map(packages.map(pkg => [pkg.name, pkg])); + function visit(pkg) { + if (visited.has(pkg.name)) return; + if (visiting.has(pkg.name)) throw new Error(`Runtime dependency cycle: ${pkg.name}`); + visiting.add(pkg.name); + for (const name of Object.keys({...pkg.dependencies, ...pkg.optionalDependencies})) { + if (byName.has(name)) visit(byName.get(name)); + } + visiting.delete(pkg.name); + visited.add(pkg.name); + const metadata = published.get(pkg.name); + const exists = metadata.versions.includes(pkg.version); + if (!exists) { + const packageTag = publishTag(pkg.version, branch, metadata.versions); + if (!canAdvanceTag(pkg.version, packageTag, metadata)) { + throw new Error(`Refusing non-monotonic publication: ${pkg.name}@${pkg.version} -> ${packageTag}`); + } + ordered.push({name: pkg.name, version: pkg.version, tag: packageTag}); + } + } + for (const pkg of packages) visit(pkg); + return {packages: ordered, tag}; +} + +export function publishPrepared(plan, run = execFileSync) { + for (const pkg of plan.packages) { + run('yarn', ['workspace', pkg.name, 'npm', 'publish', '--provenance', + '--tag', pkg.tag, '--tolerate-republish'], {stdio: 'inherit'}); + } +} diff --git a/.github/workflows/microsoft-changesets-version.yml b/.github/workflows/microsoft-changesets-version.yml index 7d7ed8d1a030..00ae52403e10 100644 --- a/.github/workflows/microsoft-changesets-version.yml +++ b/.github/workflows/microsoft-changesets-version.yml @@ -6,6 +6,10 @@ on: - "*-stable" workflow_dispatch: +concurrency: + group: changesets-version-${{ github.ref }} + cancel-in-progress: true + jobs: version: name: Create Version Bump PR @@ -28,6 +32,9 @@ jobs: - name: Install dependencies run: yarn install --immutable + - name: Test publishing contract + run: node --test .github/scripts/__tests__/publishing-contract.test.mjs + - name: Generate token for version PR uses: actions/create-github-app-token@v2 id: app-token @@ -37,7 +44,12 @@ jobs: permission-contents: write # for GH releases and Git tags (Changesets) permission-pull-requests: write # version PRs (Changesets) + - name: Check stable branch head + id: current-head + run: node .github/scripts/check-version-head.mjs + - name: Create Version Bump PR + if: steps.current-head.outputs.current == 'true' uses: changesets/action@v1 with: version: yarn changeset:version diff --git a/.github/workflows/microsoft-npm-publish.yml b/.github/workflows/microsoft-npm-publish.yml index 3f196f0a8516..dbf5380d275a 100644 --- a/.github/workflows/microsoft-npm-publish.yml +++ b/.github/workflows/microsoft-npm-publish.yml @@ -5,6 +5,13 @@ on: branches: - "*-stable" +concurrency: + # Serialize release lines because they share the latest and next tags. + group: npm-publish + # GitHub.com supports up to 100 pending runs; do not replace a release push. + queue: max + cancel-in-progress: false + jobs: publish: name: Publish to npm @@ -34,26 +41,14 @@ jobs: - name: Install dependencies run: yarn install --immutable - - name: Verify release config + - name: Test publishing contract + run: node --test .github/scripts/__tests__/publishing-contract.test.mjs + + # Changesets prepares versions in its automatic PR. This step never bumps + # versions and skips pushes with pending changesets or no unpublished versions. + - name: Publish prepared packages id: configure-publish - run: node .ado/scripts/configure-publish.mts --verbose - - - name: Configure yarn for npm publishing - if: steps.configure-publish.outputs.publish_react_native_macos == '1' - run: | - yarn config set npmPublishAccess public - yarn config set npmPublishRegistry "https://registry.npmjs.org" - - - name: Publish packages - if: steps.configure-publish.outputs.publish_react_native_macos == '1' - run: | - yarn workspaces foreach -vv --all --topological --no-private npm publish \ - --provenance \ - --tag "${{ steps.configure-publish.outputs.publishTag }}" \ - --tolerate-republish - - - name: Remove npm auth configuration - if: always() - run: | - yarn config unset npmPublishAccess || true - yarn config unset npmPublishRegistry || true + run: node .ado/scripts/configure-publish.mts --publish --verbose + env: + YARN_NPM_PUBLISH_ACCESS: public + YARN_NPM_PUBLISH_REGISTRY: https://registry.npmjs.org diff --git a/package.json b/package.json index 77f4780a4d1d..e890208df87c 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@babel/traverse": "^7.25.2", "@babel/types": "^7.25.2", "@changesets/cli": "^2.28.1", + "@changesets/get-release-plan": "^4.0.14", "@electron/packager": "^18.3.6", "@expo/spawn-async": "^1.7.2", "@jest/create-cache-key-function": "^29.7.0", @@ -138,6 +139,7 @@ "react": "19.2.0", "react-test-renderer": "19.2.0", "rimraf": "^3.0.2", + "semver": "^7.1.3", "shelljs": "^0.8.5", "signedsource": "^2.0.0", "supports-color": "^7.1.0", diff --git a/yarn.lock b/yarn.lock index d735b7e7e302..9da7cbc19f61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3936,6 +3936,7 @@ __metadata: "@babel/traverse": "npm:^7.25.2" "@babel/types": "npm:^7.25.2" "@changesets/cli": "npm:^2.28.1" + "@changesets/get-release-plan": "npm:^4.0.14" "@electron/packager": "npm:^18.3.6" "@expo/spawn-async": "npm:^1.7.2" "@jest/create-cache-key-function": "npm:^29.7.0" @@ -4008,6 +4009,7 @@ __metadata: react: "npm:19.2.0" react-test-renderer: "npm:19.2.0" rimraf: "npm:^3.0.2" + semver: "npm:^7.1.3" shelljs: "npm:^0.8.5" signedsource: "npm:^2.0.0" supports-color: "npm:^7.1.0" From ac2c7663e528f9c11186fb4c4e2e287851f867e6 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 17:56:02 -0500 Subject: [PATCH 08/38] fix(release): validate prepared version PRs against their base Accept consumed Changesets only when every changed public workspace has a valid version transition and a new nonempty changelog section. Permit exact matching 1000.0.0-to-initial-stable bootstrap versions. Validate deleted public workspaces against base workspace membership and reject unrelated deletions or cross-workspace moves. Independent review verified 48 Node 22 tests and the deletion bypass regressions. --- .../__tests__/publishing-contract.test.mjs | 322 +++++++++++++++++- .github/scripts/change.mts | 30 +- .github/scripts/publishing-contract.mjs | 96 +++++- 3 files changed, 434 insertions(+), 14 deletions(-) diff --git a/.github/scripts/__tests__/publishing-contract.test.mjs b/.github/scripts/__tests__/publishing-contract.test.mjs index 076dc522aa37..151404b74843 100644 --- a/.github/scripts/__tests__/publishing-contract.test.mjs +++ b/.github/scripts/__tests__/publishing-contract.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import {execFileSync} from 'node:child_process'; -import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; import {createRequire} from 'node:module'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -14,10 +14,12 @@ import { readChangesetStatus, readWorkspaces, validateRelease, + validatePreparedVersionPR, canAdvanceTag, } from '../publishing-contract.mjs'; import {releaseAlignmentChangeset, versionWithPostbump, withReleaseConfig} from '../changeset-version-with-postbump.mts'; import {isCurrentHead} from '../check-version-head.mjs'; +import {runCheck} from '../change.mts'; const core = 'react-native-macos'; const lists = '@react-native-macos/virtualized-lists'; @@ -417,3 +419,321 @@ test('stale-head check accepts only the event SHA on the same stable branch and assert.equal(isCurrentHead({...env, GITHUB_REF: 'refs/heads/main'}, () => assert.fail()), false); assert.throws(() => isCurrentHead(env, () => {throw new Error('network');}), /network/); }); + +function preparedFixture(t, { + oldVersion = '0.83.1', version = '0.83.2', target = branch, + privateLists = false, head = 'arbitrary-release-name', + editBase = () => {}, editHead = () => {}, +} = {}) { + const {root} = releaseFixture(t); + const git = args => execFileSync('git', args, { + cwd: root, encoding: 'utf8', + env: {...process.env, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com', + GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com'}, + }); + const writePackage = (index, pkg) => writeFileSync(join(root, `packages/p${index}/package.json`), JSON.stringify(pkg)); + const writeChangelog = (index, text) => writeFileSync(join(root, `packages/p${index}/CHANGELOG.md`), text); + const old = graph(oldVersion); + old[1].private = privateLists; + old.forEach((pkg, index) => writePackage(index, pkg)); + for (const index of [0, 1]) writeChangelog(index, `# Changelog\n\n## ${oldVersion}\n\nOld release.\n`); + writeFileSync(join(root, '.changeset/bootstrap.md'), `---\n"${core}": patch\n---\n\nPrepare release.\n`); + editBase({root, workspaces: old, writePackage, writeChangelog}); + git(['init', '-q', '-b', target]); + const commit = () => { + git(['add', '.']); + git(['-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Fixture state']); + }; + commit(); + const base = git(['rev-parse', 'HEAD']).trim(); + git(['switch', '-qc', head]); + const workspaces = graph(version); + const locations = workspaces.map((pkg, index) => `packages/p${index}`); + workspaces.forEach((pkg, index) => writePackage(index, pkg)); + for (const index of [0, 1]) { + writeChangelog(index, `# Changelog\n\n## ${version}\n\n### Patch Changes\n\n- Prepare release.\n\n## ${oldVersion}\n\nOld release.\n`); + } + rmSync(join(root, '.changeset/bootstrap.md')); + editHead({root, workspaces, locations, writePackage, writeChangelog}); + commit(); + const run = (command, args, options) => { + if (command === 'git') return execFileSync(command, args, options); + assert.equal(command, 'yarn'); + assert.deepEqual(args, ['workspaces', 'list', '--json']); + assert.equal(options.cwd, root); + return locations.map(location => JSON.stringify({location})).join('\n'); + }; + const validate = (overrides = {}) => validatePreparedVersionPR({root, baseBranch: target, run, ...overrides}); + return {root, git, commit, base, validate, run, writePackage, writeChangelog}; +} + +test('prepared bootstrap accepts every current public package, including private-to-public lists, on matching stable lines', async t => { + for (const minor of [81, 83, 84]) { + const fixture = preparedFixture(t, {oldVersion: '1000.0.0', version: `0.${minor}.0`, + target: `0.${minor}-stable`, privateLists: true}); + assert.equal(await fixture.validate(), true); + } +}); + +test('prepared patch validates real merge-base evidence when the target advances independently', async t => { + const fixture = preparedFixture(t); + fixture.git(['switch', '-q', branch]); + for (const index of [0, 1]) { + fixture.writePackage(index, graph('0.83.9')[index]); + fixture.writeChangelog(index, '# Changelog\n\n## 0.83.2\n\nUnrelated target history.\n'); + } + fixture.commit(); + fixture.git(['switch', '-q', 'arbitrary-release-name']); + assert.equal(fixture.git(['merge-base', branch, 'HEAD']).trim(), fixture.base); + assert.equal(await fixture.validate(), true); +}); + +test('prepared transitions require an increase or the exact bootstrap version', async t => { + for (const [oldVersion, version, target] of [ + ['0.83.2', '0.83.2', branch], + ['0.83.3', '0.83.2', branch], + ['0.83.2+build.1', '0.83.2+build.2', branch], + ['1000.0.0', '0.83.1', branch], + ['1000.0.0', '0.83.0-rc.1', branch], + ['1000.0.0', '0.83.0+build.1', branch], + ['1000.0.1', '0.83.0', branch], + ['1000.0.0-rc.1', '0.83.0', branch], + ['1000.0.0', '1.0.0', '1.0-stable'], + ['1000.0.0', '0.84.0', branch], + ]) { + const fixture = preparedFixture(t, {oldVersion, version, target}); + await assert.rejects(fixture.validate(), /Version must increase|Invalid release version|does not match/); + } + const fixture = preparedFixture(t, {oldVersion: '0.83.2-rc.1', version: '0.83.2'}); + assert.equal(await fixture.validate(), true); +}); + +test('every changed public package needs its own new nonempty version section', async t => { + for (const index of [0, 1]) { + for (const text of [ + undefined, + '# Changelog\n\n## 0.83.1\n\nOld release.\n', + '# Changelog\n\n## 0.83.2\n\n### Patch Changes\n\n\n\n## 0.83.1\n\nOld release.\n', + '# Changelog\n\n## 0.83.2\n\nOne.\n\n## 0.83.2\n\nTwo.\n', + ]) { + const fixture = preparedFixture(t, {editHead: ({root, writeChangelog}) => { + if (text === undefined) rmSync(join(root, `packages/p${index}/CHANGELOG.md`)); + else writeChangelog(index, text); + }}); + await assert.rejects(fixture.validate(), /changelog section|CHANGELOG\.md/i); + } + const fixture = preparedFixture(t, {editBase: ({writeChangelog}) => { + writeChangelog(index, '# Changelog\n\n## 0.83.2\n\nExisting release.\n'); + }}); + await assert.rejects(fixture.validate(), /new changelog section/); + } +}); + +test('a new changelog file is valid, but an absent base manifest is not a version transition', async t => { + const fixture = preparedFixture(t, {editBase: ({root}) => { + for (const index of [0, 1]) rmSync(join(root, `packages/p${index}/CHANGELOG.md`)); + }}); + assert.equal(await fixture.validate(), true); + const missing = preparedFixture(t, {editBase: ({root}) => { + rmSync(join(root, 'packages/p1/package.json')); + }}); + await assert.rejects(missing.validate(), /Missing merge-base version/); +}); + +test('private-to-public lists cannot reuse the old version or omit their release notes', async t => { + for (const mode of ['same-version', 'missing-notes']) { + const fixture = preparedFixture(t, {privateLists: true, editHead: ({writePackage, writeChangelog}) => { + if (mode === 'same-version') writePackage(1, graph('0.83.1')[1]); + else writeChangelog(1, '# Changelog\n'); + }}); + await assert.rejects(fixture.validate(), /does not match|new changelog section/); + } + const fixture = preparedFixture(t, {privateLists: true, editBase: ({writePackage}) => { + writePackage(1, {...graph('0.83.2')[1], private: true}); + }}); + await assert.rejects(fixture.validate(), /Version must increase for @react-native-macos\/virtualized-lists/); +}); + +test('prepared PR rejects mismatched releases and invalid private or out-of-scope runtime links', async t => { + for (const edit of [ + pkg => {pkg.version = '0.83.3';}, + pkg => {pkg.private = true;}, + pkg => {pkg.dependencies = {'@react-native/codegen': 'workspace:*'};}, + pkg => {pkg.optionalDependencies = {'@react-native-macos/internal': '0.83.2'};}, + pkg => {pkg.peerDependencies = {'react-native-macos-init': 'workspace:*'};}, + ]) { + const fixture = preparedFixture(t, {editHead: ({workspaces, writePackage}) => { + edit(workspaces[0]); + writePackage(0, workspaces[0]); + }}); + await assert.rejects(fixture.validate(), /does not match|Missing public|runtime/); + } +}); + +test('all changed public packages must belong to the release group, including source-only changes', async t => { + for (const index of [4, 5]) { + const fixture = preparedFixture(t, {editHead: ({root}) => { + writeFileSync(join(root, `packages/p${index}/source.js`), 'export const changed = true;\n'); + }}); + await assert.rejects(fixture.validate(), /outside the release group/); + } + const fixture = preparedFixture(t, {editHead: ({root}) => { + writeFileSync(join(root, 'packages/p3/source.js'), 'export const privateChange = true;\n'); + }}); + assert.equal(await fixture.validate(), true); +}); + +test('source-only public changes cannot use a prepared-looking head name as an exemption', async t => { + const fixture = preparedFixture(t, {head: 'changeset-release/0.83-stable', editHead: ({root, writePackage, writeChangelog}) => { + for (const index of [0, 1]) { + writePackage(index, {...graph('0.83.1')[index], ...(index === 1 ? {private: false} : {})}); + writeChangelog(index, '# Changelog\n\n## 0.83.1\n\nOld release.\n'); + } + writeFileSync(join(root, 'packages/p0/source.js'), 'export const changed = true;\n'); + }}); + await assert.rejects(fixture.validate(), /Version must increase/); + assert.equal(await fixture.validate({branch: 'main'}), false); +}); + +test('pending API state always uses the normal check, including empty Changesets and release-only state', async t => { + const fixture = preparedFixture(t); + for (const status of [ + {changesets: [{id: 'pending', releases: [{name: core, type: 'patch'}]}], releases: []}, + {changesets: [{id: 'empty', releases: []}], releases: []}, + {changesets: [], releases: [{name: core, type: 'patch'}]}, + ]) { + let normalChecks = 0; + await runCheck(branch, { + validatePrepared: () => fixture.validate({ + run: () => assert.fail('Inspected Git or packages with pending Changesets'), + getStatus: root => readChangesetStatus(root, async (actualRoot, sinceRef, config) => { + assert.equal(actualRoot, fixture.root); + assert.equal(sinceRef, undefined); + assert.deepEqual(config, {bumpVersionsWithWorkspaceProtocolOnly: true}); + return status; + }), + }), + getStatus: async baseBranch => { + assert.equal(baseBranch, branch); + normalChecks++; + return {data: {releases: [], changesets: []}, exitCode: 0}; + }, + }); + assert.equal(normalChecks, 1); + } + writeFileSync(join(fixture.root, '.changeset/empty.md'), '---\n{}\n---\n'); + assert.equal(await fixture.validate(), false); +}); + +test('prepared check shares validation with the CLI and propagates API and Git errors', async t => { + const fixture = preparedFixture(t); + const getStatus = () => assert.fail('Ran normal check after prepared success or error'); + await runCheck(branch, {validatePrepared: () => fixture.validate(), getStatus}); + for (const overrides of [ + {getStatus: () => {throw new Error('release API failed');}}, + {getStatus: () => ({})}, + {baseBranch: 'missing/0.83-stable'}, + {run: () => {throw new Error('command failed');}}, + ]) { + await assert.rejects(runCheck(branch, {validatePrepared: () => fixture.validate(overrides), getStatus}), + /release API failed|Invalid Changesets status|Not a valid object name|command failed/); + } + await assert.rejects(runCheck(branch, {validatePrepared: async () => false, + getStatus: async () => {throw new Error('normal check failed');}}), /normal check failed/); +}); + +test('normal check still rejects missing Changesets and major bumps', async t => { + t.mock.method(process, 'exit', code => {throw new Error(`Exit ${code}`);}); + for (const result of [ + {data: {releases: [], changesets: []}, exitCode: 1}, + {data: {releases: [{name: core, type: 'major', changesets: ['breaking']}], changesets: ['breaking']}, exitCode: 0}, + ]) { + await assert.rejects(runCheck(branch, {validatePrepared: async () => false, + getStatus: async () => result}), /Exit 1/); + } +}); + +test('no changed public package uses the normal check', async t => { + const fixture = preparedFixture(t, {editHead: ({writePackage, writeChangelog}) => { + for (const index of [0, 1]) { + writePackage(index, {...graph('0.83.1')[index], ...(index === 1 ? {private: false} : {})}); + writeChangelog(index, '# Changelog\n\n## 0.83.1\n\nOld release.\n'); + } + }}); + assert.equal(await fixture.validate(), false); +}); + +test('a valid prepared bump cannot hide a deleted unrelated public workspace', async t => { + for (const index of [4, 5]) { + const fixture = preparedFixture(t, {editHead: ({root, locations}) => { + rmSync(join(root, `packages/p${index}`), {recursive: true}); + locations.splice(index, 1); + }}); + await assert.rejects(fixture.validate(), /Deleted or moved public workspace/); + } +}); + +test('a valid prepared bump cannot hide a public workspace moved into another workspace or to a new location', async t => { + for (const destination of ['packages/p0/fixtures/moved', 'packages/moved']) { + const fixture = preparedFixture(t, {editHead: ({root, locations}) => { + mkdirSync(join(root, 'packages/p0/fixtures'), {recursive: true}); + renameSync(join(root, 'packages/p4'), join(root, destination)); + if (destination === 'packages/moved') locations[4] = destination; + else locations.splice(4, 1); + }}); + await assert.rejects(fixture.validate(), /Deleted or moved public workspace: react-native-macos-init/); + } +}); + +test('base workspace membership detects a public package excluded only at HEAD', async t => { + const fixture = preparedFixture(t, {editHead: ({root, locations}) => { + const path = join(root, 'package.json'); + const pkg = JSON.parse(readFileSync(path, 'utf8')); + pkg.workspaces.push('!packages/p4'); + writeFileSync(path, JSON.stringify(pkg)); + locations.splice(4, 1); + }}); + await assert.rejects(fixture.validate(), /Deleted or moved public workspace: react-native-macos-init/); +}); + +test('deleted private workspaces and non-workspace fixture manifests do not invalidate a prepared bump', async t => { + const extras = ['fixtures/public', 'packages/p0/fixtures/public', 'packages/p0/node_modules/public', + 'packages/excluded', 'tools/node_modules/public']; + for (const objectConfig of [false, true]) { + const fixture = preparedFixture(t, { + editBase: ({root}) => { + const path = join(root, 'package.json'); + const pkg = JSON.parse(readFileSync(path, 'utf8')); + const patterns = ['packages/*', 'tools/**', '!packages/excluded']; + pkg.workspaces = objectConfig ? {packages: patterns} : patterns; + writeFileSync(path, JSON.stringify(pkg)); + for (const location of extras) { + mkdirSync(join(root, location), {recursive: true}); + // Invalid JSON proves the validator does not read unrelated manifests. + writeFileSync(join(root, location, 'package.json'), 'not a workspace manifest'); + } + }, + editHead: ({root, locations}) => { + rmSync(join(root, 'packages/p3'), {recursive: true}); + locations.splice(3, 1); + for (const location of extras) rmSync(join(root, location), {recursive: true}); + }, + }); + assert.equal(await fixture.validate(), true); + } +}); + +test('base workspace manifest and tree command errors propagate', async t => { + const fixture = preparedFixture(t); + for (const fail of [ + args => args[0] === 'ls-tree', + args => args[0] === 'show' && args[1] === `${fixture.base}:package.json`, + args => args[0] === 'show' && args[1] === `${fixture.base}:packages/p4/package.json`, + ]) { + const error = new Error('Base workspace Git failure'); + await assert.rejects(fixture.validate({run: (command, args, options) => { + if (command === 'git' && fail(args)) throw error; + return fixture.run(command, args, options); + }}), actual => actual === error); + } +}); diff --git a/.github/scripts/change.mts b/.github/scripts/change.mts index b07c738c29c2..c97bdae21b8a 100644 --- a/.github/scripts/change.mts +++ b/.github/scripts/change.mts @@ -1,8 +1,10 @@ #!/usr/bin/env node // @ts-ignore import { parseArgs, styleText } from 'node:util'; +import { pathToFileURL } from 'node:url'; import { $, echo, fs } from 'zx'; +import { validatePreparedVersionPR } from './publishing-contract.mjs'; /** * Wrapper around `changeset add` (default) and `changeset status` validation (--check). @@ -11,7 +13,7 @@ import { $, echo, fs } from 'zx'; * auto-detected from package.json's repository URL, temporarily patched into config.json. * * With --check (CI mode): validates that all changed public packages have changesets and that - * no major version bumps are introduced. + * no major version bumps are introduced, or validates a fully prepared version PR. */ interface ChangesetStatusOutput { @@ -79,10 +81,18 @@ function checkMajorBumps(releases: ChangesetStatusOutput['releases']): void { } /** Validate that all changed public packages have changesets and no major bumps are introduced. */ -async function runCheck(baseBranch: string): Promise { +export async function runCheck(baseBranch: string, { + validatePrepared = validatePreparedVersionPR, + getStatus = getChangesetStatus, +} = {}): Promise { log.info(`Validating changesets against ${baseBranch}...\n`); - const { data, exitCode } = await getChangesetStatus(baseBranch); + if (await validatePrepared({baseBranch})) { + log.success('All validations passed (prepared version PR)'); + return; + } + + const { data, exitCode } = await getStatus(baseBranch); if (exitCode !== 0) { log.error('Some packages have been changed but no changesets were found.'); @@ -101,12 +111,14 @@ async function runAdd(baseBranch: string): Promise { await $({ stdio: 'inherit' })`yarn changeset --since ${baseBranch}`; } -const { values: args } = parseArgs({ options: { check: { type: 'boolean', default: false } } }); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const { values: args } = parseArgs({ options: { check: { type: 'boolean', default: false } } }); -const baseBranch = await getBaseBranch(); + const baseBranch = await getBaseBranch(); -if (args.check) { - await runCheck(baseBranch); -} else { - await runAdd(baseBranch); + if (args.check) { + await runCheck(baseBranch); + } else { + await runAdd(baseBranch); + } } diff --git a/.github/scripts/publishing-contract.mjs b/.github/scripts/publishing-contract.mjs index 7515de74de5b..70b166439987 100644 --- a/.github/scripts/publishing-contract.mjs +++ b/.github/scripts/publishing-contract.mjs @@ -5,6 +5,7 @@ import {join} from 'node:path'; const require = createRequire(import.meta.url); const semver = require('semver'); +const micromatch = require('micromatch'); export const registry = 'https://registry.npmjs.org'; @@ -20,17 +21,21 @@ export function isStableBranch(branch) { return /^(0|[1-9]\d*)\.(0|[1-9]\d*)-stable$/.test(branch); } -export function readWorkspaces(root = process.cwd(), run = execFileSync) { +function readWorkspaceEntries(root, run) { const output = run('yarn', ['workspaces', 'list', '--json'], { cwd: root, encoding: 'utf8', }); return output.trim().split('\n').map(line => { const {location} = JSON.parse(line); - return JSON.parse(readFileSync(join(root, location, 'package.json'), 'utf8')); + return {location, pkg: JSON.parse(readFileSync(join(root, location, 'package.json'), 'utf8'))}; }); } +export function readWorkspaces(root = process.cwd(), run = execFileSync) { + return readWorkspaceEntries(root, run).map(({pkg}) => pkg); +} + // The init CLI has its own version and release process. Never include all public // workspaces: only these packages follow the React Native macOS release line. export function releasePackages(workspaces) { @@ -94,10 +99,9 @@ export function validateRelease(workspaces, branch) { return packages; } -export async function readChangesetStatus(root = process.cwd()) { +export async function readChangesetStatus(root = process.cwd(), getReleasePlan = require('@changesets/get-release-plan').default) { // Unlike `changeset status`, this API does not default to config.baseBranch. // Omit sinceRef to inspect ALL pending Changesets, including empty changesets. - const getReleasePlan = require('@changesets/get-release-plan').default; // Explicit registry dependencies on private upstream workspaces are external // releases. Only workspace: links participate in dependency bump propagation. const status = await getReleasePlan(root, undefined, {bumpVersionsWithWorkspaceProtocolOnly: true}); @@ -107,6 +111,90 @@ export async function readChangesetStatus(root = process.cwd()) { return status; } +function changelogSection(changelog, version) { + const headings = [...changelog.matchAll(/^#{1,2} .+$/gm)]; + const matches = headings.filter(heading => heading[0].trim() === `## ${version}`); + if (matches.length > 1) throw new Error(`Duplicate changelog section: ${version}`); + if (!matches.length) return undefined; + const heading = matches[0]; + const next = headings[headings.indexOf(heading) + 1]; + return changelog.slice(heading.index + heading[0].length, next?.index) + .replace(//g, '').replace(/^#{1,6} .+$/gm, '').trim(); +} + +// A consumed Changeset is valid only when the PR contains the complete release +// evidence. Head branch names are not evidence and never grant an exemption. +export async function validatePreparedVersionPR({ + baseBranch, + branch = baseBranch.split('/').at(-1), + root = process.cwd(), + run = execFileSync, + getStatus = readChangesetStatus, +}) { + const status = await getStatus(root); + if (!Array.isArray(status.changesets) || !Array.isArray(status.releases)) { + throw new Error('Invalid Changesets status'); + } + // Keep the normal check for pending releases, including empty Changesets. + if (status.changesets.length || status.releases.length || !isStableBranch(branch)) return false; + + const git = args => run('git', args, {cwd: root, encoding: 'utf8'}); + const mergeBase = git(['merge-base', baseBranch, 'HEAD']).trim(); + const changed = git(['diff', '--name-only', '--no-renames', '-z', mergeBase, 'HEAD']).split('\0').filter(Boolean); + const entries = readWorkspaceEntries(root, run); + const baseFiles = new Set(git(['ls-tree', '-r', '--name-only', '-z', mergeBase]).split('\0')); + const baseRoot = JSON.parse(git(['show', `${mergeBase}:package.json`])); + const patterns = Array.isArray(baseRoot.workspaces) ? baseRoot.workspaces : baseRoot.workspaces?.packages ?? []; + const baseLocations = micromatch([...baseFiles] + .filter(path => path.endsWith('/package.json')) + .map(path => path.slice(0, -'/package.json'.length)), patterns, { + dot: true, ignore: ['**/node_modules/**', '**/.git/**', '**/.yarn/**'], + }); + const byLocation = new Map(entries.map(({location, pkg}) => [location, pkg])); + // Current Yarn metadata cannot report a deleted workspace. Use the base root's + // workspace patterns, not arbitrary fixture manifests, to check lost packages. + for (const location of ['.', ...baseLocations]) { + const previous = location === '.' ? baseRoot : JSON.parse(git(['show', `${mergeBase}:${location}/package.json`])); + if (!previous.private && byLocation.get(location)?.name !== previous.name) { + throw new Error(`Deleted or moved public workspace: ${previous.name} (${location})`); + } + } + // Use current visibility: a private-to-public workspace needs release evidence. + const publicChanges = entries.filter(({location, pkg}) => !pkg.private && changed.some(path => + location === '.' || path.startsWith(`${location}/`))); + if (!publicChanges.length) return false; + + const selected = new Set(validateRelease(entries.map(({pkg}) => pkg), branch).map(pkg => pkg.name)); + for (const {location, pkg} of publicChanges) { + if (!selected.has(pkg.name)) { + throw new Error(`Changed public package is outside the release group: ${pkg.name}`); + } + const manifest = join(location, 'package.json'); + const changelog = join(location, 'CHANGELOG.md'); + if (!baseFiles.has(manifest)) { + throw new Error(`Missing merge-base version for ${pkg.name}`); + } + const previous = JSON.parse(git(['show', `${mergeBase}:${manifest}`])); + const current = JSON.parse(git(['show', `HEAD:${manifest}`])); + if (current.version !== pkg.version || current.name !== pkg.name || current.private) { + throw new Error(`Workspace differs from HEAD: ${pkg.name}`); + } + const bootstrap = previous.version === '1000.0.0' && pkg.version === `0.${parseVersion(pkg.version).minor}.0`; + if (!bootstrap) { + parseVersion(previous.version); + if (!semver.gt(pkg.version, previous.version)) { + throw new Error(`Version must increase for ${pkg.name}: ${previous.version} -> ${pkg.version}`); + } + } + const before = baseFiles.has(changelog) ? git(['show', `${mergeBase}:${changelog}`]) : ''; + if (changelogSection(before, pkg.version) !== undefined || + !changed.includes(changelog) || !changelogSection(git(['show', `HEAD:${changelog}`]), pkg.version)) { + throw new Error(`Missing nonempty new changelog section for ${pkg.name}@${pkg.version}`); + } + } + return true; +} + export async function publishedMetadata(name, fetchRegistry = fetch) { const response = await fetchRegistry(`${registry}/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(60000), From 809fa8005bc0b8d23dd6d2a6179c4e3528fbc245 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 18:24:50 -0500 Subject: [PATCH 09/38] test(release): isolate invalid publication graph fixtures --- .../__tests__/publishing-contract.test.mjs | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/scripts/__tests__/publishing-contract.test.mjs b/.github/scripts/__tests__/publishing-contract.test.mjs index 151404b74843..22fc54830bfd 100644 --- a/.github/scripts/__tests__/publishing-contract.test.mjs +++ b/.github/scripts/__tests__/publishing-contract.test.mjs @@ -157,14 +157,19 @@ test('registry adapter distinguishes missing packages from auth, network, and ma await assert.rejects(publishedMetadata(core, async () => {throw new Error('offline');}), /offline/); }); -function releaseFixture(t) { +function releaseFixture(t, {versionPrivatePackages = false} = {}) { const root = mkdtempSync(join(tmpdir(), 'rnm-release-api-')); t.after(() => rmSync(root, {recursive: true, force: true})); mkdirSync(join(root, '.changeset')); writeFileSync(join(root, 'package.json'), JSON.stringify({name: 'release-fixture', private: true, workspaces: ['packages/*']})); - const config = JSON.parse(readFileSync(new URL('../../../.changeset/config.json', import.meta.url), 'utf8')); - config.baseBranch = 'origin/nonexistent'; - config.changelog = require.resolve('@changesets/cli/changelog'); + // Keep the synthetic graph independent of branch-specific release configuration. + const config = { + access: 'public', baseBranch: 'origin/nonexistent', + changelog: require.resolve('@changesets/cli/changelog'), commit: false, + fixed: [], linked: [], ignore: [], + bumpVersionsWithWorkspaceProtocolOnly: true, + privatePackages: {version: versionPrivatePackages, tag: false}, + }; writeFileSync(join(root, '.changeset/config.json'), JSON.stringify(config)); const workspaces = graph(); for (const [index, pkg] of workspaces.entries()) { @@ -425,7 +430,9 @@ function preparedFixture(t, { privateLists = false, head = 'arbitrary-release-name', editBase = () => {}, editHead = () => {}, } = {}) { - const {root} = releaseFixture(t); + // Let the real Changesets API inspect invalid private links without rejecting + // skipped dependencies first. The contract must still reject those links. + const {root} = releaseFixture(t, {versionPrivatePackages: true}); const git = args => execFileSync('git', args, { cwd: root, encoding: 'utf8', env: {...process.env, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com', @@ -555,18 +562,26 @@ test('private-to-public lists cannot reuse the old version or omit their release }); test('prepared PR rejects mismatched releases and invalid private or out-of-scope runtime links', async t => { - for (const edit of [ - pkg => {pkg.version = '0.83.3';}, - pkg => {pkg.private = true;}, - pkg => {pkg.dependencies = {'@react-native/codegen': 'workspace:*'};}, - pkg => {pkg.optionalDependencies = {'@react-native-macos/internal': '0.83.2'};}, - pkg => {pkg.peerDependencies = {'react-native-macos-init': 'workspace:*'};}, + for (const [edit, message] of [ + [pkg => {pkg.version = '0.83.3';}, + `${lists}@0.83.2 does not match 0.83.3`], + [pkg => {pkg.private = true;}, + 'Missing public react-native-macos workspace'], + [pkg => {pkg.dependencies = {'@react-native/codegen': 'workspace:*'};}, + `${core} has a private or out-of-scope runtime workspace dependency: @react-native/codegen`], + [pkg => {pkg.optionalDependencies = {'@react-native-macos/internal': '0.83.2'};}, + `${core} has a private runtime dependency: @react-native-macos/internal`], + [pkg => {pkg.peerDependencies = {'react-native-macos-init': 'workspace:*'};}, + `${core} has a private or out-of-scope runtime workspace dependency: react-native-macos-init`], ]) { const fixture = preparedFixture(t, {editHead: ({workspaces, writePackage}) => { edit(workspaces[0]); writePackage(0, workspaces[0]); }}); - await assert.rejects(fixture.validate(), /does not match|Missing public|runtime/); + const status = await readChangesetStatus(fixture.root); + assert.deepEqual(status.changesets, []); + assert.deepEqual(status.releases, []); + await assert.rejects(fixture.validate(), {name: 'Error', message}); } }); From 75926e26eea882e9b148f644ed2333d033d614e9 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 19:27:14 -0500 Subject: [PATCH 10/38] fix(packaging): complete fork package dependencies and notices Declare the packaged reporter's Metro dependency, include the virtualized-lists license notice, and remove obsolete DevTools and update-ruby references. Independent review verified actual pack contents and isolated reporter resolution without changing locked package versions. --- .../DevToolsSettingsManager.macos.js | 14 ------------- packages/react-native/package.json | 3 +-- packages/virtualized-lists/LICENSE | 21 +++++++++++++++++++ yarn.lock | 1 + 4 files changed, 23 insertions(+), 16 deletions(-) delete mode 100644 packages/react-native/Libraries/DevToolsSettings/DevToolsSettingsManager.macos.js create mode 100644 packages/virtualized-lists/LICENSE diff --git a/packages/react-native/Libraries/DevToolsSettings/DevToolsSettingsManager.macos.js b/packages/react-native/Libraries/DevToolsSettings/DevToolsSettingsManager.macos.js deleted file mode 100644 index 96bbe349a329..000000000000 --- a/packages/react-native/Libraries/DevToolsSettings/DevToolsSettingsManager.macos.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -// [macOS] - -// $FlowFixMe[prop-missing] Share the iOS file -export {DevToolsSettingsManager} from './DevToolsSettingsManager.ios'; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 230a3364423f..b3ef7499d0d6 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -35,7 +35,6 @@ "scripts/packager.sh", "scripts/react-native-xcode.sh", "scripts/react_native_pods_utils/script_phases.sh", - "scripts/update-ruby.sh", "scripts/xcode/ccache-clang.sh", "scripts/xcode/ccache-clang++.sh", "scripts/xcode/with-environment.sh", @@ -148,7 +147,6 @@ "scripts/react_native_pods_utils/script_phases.sh", "scripts/react_native_pods.rb", "scripts/react-native-xcode.sh", - "scripts/update-ruby.sh", "scripts/xcode/ccache-clang.sh", "scripts/xcode/ccache-clang++.sh", "scripts/xcode/ccache.conf", @@ -204,6 +202,7 @@ "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", + "metro": "^0.83.3", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", diff --git a/packages/virtualized-lists/LICENSE b/packages/virtualized-lists/LICENSE new file mode 100644 index 000000000000..b93be90515cc --- /dev/null +++ b/packages/virtualized-lists/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/yarn.lock b/yarn.lock index 9da7cbc19f61..6e6807194fd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13521,6 +13521,7 @@ __metadata: invariant: "npm:^2.2.4" jest-environment-node: "npm:^29.7.0" memoize-one: "npm:^5.0.0" + metro: "npm:^0.83.3" metro-runtime: "npm:^0.83.3" metro-source-map: "npm:^0.83.3" nullthrows: "npm:^1.1.1" From ef79dc16d86f3e01299ff2b85bb8aff28eac11b1 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 19:57:23 -0500 Subject: [PATCH 11/38] fix(release): preserve fork workspace version relationships Forward-port the reviewed 0.83 release graph policy: keep private tools on workspace dependencies, preserve fork workspace links for Changesets, and couple public core/list versions. Main retains origin/main and excludes private package releases. Independent review verified 51 contract tests and actual Yarn constraints without changing package versions. --- .changeset/config.json | 2 + .../__tests__/publishing-contract.test.mjs | 80 ++++++++++++++++++- yarn.config.cjs | 20 ++--- 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 5df6ff41ef8e..83c142522a99 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,8 +2,10 @@ "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "access": "public", "baseBranch": "origin/main", + "bumpVersionsWithWorkspaceProtocolOnly": true, "changelog": "@changesets/cli/changelog", "commit": false, + "fixed": [["react-native-macos", "@react-native-macos/virtualized-lists"]], "ignore": [], "privatePackages": { "version": false, diff --git a/.github/scripts/__tests__/publishing-contract.test.mjs b/.github/scripts/__tests__/publishing-contract.test.mjs index 22fc54830bfd..ea51fbf79614 100644 --- a/.github/scripts/__tests__/publishing-contract.test.mjs +++ b/.github/scripts/__tests__/publishing-contract.test.mjs @@ -157,13 +157,13 @@ test('registry adapter distinguishes missing packages from auth, network, and ma await assert.rejects(publishedMetadata(core, async () => {throw new Error('offline');}), /offline/); }); -function releaseFixture(t, {versionPrivatePackages = false} = {}) { +function releaseFixture(t, {versionPrivatePackages = false, workspaces = graph(), config: policy} = {}) { const root = mkdtempSync(join(tmpdir(), 'rnm-release-api-')); t.after(() => rmSync(root, {recursive: true, force: true})); mkdirSync(join(root, '.changeset')); writeFileSync(join(root, 'package.json'), JSON.stringify({name: 'release-fixture', private: true, workspaces: ['packages/*']})); // Keep the synthetic graph independent of branch-specific release configuration. - const config = { + const config = policy ?? { access: 'public', baseBranch: 'origin/nonexistent', changelog: require.resolve('@changesets/cli/changelog'), commit: false, fixed: [], linked: [], ignore: [], @@ -171,7 +171,6 @@ function releaseFixture(t, {versionPrivatePackages = false} = {}) { privatePackages: {version: versionPrivatePackages, tag: false}, }; writeFileSync(join(root, '.changeset/config.json'), JSON.stringify(config)); - const workspaces = graph(); for (const [index, pkg] of workspaces.entries()) { mkdirSync(join(root, `packages/p${index}`), {recursive: true}); writeFileSync(join(root, `packages/p${index}/package.json`), JSON.stringify(pkg)); @@ -179,6 +178,81 @@ function releaseFixture(t, {versionPrivatePackages = false} = {}) { return {root, workspaces}; } +const repositoryRoot = new URL('../../../', import.meta.url).pathname; +const releasePolicy = JSON.parse(readFileSync(join(repositoryRoot, '.changeset/config.json'), 'utf8')); +const getReleasePlan = require('@changesets/get-release-plan').default; + +test('repository Changesets policy accepts main with private lists and skips their release', async t => { + const workspaces = readWorkspaces(repositoryRoot); + assert.equal(workspaces.find(pkg => pkg.name === lists).private, true); + const {root} = releaseFixture(t, {workspaces, config: releasePolicy}); + assert.deepEqual((await getReleasePlan(root)).releases, []); + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${core}": patch\n---\n\nFix core.\n`); + const bumped = (await getReleasePlan(root)).releases.filter(pkg => pkg.type !== 'none'); + assert.deepEqual(bumped.map(pkg => [pkg.name, pkg.newVersion]), [[core, '1000.0.1']]); + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${lists}": patch\n---\n\nFix private lists.\n`); + assert.deepEqual((await getReleasePlan(root)).releases, []); +}); + +test('repository Changesets policy couples public stable packages without registry or private release edges', async t => { + const {root, workspaces} = releaseFixture(t, {config: releasePolicy}); + for (const changed of [core, lists, '@react-native/codegen', 'react-native-macos-init']) { + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${changed}": patch\n---\n\nFix package.\n`); + const releases = (await getReleasePlan(root)).releases.map(pkg => [pkg.name, pkg.newVersion]).sort(); + assert.deepEqual(releases, changed === 'react-native-macos-init' + ? [[changed, '2.1.4']] + : changed === '@react-native/codegen' ? [] : [[core, '0.83.3'], [lists, '0.83.3']].sort()); + } + // A consumer outside the fixed group proves that only workspace edges propagate. + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${lists}": patch\n---\n\nFix lists.\n`); + for (const range of ['0.83.2', 'workspace:*']) { + writeFileSync(join(root, 'packages/p4/package.json'), JSON.stringify({ + ...workspaces[4], dependencies: {[lists]: range}, + })); + const release = (await getReleasePlan(root)).releases.find(pkg => pkg.name === 'react-native-macos-init'); + assert.equal(release?.newVersion, range === 'workspace:*' ? '2.1.4' : undefined); + } +}); + +test('real Yarn constraints preserve workspace fork edges and distinguish public and private upstream consumers', t => { + for (const main of [true, false]) { + const workspaces = graph(main ? '1000.0.0' : '0.83.2'); + workspaces[1].private = main; + workspaces[1].version = '0.82.0'; + workspaces[2].private = false; + workspaces[3].version = '0.82.0'; + for (const index of [0, 1, 3]) { + for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { + workspaces[index][field] = {'@react-native/codegen': '*'}; + if (index !== 1) workspaces[index][field][lists] = '*'; + } + } + if (!main) workspaces[0].peerDependencies['react-native'] = '0.83.1'; + const {root} = releaseFixture(t, {workspaces}); + writeFileSync(join(root, 'yarn.lock'), ''); + writeFileSync(join(root, 'yarn.config.cjs'), `module.exports = require(${JSON.stringify(join(repositoryRoot, 'yarn.config.cjs'))});\n`); + const yarn = args => execFileSync(process.execPath, [join(repositoryRoot, '.yarn/releases/yarn-4.12.0.cjs'), ...args], { + cwd: root, encoding: 'utf8', env: {...process.env, YARN_IGNORE_PATH: '1', + YARN_ENABLE_NETWORK: '0', YARN_ENABLE_IMMUTABLE_INSTALLS: '0', YARN_ENABLE_SCRIPTS: '0'}, + }); + yarn(['install']); + yarn(['constraints', '--fix']); + yarn(['constraints']); + const actual = workspaces.map((_, index) => JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8'))); + assert.equal(actual[0].version, workspaces[0].version); + assert.equal(actual[1].version, main ? '1000.0.0' : '0.83.2'); + assert.equal(actual[2].private, true); + assert.equal(actual[2].version, '0.83.1'); + assert.equal(actual[3].version, '1000.0.0'); + for (const index of [0, 1, 3]) { + for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { + assert.equal(actual[index][field]['@react-native/codegen'], main || index === 3 ? 'workspace:*' : '0.83.1'); + if (index !== 1) assert.equal(actual[index][field][lists], 'workspace:*'); + } + } + } +}); + test('real get-release-plan reads a prepared graph without a Git base or pending changesets', async t => { const {root, workspaces} = releaseFixture(t); const status = await readChangesetStatus(root); diff --git a/yarn.config.cjs b/yarn.config.cjs index 1203f44115b5..0faff7ef621d 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -99,10 +99,10 @@ function enforceReactNativeDependencyConsistency({Yarn}) { const reactNativeVersion = getReactNativePeerDependency({Yarn}); const isRNM = dependency.workspace.ident === 'react-native-macos'; - const isRNMForkedPackage = dependency.workspace.ident?.startsWith('@react-native-macos/'); + const isRNMForkedPackage = dependency.workspace.ident?.startsWith('@react-native-macos/') && !dependency.workspace.manifest.private; if (isRNM || isRNMForkedPackage) { - // Don't use `workspace:*` for packages we publish until nx release with Yarn 4 supports it. + // Published upstream packages are registry inputs, not Changesets release dependencies. dependency.update(reactNativeVersion); } else { dependency.update('workspace:*'); @@ -138,22 +138,14 @@ function enforceReactNativeMacosVersionConsistency({Yarn}) { } /** - * Enforce that all @react-native-macos/ scoped dependencies use the same version - * as the react-native-macos - * Do not enforce on the main branch, where there is no published version of React Native to align to. + * Use workspace dependencies for forked packages so Changesets tracks their release graph. + * Yarn replaces workspace:* with the exact package version when packing or publishing. * @param {Context} context */ function enforceReactNativeMacOSDependencyConsistency({Yarn}) { - const rnmWorkspace = getReactNativeMacOSWorkspace({Yarn}); - const rnmVersion = rnmWorkspace?.manifest.version; - for (const dependency of Yarn.dependencies()) { if (dependency.ident.startsWith('@react-native-macos/')) { - if (!isMainBranch({Yarn})) { - dependency.update(rnmVersion); - } else { - dependency.update('workspace:*'); - } + dependency.update('workspace:*'); } } } @@ -183,4 +175,4 @@ module.exports = defineConfig({ enforceReactNativeMacOSDependencyConsistency(ctx); enforceReactNativeMacosPrivatePackageVersion(ctx); }, -}); \ No newline at end of file +}); From 35a72da87653197abc75c5bf09547f5f7a4366e2 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 21:00:18 -0500 Subject: [PATCH 12/38] fix(macos): initialize and dismiss RedBox through its controller Load the V1 view before populating its RCTUITableView and dismiss through the actual presenting controller instead of the current key window. Independent native AppKit review reproduced empty first-display rows and verified 28 fixed lifecycle checks. --- packages/react-native/React/CoreModules/RCTRedBox.mm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index 35da92633158..e3fed0a5d17c 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -371,6 +371,10 @@ - (void)showErrorMessage:(NSString *)message _lastErrorMessage = [messageWithoutAnsi substringToIndex:MIN((NSUInteger)10000, messageWithoutAnsi.length)]; _lastErrorCookie = errorCookie; +#if TARGET_OS_OSX // [macOS + // Create the table before reloading it on the first presentation. + (void)self.view; +#endif // macOS] [_stackTraceTableView reloadData]; if (!isRootViewControllerPresented) { @@ -394,7 +398,7 @@ - (void)dismiss [self dismissViewControllerAnimated:YES completion:nil]; #else // [macOS] if (self.presentingViewController) { - [[RCTKeyWindow() contentViewController] dismissViewController:self]; + [self.presentingViewController dismissViewController:self]; } #endif // macOS] } From 3c658c0eecc6dfead58b0b3ba9e45922dca7f274 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 21:36:04 -0500 Subject: [PATCH 13/38] fix(pods): resolve platform-specific framework dependency headers Use target-owned add_dependency paths instead of unsuffixed framework directories for cxxreact, TurboModule core, RuntimeApple and the sample codegen module. Independent CocoaPods review verified versions and generated header paths across platform configurations. --- .../ReactCommon/ReactCommon.podspec | 8 ++--- .../cxxreact/React-cxxreact.podspec | 3 +- .../platform/ios/React-RuntimeApple.podspec | 4 +-- .../scripts/cocoapods/__tests__/utils-test.rb | 36 +++++++++++++++++++ .../MyNativeView.podspec | 2 +- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/react-native/ReactCommon/ReactCommon.podspec b/packages/react-native/ReactCommon/ReactCommon.podspec index 83e81864640d..8f4f85de40bf 100644 --- a/packages/react-native/ReactCommon/ReactCommon.podspec +++ b/packages/react-native/ReactCommon/ReactCommon.podspec @@ -63,10 +63,10 @@ Pod::Spec.new do |s| ss.subspec "core" do |sss| sss.source_files = podspec_sources("react/nativemodule/core/ReactCommon/**/*.{cpp,h}", "react/nativemodule/core/ReactCommon/**/*.h") - sss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ReactCommon\" \"$(PODS_CONFIGURATION_BUILD_DIR)/React-debug/React_debug.framework/Headers\" \"$(PODS_CONFIGURATION_BUILD_DIR)/React-debug/React_featureflags.framework/Headers\" \"$(PODS_CONFIGURATION_BUILD_DIR)/React-utils/React_utils.framework/Headers\"" } - sss.dependency "React-debug", version - sss.dependency "React-featureflags", version - sss.dependency "React-utils", version + sss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ReactCommon\"" } + add_dependency(sss, "React-debug", :version => version) + add_dependency(sss, "React-featureflags", :version => version) + add_dependency(sss, "React-utils", :version => version) end end end diff --git a/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec b/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec index d6282664acff..25232b1b2dc4 100644 --- a/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec +++ b/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec @@ -29,7 +29,6 @@ Pod::Spec.new do |s| s.source = source s.source_files = podspec_sources("*.{cpp,h}", "*.h") s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_CONFIGURATION_BUILD_DIR)/React-debug/React_debug.framework/Headers\" \"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers\"", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard() } s.header_dir = "cxxreact" @@ -42,7 +41,7 @@ Pod::Spec.new do |s| s.dependency "React-perflogger", version s.dependency "React-jsi", version s.dependency "React-logger", version - s.dependency "React-debug", version + add_dependency(s, "React-debug", :version => version) s.dependency "React-timing", version s.resource_bundles = {'React-cxxreact_privacy' => 'PrivacyInfo.xcprivacy'} diff --git a/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec b/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec index 7c84db0bca95..a4ef1f14e443 100644 --- a/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec +++ b/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec @@ -49,12 +49,12 @@ Pod::Spec.new do |s| s.dependency "React-Core/Default" s.dependency "React-CoreModules" s.dependency "React-NativeModulesApple" - s.dependency "React-RCTFabric" + add_dependency(s, "React-RCTFabric", :framework_name => "RCTFabric") s.dependency "React-RuntimeCore" s.dependency "React-Mapbuffer" s.dependency "React-jserrorhandler" s.dependency "React-jsinspector" - s.dependency "React-featureflags" + add_dependency(s, "React-featureflags") add_dependency(s, "React-jsitooling", :framework_name => "JSITooling") add_dependency(s, "React-RCTFBReactNativeSpec") add_dependency(s, "React-utils", :additional_framework_paths => ["react/utils/platform/ios"]) diff --git a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb index 7ddbbb36e85f..1b50febdebde 100644 --- a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb +++ b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb @@ -5,6 +5,7 @@ require "test/unit" require_relative "../utils.rb" +require_relative "../rncore.rb" require_relative "./test_utils/PodMock.rb" require_relative "./test_utils/InstallerMock.rb" require_relative "./test_utils/EnvironmentMock.rb" @@ -768,6 +769,41 @@ def test_creatHeaderSearchPathForFrameworks_whenMultiplePlatformsAndExtraPath_cr # ===================== # # TEST - Add Dependency # # ===================== # + data("normal" => [nil, [""]], + "single platform" => [["iOS"], [""]], + "three platforms" => [["iOS", "macOS", "visionOS"], ["-iOS", "-macOS", "-visionOS"]]) + def test_addDependency_forDynamicPodDependencies_preservesVersionsAndTargetSettings(platforms_and_suffixes) + $RN_PLATFORMS, suffixes = platforms_and_suffixes + ENV['USE_FRAMEWORKS'] = 'dynamic' + + [ + ["React-debug", "React_debug", '1000.0.0'], + ["React-utils", "React_utils", '1000.0.0'], + ["React-featureflags", "React_featureflags", '1000.0.0'], + ["React-RCTFabric", "RCTFabric", nil], + ["ReactCodegen", "ReactCodegen", nil], + ].each do |pod_name, framework_name, version| + spec = SpecMock.new + spec.pod_target_xcconfig = { + "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ReactCommon\"", + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + } + + ReactNativePodsUtils.add_dependency(spec, pod_name, "PODS_CONFIGURATION_BUILD_DIR", framework_name, :version => version) + + expected_dependency = {:dependency_name => pod_name} + expected_dependency["version"] = version if version + expected_paths = ["\"$(PODS_TARGET_SRCROOT)/ReactCommon\""] + suffixes.map do |suffix| + "\"${PODS_CONFIGURATION_BUILD_DIR}/#{pod_name}#{suffix}/#{framework_name}.framework/Headers\"" + end + assert_equal([expected_dependency], spec.dependencies, pod_name) + assert_equal({ + "HEADER_SEARCH_PATHS" => expected_paths.join(" "), + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + }, spec.to_hash["pod_target_xcconfig"], pod_name) + end + end + def test_addDependency_whenNoHeaderSearchPathAndNoVersion_addsThem spec = SpecMock.new diff --git a/packages/rn-tester/NativeComponentExample/MyNativeView.podspec b/packages/rn-tester/NativeComponentExample/MyNativeView.podspec index 824028831680..37af216df01a 100644 --- a/packages/rn-tester/NativeComponentExample/MyNativeView.podspec +++ b/packages/rn-tester/NativeComponentExample/MyNativeView.podspec @@ -19,7 +19,6 @@ Pod::Spec.new do |s| s.author = "Meta Platforms, Inc. and its affiliates" s.source = { :git => "https://github.com/facebook/my-native-view.git", :tag => "#{s.version}" } s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCodegen/ReactCodegen.framework/Headers\"", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard() } @@ -27,4 +26,5 @@ Pod::Spec.new do |s| s.requires_arc = true install_modules_dependencies(s) + add_dependency(s, "ReactCodegen") end From b18a9d38fbc2dbf9aa4dc676f8a0983bea33cca7 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 21:53:22 -0500 Subject: [PATCH 14/38] fix(graphics): use canonical HostPlatformColor header import --- .../platform/ios/react/renderer/graphics/HostPlatformColor.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm index a01024a20646..5166765154a7 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -#import "HostPlatformColor.h" +#import #import #import // [macOS] From 5a7c77b08dee846503b291b1b55c7cc4de225595 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 22:09:32 -0500 Subject: [PATCH 15/38] fix(macos): decouple core views from the optional text framework Resolve optional RCTTextView dynamically for accessibility and use native NSTextField selection behavior. Remove reverse React-Core class references to React-RCTText without adding a circular pod dependency. Independent review verified producer symbols and native behavior. --- packages/react-native/React/Base/RCTTouchHandler.m | 5 ++--- packages/react-native/React/Views/RCTView.m | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/react-native/React/Base/RCTTouchHandler.m b/packages/react-native/React/Base/RCTTouchHandler.m index b85ee3dd203f..e97f96c3b59d 100644 --- a/packages/react-native/React/Base/RCTTouchHandler.m +++ b/packages/react-native/React/Base/RCTTouchHandler.m @@ -10,7 +10,6 @@ #if !TARGET_OS_OSX // [macOS] #import #endif // [macOS] -#import // [macOS] #import "RCTAssert.h" #import "RCTBridge.h" @@ -144,8 +143,8 @@ - (void)_recordNewTouches:(NSSet *)touches } else if ([targetView isKindOfClass:[NSText class]]) { _shouldSendMouseUpOnSystemBehalf = [(NSText*)targetView isSelectable]; } - else if ([targetView.superview isKindOfClass:[RCTUITextField class]]) { - _shouldSendMouseUpOnSystemBehalf = [(RCTUITextField*)targetView.superview isSelectable]; + else if ([targetView.superview isKindOfClass:[NSTextField class]]) { + _shouldSendMouseUpOnSystemBehalf = [(NSTextField*)targetView.superview isSelectable]; } else { _shouldSendMouseUpOnSystemBehalf = NO; } diff --git a/packages/react-native/React/Views/RCTView.m b/packages/react-native/React/Views/RCTView.m index e21035af2c47..7b009661bb82 100644 --- a/packages/react-native/React/Views/RCTView.m +++ b/packages/react-native/React/Views/RCTView.m @@ -25,9 +25,6 @@ #import "RCTViewUtils.h" #import "UIView+React.h" #import "RCTViewKeyboardEvent.h" -#if TARGET_OS_OSX // [macOS -#import "RCTTextView.h" -#endif // macOS] RCT_MOCK_DEF(RCTView, RCTContentInsets); #define RCTContentInsets RCT_MOCK_USE(RCTView, RCTContentInsets) @@ -112,7 +109,8 @@ - (RCTPlatformView *)react_findClipView // [macOS] NSString *label = subview.accessibilityLabel; #else // [macOS NSString *label; - if ([subview isKindOfClass:[RCTTextView class]]) { + // React-RCTText depends on React-Core, so resolve its optional class without a link dependency. + if ([subview isKindOfClass:NSClassFromString(@"RCTTextView")]) { // on macOS VoiceOver a text element will always have its accessibilityValue read, but will only read it's accessibilityLabel if it's value is set. // the macOS RCTTextView accessibilityValue will return its accessibilityLabel if set otherwise return its text. label = subview.accessibilityValue; From d7d9fc0dfa6515e59f22be85199324c2ff88bcce Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 23:11:05 -0500 Subject: [PATCH 16/38] fix(pods): declare direct RCTUIKit framework dependencies Declare the framework owner for Image, Animation, Text, CoreModules and Fabric class references. Independent review verified no dependency cycle and generated linker flags across 270 CocoaPods configurations. --- packages/react-native/Libraries/Image/React-RCTImage.podspec | 1 + .../Libraries/NativeAnimation/React-RCTAnimation.podspec | 1 + packages/react-native/Libraries/Text/React-RCTText.podspec | 1 + .../react-native/React/CoreModules/React-CoreModules.podspec | 1 + packages/react-native/React/React-RCTFabric.podspec | 1 + 5 files changed, 5 insertions(+) diff --git a/packages/react-native/Libraries/Image/React-RCTImage.podspec b/packages/react-native/Libraries/Image/React-RCTImage.podspec index 6e3bc6a9389e..5bbcea9aac8d 100644 --- a/packages/react-native/Libraries/Image/React-RCTImage.podspec +++ b/packages/react-native/Libraries/Image/React-RCTImage.podspec @@ -55,6 +55,7 @@ Pod::Spec.new do |s| add_dependency(s, "React-RCTFBReactNativeSpec") add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"]) add_dependency(s, "React-NativeModulesApple") + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit') # [macOS] add_rn_third_party_dependencies(s) add_rncore_dependency(s) diff --git a/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec b/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec index 81a9e7d7f189..1ce902e94332 100644 --- a/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec +++ b/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec @@ -52,6 +52,7 @@ Pod::Spec.new do |s| add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"]) add_dependency(s, "React-NativeModulesApple") add_dependency(s, "React-featureflags") + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit') # [macOS] add_rn_third_party_dependencies(s) add_rncore_dependency(s) diff --git a/packages/react-native/Libraries/Text/React-RCTText.podspec b/packages/react-native/Libraries/Text/React-RCTText.podspec index b850acf0f414..291ea48191ab 100644 --- a/packages/react-native/Libraries/Text/React-RCTText.podspec +++ b/packages/react-native/Libraries/Text/React-RCTText.podspec @@ -39,4 +39,5 @@ Pod::Spec.new do |s| s.dependency "Yoga" s.dependency "React-Core/RCTTextHeaders", version + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit', :version => version) # [macOS] end diff --git a/packages/react-native/React/CoreModules/React-CoreModules.podspec b/packages/react-native/React/CoreModules/React-CoreModules.podspec index 59906723b425..bfd47144d437 100644 --- a/packages/react-native/React/CoreModules/React-CoreModules.podspec +++ b/packages/react-native/React/CoreModules/React-CoreModules.podspec @@ -65,6 +65,7 @@ Pod::Spec.new do |s| add_dependency(s, "React-RCTFBReactNativeSpec") add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"]) add_dependency(s, "React-NativeModulesApple") + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit', :version => version) # [macOS] add_rn_third_party_dependencies(s) add_rncore_dependency(s) diff --git a/packages/react-native/React/React-RCTFabric.podspec b/packages/react-native/React/React-RCTFabric.podspec index a034eaa21b16..d3380a8bf0cf 100644 --- a/packages/react-native/React/React-RCTFabric.podspec +++ b/packages/react-native/React/React-RCTFabric.podspec @@ -93,6 +93,7 @@ Pod::Spec.new do |s| add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"]) add_dependency(s, "React-runtimescheduler") add_dependency(s, "React-RCTAnimation", :framework_name => 'RCTAnimation') + add_dependency(s, "React-RCTUIKit", :framework_name => 'RCTUIKit') # [macOS] add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern') add_dependency(s, "React-jsinspectorcdp", :framework_name => 'jsinspector_moderncdp') add_dependency(s, "React-jsinspectortracing", :framework_name => 'jsinspector_moderntracing') From ea7972ac08ed394220da367754574bc5cb25ca94 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 23:23:12 -0500 Subject: [PATCH 17/38] fix(macos): link UniformTypeIdentifiers from Fabric --- packages/react-native/React/React-RCTFabric.podspec | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native/React/React-RCTFabric.podspec b/packages/react-native/React/React-RCTFabric.podspec index d3380a8bf0cf..70e9d352ca52 100644 --- a/packages/react-native/React/React-RCTFabric.podspec +++ b/packages/react-native/React/React-RCTFabric.podspec @@ -52,6 +52,7 @@ Pod::Spec.new do |s| # [macOS MobileCoreServices not available on macOS s.ios.framework = "MobileCoreServices" s.visionos.framework = "MobileCoreServices" + s.osx.frameworks = ["UniformTypeIdentifiers"] # macOS] s.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => header_search_paths, From 0b1306dd4a39ce735747c8782555623f8857ac3d Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 23:39:10 -0500 Subject: [PATCH 18/38] fix(pods): link CoreGraphics from its graphics consumer --- .../ReactCommon/react/renderer/graphics/React-graphics.podspec | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec b/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec index 489b1d916bea..e7db55edd6ee 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec +++ b/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec @@ -32,6 +32,7 @@ Pod::Spec.new do |s| s.source = source s.source_files = podspec_sources(source_files, ["*.h", "platform/ios/**/*.h"]) s.header_dir = "react/renderer/graphics" + s.frameworks = "CoreGraphics" # [macOS Restrict UIKit to iOS and visionOS s.ios.framework = "UIKit" s.visionos.framework = "UIKit" From a9e5b9e8a7f05281f65628437be068d2f0ac69b6 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 23:46:07 -0500 Subject: [PATCH 19/38] fix(pods): link CoreGraphics from the sample module --- .../react/nativemodule/samples/ReactCommon-Samples.podspec | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec b/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec index ffed0e5ae2ab..90ad49db4ef9 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec +++ b/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec @@ -37,6 +37,7 @@ Pod::Spec.new do |s| "USE_HEADERMAP" => "YES", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), "GCC_WARN_PEDANTIC" => "YES" } + s.frameworks = "CoreGraphics" # [macOS Restrict UIKit to iOS and visionOS s.ios.framework = "UIKit" s.visionos.framework = "UIKit" From 29191b6efaa1912c1c0fce708ba3d4ddff6ec62a Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 21:35:32 -0500 Subject: [PATCH 20/38] fix(macos): size RedBox rows from native table content Fit the native column to its document width and use text constraints for automatic row heights. Preserve explicit fixed-height cells and keep UIKit behavior unchanged. Independent review verified native layout, reuse, and lifecycle probes with negative controls. (cherry picked from commit faf217741e9dbc71d09ca3828e250bb1878575a6) --- .../React/CoreModules/RCTRedBox.mm | 4 ++++ .../Libraries/RCTUIKit/RCTUITableView.m | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/react-native/React/CoreModules/RCTRedBox.mm b/packages/react-native/React/CoreModules/RCTRedBox.mm index e3fed0a5d17c..19922d2aba50 100644 --- a/packages/react-native/React/CoreModules/RCTRedBox.mm +++ b/packages/react-native/React/CoreModules/RCTRedBox.mm @@ -601,6 +601,9 @@ - (RCTUITableViewCell *)reuseCell:(RCTUITableViewCell *)cell forStackFrame:(RCTJ - (CGFloat)tableView:(RCTUITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath // [macOS] { +#if TARGET_OS_OSX // [macOS + return RCTUITableViewAutomaticDimension; +#else // macOS] if (indexPath.section == 0) { NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; paragraphStyle.lineBreakMode = NSLineBreakByWordWrapping; @@ -622,6 +625,7 @@ - (CGFloat)tableView:(RCTUITableView *)tableView heightForRowAtIndexPath:(NSInde } else { return 50; } +#endif // [macOS] } - (void)tableView:(RCTUITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath // [macOS] diff --git a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m index 1e4f77f99b5c..5a23848cd3d5 100644 --- a/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m +++ b/packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUITableView.m @@ -17,6 +17,21 @@ static NSString *const RCTUITableViewHeaderHeightConstraintIdentifier = @"RCTUITableViewHeaderHeight"; static char RCTUITableViewHeaderHeightConstraintKey; +static void RCTUITableViewConfigureLabels(NSView *view, BOOL automaticHeight) +{ + if ([view isKindOfClass:[NSTextField class]]) { + // AppKit's column-width constraint has priority 500. Let text wrap within it. + [view setContentCompressionResistancePriority:NSLayoutPriorityDefaultLow + forOrientation:NSLayoutConstraintOrientationHorizontal]; + // Automatic row fitting must include the full height of each visible label. + [view setContentCompressionResistancePriority:automaticHeight ? NSLayoutPriorityRequired : NSLayoutPriorityDefaultHigh + forOrientation:NSLayoutConstraintOrientationVertical]; + } + for (NSView *subview in view.subviews) { + RCTUITableViewConfigureLabels(subview, automaticHeight); + } +} + typedef NS_ENUM(NSInteger, RCTUITableViewSlotKind) { RCTUITableViewSlotKindHeader, RCTUITableViewSlotKindRow, @@ -186,6 +201,7 @@ - (void)setFrameSize:(NSSize)newSize - (void)setFixedHeight:(NSNumber *)fixedHeight { + RCTUITableViewConfigureLabels(self.contentView, fixedHeight == nil); if (fixedHeight == nil) { _fixedHeightConstraint.active = NO; _fixedHeightConstraint = nil; @@ -240,6 +256,8 @@ - (instancetype)initWithFrame:(NSRect)frameRect [_tableView addTableColumn:column]; self.documentView = _tableView; + // The column retains its default width until it belongs to a scroll view. + [_tableView sizeLastColumnToFit]; _lastContentWidth = self.contentSize.width; self.separatorColor = nil; } @@ -276,6 +294,7 @@ - (void)setFrameSize:(NSSize)newSize CGFloat contentWidth = self.contentSize.width; if (_lastContentWidth != contentWidth) { _lastContentWidth = contentWidth; + [_tableView sizeLastColumnToFit]; if (_automaticRows.count > 0) { [_tableView noteHeightOfRowsWithIndexesChanged:_automaticRows]; } From ff90a9b67703e689f8e7819aa6482f782bc1fc8f Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 21:36:04 -0500 Subject: [PATCH 21/38] fix(fabric): preserve canonical platform headers in frameworks Add platform-dispatch headers, guard macOS translation units, and retain canonical static/framework header namespaces without exporting conflicting platform families. Independent review verified CocoaPods selection, Android dispatch and real-header dependency traces. (cherry picked from commit f2e474f9ee570fabca74de28e26bc5440f6b22ea) --- .../ReactCommon/React-Fabric.podspec | 8 ++++++++ .../components/view/HostPlatformTouch.h | 20 +++++++++++++++++++ .../view/HostPlatformViewEventEmitter.h | 20 +++++++++++++++++++ .../components/view/HostPlatformViewProps.h | 20 +++++++++++++++++++ .../view/HostPlatformViewTraitsInitializer.h | 20 +++++++++++++++++++ .../react/renderer/components/view/KeyEvent.h | 10 ++++++++++ .../renderer/components/view/MouseEvent.h | 10 ++++++++++ .../view/HostPlatformViewEventEmitter.cpp | 8 +++++++- .../components/view/HostPlatformViewProps.cpp | 8 ++++++++ 9 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h diff --git a/packages/react-native/ReactCommon/React-Fabric.podspec b/packages/react-native/ReactCommon/React-Fabric.podspec index 6ab41bcf4134..dbc6bd510ee6 100644 --- a/packages/react-native/ReactCommon/React-Fabric.podspec +++ b/packages/react-native/ReactCommon/React-Fabric.podspec @@ -132,6 +132,14 @@ Pod::Spec.new do |s| sss.source_files = "react/renderer/components/view/**/*.{m,mm,cpp,h}" # [macOS] sss.exclude_files = "react/renderer/components/view/tests", "react/renderer/components/view/platform/android", "react/renderer/components/view/platform/windows" # [macOS] sss.header_dir = "react/renderer/components/view" + # [macOS Keep the canonical wrappers and their physical headers in separate namespaces. + # The view sources also remain present with prebuilt RNCore, where the root mapping is not set. + sss.header_mappings_dir = ENV['USE_FRAMEWORKS'] ? "./" : "react/renderer/components/view" + sss.osx.exclude_files = "react/renderer/components/view/platform/cxx/**/*.h" + sss.ios.exclude_files = "react/renderer/components/view/platform/macos/**/HostPlatform*.h" + sss.tvos.exclude_files = "react/renderer/components/view/platform/macos/**/HostPlatform*.h" + sss.visionos.exclude_files = "react/renderer/components/view/platform/macos/**/HostPlatform*.h" + # macOS] end ss.subspec "scrollview" do |sss| diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h new file mode 100644 index 000000000000..7962db434d0a --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#if defined(__APPLE__) +#include +#endif + +#if defined(__ANDROID__) +#include "platform/android/react/renderer/components/view/HostPlatformTouch.h" +#elif defined(__APPLE__) && TARGET_OS_OSX +#include "platform/macos/react/renderer/components/view/HostPlatformTouch.h" +#else +#include "platform/cxx/react/renderer/components/view/HostPlatformTouch.h" +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h new file mode 100644 index 000000000000..6640bde021fa --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#if defined(__APPLE__) +#include +#endif + +#if defined(__ANDROID__) +#include "platform/android/react/renderer/components/view/HostPlatformViewEventEmitter.h" +#elif defined(__APPLE__) && TARGET_OS_OSX +#include "platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.h" +#else +#include "platform/cxx/react/renderer/components/view/HostPlatformViewEventEmitter.h" +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h new file mode 100644 index 000000000000..87c02fb1c5e8 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#if defined(__APPLE__) +#include +#endif + +#if defined(__ANDROID__) +#include "platform/android/react/renderer/components/view/HostPlatformViewProps.h" +#elif defined(__APPLE__) && TARGET_OS_OSX +#include "platform/macos/react/renderer/components/view/HostPlatformViewProps.h" +#else +#include "platform/cxx/react/renderer/components/view/HostPlatformViewProps.h" +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h new file mode 100644 index 000000000000..2caf27c65d2b --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#if defined(__APPLE__) +#include +#endif + +#if defined(__ANDROID__) +#include "platform/android/react/renderer/components/view/HostPlatformViewTraitsInitializer.h" +#elif defined(__APPLE__) && TARGET_OS_OSX +#include "platform/macos/react/renderer/components/view/HostPlatformViewTraitsInitializer.h" +#else +#include "platform/cxx/react/renderer/components/view/HostPlatformViewTraitsInitializer.h" +#endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h b/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h new file mode 100644 index 000000000000..ca285e3fa594 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h @@ -0,0 +1,10 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include "platform/macos/react/renderer/components/view/KeyEvent.h" diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h b/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h new file mode 100644 index 000000000000..23515fefccd6 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h @@ -0,0 +1,10 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include "platform/macos/react/renderer/components/view/MouseEvent.h" diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp index 5b8b18d67be7..ff7a6d42c936 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp @@ -5,7 +5,11 @@ * LICENSE file in the root directory of this source tree. */ - // [macOS] +#if defined(__APPLE__) +#include +#endif + +#if defined(__APPLE__) && TARGET_OS_OSX // [macOS] #include #include @@ -211,3 +215,5 @@ void HostPlatformViewEventEmitter::onDrop(const DragEvent& dragEvent) const { } } // namespace facebook::react + +#endif // defined(__APPLE__) && TARGET_OS_OSX diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp index 4190d7a57e13..3e45d7288808 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp @@ -5,6 +5,12 @@ * LICENSE file in the root directory of this source tree. */ +#if defined(__APPLE__) +#include +#endif + +#if defined(__APPLE__) && TARGET_OS_OSX // [macOS] + #include "HostPlatformViewProps.h" #include @@ -160,3 +166,5 @@ void HostPlatformViewProps::setProp( } // namespace facebook::react + +#endif // defined(__APPLE__) && TARGET_OS_OSX From 52c69fc933b7439de3fa974d270029062d57831f Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 16 Sep 2026 16:13:58 -0500 Subject: [PATCH 22/38] fix(hermes): resolve default artifacts from branch metadata Share explicit legacy-default, V1-default and single-version metadata policies without tying Hermes coordinates to RN package versions. Preserve tarball/version overrides and CI source-tag fallback when artifact metadata is absent. Independent review verified 73 Node 22 tests including real CI entry points and missing-metadata fallback. Later fork policies remain explicit caller adaptations. (cherry picked from commit fab736d13f0045b6ae02ad52f8aa4607f2a9f755) --- .../__tests__/__fixtures__/resolve-hermes.cjs | 55 ++++ .../scripts/__tests__/resolve-hermes-test.js | 171 +++++++++++ .github/scripts/resolve-hermes.mts | 42 ++- .../ios-prebuild/__tests__/hermes-test.js | 277 ++++++++++++++++++ .../__tests__/hermes-version-test.js | 122 ++++++++ .../scripts/ios-prebuild/hermes-version.js | 99 +++++++ .../scripts/ios-prebuild/hermes.js | 7 +- 7 files changed, 747 insertions(+), 26 deletions(-) create mode 100644 .github/scripts/__tests__/__fixtures__/resolve-hermes.cjs create mode 100644 .github/scripts/__tests__/resolve-hermes-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/hermes-version.js diff --git a/.github/scripts/__tests__/__fixtures__/resolve-hermes.cjs b/.github/scripts/__tests__/__fixtures__/resolve-hermes.cjs new file mode 100644 index 000000000000..7bc7fd14b138 --- /dev/null +++ b/.github/scripts/__tests__/__fixtures__/resolve-hermes.cjs @@ -0,0 +1,55 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +// Preload in the CLI subprocess. Exercise the real ESM entry point and URL +// helper without network access or changes to checked-in metadata. +const fs = require('node:fs'); +const path = require('node:path'); + +const propertiesPath = path.resolve( + __dirname, + '../../../../packages/react-native/sdks/hermes-engine/version.properties', +); +const readFileSync = fs.readFileSync; +fs.readFileSync = function (file, ...args) { + if (file === propertiesPath && process.env.HERMES_TEST_PROPERTIES != null) { + if (process.env.HERMES_TEST_PROPERTIES === 'MISSING') { + throw Object.assign(new Error(`ENOENT: ${propertiesPath}`), { + code: 'ENOENT', + }); + } + if (process.env.HERMES_TEST_PROPERTIES === 'UNREADABLE') { + throw Object.assign(new Error(`EACCES: ${propertiesPath}`), { + code: 'EACCES', + }); + } + return process.env.HERMES_TEST_PROPERTIES; + } + return readFileSync.call(this, file, ...args); +}; + +const urls = []; +global.fetch = async url => { + urls.push(url); + const mode = process.env.HERMES_TEST_DOWNLOAD; + if (url.endsWith('/maven-metadata.xml')) { + return { + ok: mode === 'snapshot', + text: async () => + '20260101.0102034', + }; + } + return { + ok: mode === 'release' || (mode === 'snapshot' && url.includes('SNAPSHOT')), + status: 404, + statusText: 'Not Found', + arrayBuffer: async () => Buffer.from('mock Hermes archive'), + }; +}; +process.on('exit', () => console.log(`HERMES_TEST_URLS=${JSON.stringify(urls)}`)); diff --git a/.github/scripts/__tests__/resolve-hermes-test.js b/.github/scripts/__tests__/resolve-hermes-test.js new file mode 100644 index 000000000000..534e6383c719 --- /dev/null +++ b/.github/scripts/__tests__/resolve-hermes-test.js @@ -0,0 +1,171 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +'use strict'; + +const {spawnSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const root = path.resolve(__dirname, '../../..'); +const script = path.join(root, '.github/scripts/resolve-hermes.mts'); +const preload = path.join(__dirname, '__fixtures__/resolve-hermes.cjs'); +let tmp; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-hermes-test-')); +}); + +afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); +}); + +function run(command, overrides = {}) { + const env = {...process.env}; + for (const key of [ + 'RCT_HERMES_V1_ENABLED', + 'HERMES_VERSION', + 'HERMES_ENGINE_TARBALL_PATH', + 'HERMES_TEST_PROPERTIES', + 'HERMES_TEST_DOWNLOAD', + ]) { + delete env[key]; + } + const outputPath = path.join(tmp, 'output'); + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', '--require', preload, script, ...command], + { + cwd: tmp, + env: {...env, TMPDIR: tmp, GITHUB_OUTPUT: outputPath, ...overrides}, + encoding: 'utf8', + timeout: 10000, + }, + ); + if (result.error) { + throw result.error; + } + const output = fs.existsSync(outputPath) + ? fs.readFileSync(outputPath, 'utf8') + : ''; + const urls = JSON.parse(result.stdout.match(/HERMES_TEST_URLS=(.*)/)[1]); + return {...result, output, urls}; +} + +test.each([ + ['0', '0.14.0', 'HERMES_VERSION_NAME', 'Debug'], + ['1', '250829098.0.2', 'HERMES_V1_VERSION_NAME', 'Release'], +])( + 'CI downloads flag %s with the selected key and version', + (flag, version, key, flavor) => { + const result = run(['download-hermes', flavor], { + RCT_HERMES_V1_ENABLED: flag, + HERMES_TEST_DOWNLOAD: 'release', + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain(`Using ${key}=${version}`); + expect(result.output).toContain(`version=${version}\n`); + expect(result.urls).toEqual([ + `https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/${version}-SNAPSHOT/maven-metadata.xml`, + `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${version}/hermes-ios-${version}-hermes-ios-${flavor.toLowerCase()}.tar.gz`, + ]); + const tarball = result.output.match(/^tarball=(.+)$/m)[1]; + expect(fs.readFileSync(tarball, 'utf8')).toBe('mock Hermes archive'); + }, +); + +test('CI snapshot fallback preserves the four-argument URL helper contract', () => { + const result = run(['download-hermes'], {HERMES_TEST_DOWNLOAD: 'snapshot'}); + expect(result.status).toBe(0); + expect(result.output).toContain('version=0.14.0\n'); + expect(result.urls).toEqual([ + 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/0.14.0-SNAPSHOT/maven-metadata.xml', + 'https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/0.14.0/hermes-ios-0.14.0-hermes-ios-debug.tar.gz', + 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/0.14.0-SNAPSHOT/hermes-ios-0.14.0-20260101.010203-4-hermes-ios-debug.tar.gz', + ]); +}); + +test('CI can still select source when valid pinned artifacts are unavailable', () => { + const result = run(['download-hermes']); + expect(result.status).toBe(0); + expect(result.output).toBe(''); + expect(result.stdout).toContain('will build from source'); + expect(result.urls).toHaveLength(2); +}); + +test.each([ + ['', 'Expected one exact HERMES_VERSION_NAME'], + ['UNREADABLE', 'EACCES'], + ['HERMES_VERSION_NAME=^1.2.3', 'Expected one exact HERMES_VERSION_NAME'], + [ + 'HERMES_VERSION_NAME=1.2.3\nHERMES_VERSION_NAME=1.2.3', + 'Expected one exact HERMES_VERSION_NAME', + ], +])('CI fails invalid metadata before any download: %s', (properties, error) => { + const result = run(['download-hermes'], {HERMES_TEST_PROPERTIES: properties}); + expect(result.status).toBe(1); + expect(result.stderr).toContain(error); + expect(result.output).toBe(''); + expect(result.urls).toEqual([]); + expect(result.stdout).not.toContain('will build from source'); +}); + +test('CI selects source without a download when version.properties is missing', () => { + const result = run(['download-hermes'], {HERMES_TEST_PROPERTIES: 'MISSING'}); + expect(result.status).toBe(0); + expect(result.output).toBe(''); + expect(result.stdout).toContain('will build from source'); + expect(result.urls).toEqual([]); +}); + +test.each([ + [undefined, '.hermesversion'], + ['0', '.hermesversion'], + ['1', '.hermesv1version'], +])('CI resolve-commit uses the tag file for flag %s', (flag, tagFile) => { + const result = run( + ['resolve-commit'], + flag == null ? {} : {RCT_HERMES_V1_ENABLED: flag}, + ); + const tag = fs + .readFileSync( + path.join(root, 'packages/react-native/sdks', tagFile), + 'utf8', + ) + .trim(); + expect(result.status).toBe(0); + expect(result.output).toBe(`hermes-commit=${tag}\n`); + expect(result.urls).toEqual([]); +}); + +test.each([ + ['0', '.hermesversion', 'MISSING'], + ['1', '.hermesv1version', 'MISSING'], + ['1', '.hermesv1version', 'HERMES_VERSION_NAME=0.14.0'], + ['0', '.hermesversion', 'HERMES_VERSION_NAME=invalid'], +])( + 'CI resolve-commit reads flag %s tag %s independently of metadata %s', + (flag, tagFile, properties) => { + const result = run(['resolve-commit'], { + RCT_HERMES_V1_ENABLED: flag, + HERMES_TEST_PROPERTIES: properties, + }); + const tag = fs + .readFileSync( + path.join(root, 'packages/react-native/sdks', tagFile), + 'utf8', + ) + .trim(); + expect(result.status).toBe(0); + expect(result.output).toBe(`hermes-commit=${tag}\n`); + expect(result.urls).toEqual([]); + }, +); diff --git a/.github/scripts/resolve-hermes.mts b/.github/scripts/resolve-hermes.mts index 77fb5d9a1abe..83cc9c32fd54 100644 --- a/.github/scripts/resolve-hermes.mts +++ b/.github/scripts/resolve-hermes.mts @@ -16,6 +16,10 @@ import { $, echo, fs, path } from 'zx'; // Use createRequire to import CommonJS modules from ESM context const require = createRequire(import.meta.url); +const { + readHermesMetadata, + selectHermesMetadata, +} = require('../../packages/react-native/scripts/ios-prebuild/hermes-version.js'); const { computeNightlyTarballURL, } = require('../../packages/react-native/scripts/ios-prebuild/utils.js'); @@ -31,30 +35,19 @@ function setActionOutput(key: string, value: string) { * Reads the Hermes artifact version from * packages/react-native/sdks/hermes-engine/version.properties. * - * Returns HERMES_V1_VERSION_NAME when RCT_HERMES_V1_ENABLED=1, otherwise - * HERMES_VERSION_NAME. Returns null if the file or the key is missing. + * Uses the same version key and validation as the local prebuild script. + * A missing file permits a source build; malformed metadata must fail CI. */ function resolveHermesArtifactVersion(): string | null { - const propsPath = path.resolve( - import.meta.dirname!, '..', '..', - 'packages', 'react-native', 'sdks', 'hermes-engine', 'version.properties', - ); try { - const props: Record = {}; - for (const line of fs.readFileSync(propsPath, 'utf8').split('\n')) { - const eq = line.indexOf('='); - if (eq > 0) { - props[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); - } + const {version, versionKey} = readHermesMetadata(); + echo(`Using ${versionKey}=${version}`); + return version; + } catch (error: any) { + if (error.code === 'ENOENT') { + return null; } - const key = - process.env.RCT_HERMES_V1_ENABLED === '1' - ? 'HERMES_V1_VERSION_NAME' - : 'HERMES_VERSION_NAME'; - const version = props[key]; - return version != null && version.length > 0 ? version : null; - } catch { - return null; + throw error; } } @@ -64,10 +57,9 @@ function resolveHermesArtifactVersion(): string | null { * facebook/hermes. Returns null if the file is missing or empty. */ function resolveHermesTag(): string | null { - const tagFile = - process.env.RCT_HERMES_V1_ENABLED === '1' - ? '.hermesv1version' - : '.hermesversion'; + const {tagFile} = selectHermesMetadata( + 'legacy-default', process.env.RCT_HERMES_V1_ENABLED, + ); const tagPath = path.resolve( import.meta.dirname!, '..', '..', 'packages', 'react-native', 'sdks', tagFile, @@ -92,7 +84,7 @@ async function downloadUpstreamHermesTarball( ): Promise<{ tarballPath: string; version: string } | null> { const version = resolveHermesArtifactVersion(); if (version == null) { - echo('Could not read Hermes version from sdks/hermes-engine/version.properties'); + echo('Hermes version.properties is missing — will build from source.'); return null; } diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js new file mode 100644 index 000000000000..cd7567050b8c --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js @@ -0,0 +1,277 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +'use strict'; + +jest.mock('child_process', () => ({execSync: jest.fn()})); + +const {prepareHermesArtifactsAsync} = require('../hermes'); +const {execSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const {Readable} = require('stream'); + +const propertiesPath = path.resolve( + __dirname, + '../../../sdks/hermes-engine/version.properties', +); +const readFileSync = fs.readFileSync.bind(fs); +const originalFetch = global.fetch; +const envKeys = [ + 'RCT_HERMES_V1_ENABLED', + 'HERMES_ENGINE_TARBALL_PATH', + 'HERMES_VERSION', + 'ENTERPRISE_REPOSITORY', +]; +let tmp; +let artifacts; +let versionFile; +let framework; +let savedEnv; +let properties; + +beforeEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + savedEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + envKeys.forEach(key => delete process.env[key]); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-test-')); + artifacts = path.join(tmp, '.build/artifacts/hermes'); + versionFile = path.join(artifacts, 'version.txt'); + framework = path.join( + artifacts, + 'destroot/Library/Frameworks/universal/hermesvm.xcframework', + ); + properties = readFileSync(propertiesPath, 'utf8'); + jest.spyOn(process, 'cwd').mockReturnValue(tmp); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...args) => { + if (file === propertiesPath) { + if (properties instanceof Error) { + throw properties; + } + return properties; + } + return readFileSync(file, ...args); + }); + global.fetch = jest.fn(async (url, options) => { + if (options?.method === 'HEAD') { + return {status: 200}; + } + return {ok: true, body: Readable.from(['mock Hermes archive'])}; + }); + execSync.mockImplementation(() => { + fs.mkdirSync(framework, {recursive: true}); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + global.fetch = originalFetch; + envKeys.forEach(key => { + if (savedEnv[key] == null) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + }); + fs.rmSync(tmp, {recursive: true, force: true}); +}); + +function releaseUrl(version, flavor = 'debug') { + return `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${version}/hermes-ios-${version}-hermes-ios-${flavor}.tar.gz`; +} + +test.each(['Debug', 'Release'])( + 'uses main metadata with the 1000.0.0 RN package for %s', + async flavor => { + expect(await prepareHermesArtifactsAsync('1000.0.0', flavor)).toBe( + artifacts, + ); + const url = releaseUrl('0.14.0', flavor.toLowerCase()); + expect(global.fetch.mock.calls).toEqual([[url, {method: 'HEAD'}], [url]]); + expect(readFileSync(versionFile, 'utf8')).toBe(`0.14.0-${flavor}`); + expect(fs.existsSync(path.join(artifacts, 'hermes-ios.download'))).toBe( + false, + ); + expect( + fs.existsSync(path.join(artifacts, `hermes-ios-0.14.0-${flavor}.tar.gz`)), + ).toBe(false); + }, +); + +test('selects V1 metadata only with flag 1', async () => { + process.env.RCT_HERMES_V1_ENABLED = '1'; + await prepareHermesArtifactsAsync('0.83.1', 'Debug'); + expect(global.fetch).toHaveBeenCalledWith(releaseUrl('250829098.0.2')); +}); + +test.each([ + '', + 'HERMES_VERSION_NAME=^1.2.3', + Object.assign(new Error('missing version.properties'), {code: 'ENOENT'}), +])( + 'fails invalid or missing metadata before network or extraction: %s', + async invalid => { + properties = invalid; + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow(); + expect(global.fetch).not.toHaveBeenCalled(); + expect(execSync).not.toHaveBeenCalled(); + }, +); + +test('local tarball overrides invalid metadata and explicit nightly', async () => { + const tarball = path.join(tmp, 'local hermes.tar.gz'); + fs.writeFileSync(tarball, 'local archive'); + fs.mkdirSync(artifacts, {recursive: true}); + fs.writeFileSync(versionFile, 'old-version'); + process.env.HERMES_ENGINE_TARBALL_PATH = tarball; + process.env.HERMES_VERSION = 'nightly'; + properties = ''; + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(execSync).toHaveBeenCalledWith( + `tar -xzf "${tarball}" -C "${artifacts}"`, + {stdio: 'inherit'}, + ); + expect(fs.existsSync(tarball)).toBe(true); + expect(fs.existsSync(versionFile)).toBe(false); + expect(fs.readFileSync).not.toHaveBeenCalledWith(propertiesPath, 'utf8'); + expect(global.fetch).not.toHaveBeenCalled(); +}); + +test.each(['123.4.56', '1000.0.0'])( + 'explicit version %s bypasses metadata', + async version => { + process.env.HERMES_VERSION = version; + properties = new Error('missing metadata'); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(global.fetch.mock.calls).toEqual([ + [releaseUrl(version), {method: 'HEAD'}], + [releaseUrl(version)], + ]); + expect(fs.readFileSync).not.toHaveBeenCalledWith(propertiesPath, 'utf8'); + }, +); + +test('only explicit nightly resolves the npm tag', async () => { + process.env.HERMES_VERSION = 'nightly'; + properties = ''; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({version: '123.4.57'}), + }); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(global.fetch.mock.calls).toEqual([ + ['https://registry.npmjs.org/hermes-compiler/nightly'], + [releaseUrl('123.4.57'), {method: 'HEAD'}], + [releaseUrl('123.4.57')], + ]); +}); + +test('an explicit nightly lookup failure does not use the default pin', async () => { + process.env.HERMES_VERSION = 'nightly'; + global.fetch.mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Unavailable', + }); + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow("Couldn't get an answer from NPM: 503 Unavailable"); + expect(global.fetch.mock.calls).toEqual([ + ['https://registry.npmjs.org/hermes-compiler/nightly'], + ]); + expect(execSync).not.toHaveBeenCalled(); +}); + +test('main does not infer a source exception from selected metadata 1000.0.0', async () => { + properties = 'HERMES_VERSION_NAME=1000.0.0'; + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(global.fetch.mock.calls).toEqual([ + [releaseUrl('1000.0.0'), {method: 'HEAD'}], + [releaseUrl('1000.0.0')], + ]); +}); + +test('uses the selected pin for snapshot metadata and download', async () => { + const base = + 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/0.14.0-SNAPSHOT'; + const url = `${base}/hermes-ios-0.14.0-20260101.010203-4-hermes-ios-debug.tar.gz`; + global.fetch.mockImplementation(async (target, options) => { + if (target.endsWith('/maven-metadata.xml')) { + return { + ok: true, + text: async () => + '20260101.0102034', + }; + } + if (options?.method === 'HEAD') { + return {status: target === url ? 200 : 404}; + } + return {ok: true, body: Readable.from(['snapshot archive'])}; + }); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(global.fetch.mock.calls).toEqual([ + [releaseUrl('0.14.0'), {method: 'HEAD'}], + [`${base}/maven-metadata.xml`], + [url, {method: 'HEAD'}], + [`${base}/maven-metadata.xml`], + [url], + ]); +}); + +test('preserves the enterprise repository override', async () => { + process.env.ENTERPRISE_REPOSITORY = 'https://mirror.example/maven'; + await prepareHermesArtifactsAsync('0.83.1', 'Release'); + expect(global.fetch).toHaveBeenCalledWith( + releaseUrl('0.14.0', 'release').replace( + 'https://repo1.maven.org/maven2', + process.env.ENTERPRISE_REPOSITORY, + ), + ); +}); + +test('reuses only the matching Hermes version, flag and flavor cache', async () => { + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + global.fetch.mockClear(); + execSync.mockClear(); + await prepareHermesArtifactsAsync('0.83.1', 'Debug'); + expect(global.fetch).not.toHaveBeenCalled(); + expect(execSync).not.toHaveBeenCalled(); + properties = + 'HERMES_VERSION_NAME=123.4.58\nHERMES_V1_VERSION_NAME=250829098.0.2'; + await prepareHermesArtifactsAsync('0.83.1', 'Debug'); + expect(global.fetch).toHaveBeenCalledWith(releaseUrl('123.4.58')); + await prepareHermesArtifactsAsync('0.83.1', 'Release'); + expect(global.fetch).toHaveBeenCalledWith(releaseUrl('123.4.58', 'release')); + process.env.RCT_HERMES_V1_ENABLED = '1'; + await prepareHermesArtifactsAsync('0.83.1', 'Release'); + expect(global.fetch).toHaveBeenCalledWith( + releaseUrl('250829098.0.2', 'release'), + ); +}); + +test('unavailable artifacts fail without an npm or source fallback', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow('Failed to download: 404 Not Found'); + expect(execSync).not.toHaveBeenCalled(); + expect(global.fetch.mock.calls.some(([url]) => url.includes('npmjs'))).toBe( + false, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js new file mode 100644 index 000000000000..74d62a6137c1 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js @@ -0,0 +1,122 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +'use strict'; + +const {parseHermesMetadata, readHermesMetadata} = require('../hermes-version'); + +const properties = + 'HERMES_VERSION_NAME=0.14.0\nHERMES_V1_VERSION_NAME=250829098.0.2\n'; + +test.each([ + ['legacy-default', undefined, false], + ['legacy-default', '0', false], + ['legacy-default', '1', true], + ['legacy-default', '', false], + ['legacy-default', 'true', false], + ['v1-default', undefined, true], + ['v1-default', '0', false], + ['v1-default', '1', true], + ['v1-default', '', true], + ['v1-default', 'true', true], + ['single', undefined, false], + ['single', '0', false], + ['single', '1', false], +])('%s with flag %s selects the exact key and tag file', (policy, flag, v1) => { + expect(parseHermesMetadata(properties, policy, flag)).toEqual({ + version: v1 ? '250829098.0.2' : '0.14.0', + versionKey: v1 ? 'HERMES_V1_VERSION_NAME' : 'HERMES_VERSION_NAME', + tagFile: v1 ? '.hermesv1version' : '.hermesversion', + }); +}); + +test('the pure parser defaults to legacy without reading the environment', () => { + const previous = process.env.RCT_HERMES_V1_ENABLED; + try { + process.env.RCT_HERMES_V1_ENABLED = '1'; + expect(parseHermesMetadata(properties).version).toBe('0.14.0'); + } finally { + if (previous == null) { + delete process.env.RCT_HERMES_V1_ENABLED; + } else { + process.env.RCT_HERMES_V1_ENABLED = previous; + } + } +}); + +test.each([ + '0.14.0', + '250829098.0.2', + '1.2.3-rc.1', + '1.2.3+build.1', + '1000.0.0', +])('accepts exact version %s with comments, whitespace and CRLF', version => { + expect( + parseHermesMetadata( + `# Hermes pin\r\n! comment\r\n OTHER_KEY=ignored\r\n HERMES_VERSION_NAME = ${version} \r\n`, + ).version, + ).toBe(version); +}); + +test.each([ + '', + 'HERMES_V1_VERSION_NAME=1.2.3', + 'OTHER_HERMES_VERSION_NAME=1.2.3', + '# HERMES_VERSION_NAME=1.2.3', + 'HERMES_VERSION_NAME=', + 'HERMES_VERSION_NAME=nightly', + 'HERMES_VERSION_NAME=latest-v1', + 'HERMES_VERSION_NAME=^1.2.3', + 'HERMES_VERSION_NAME=~1.2.3', + 'HERMES_VERSION_NAME=1.2.x', + 'HERMES_VERSION_NAME=>=1.2.3', + 'HERMES_VERSION_NAME=1.2.3 || 2.0.0', + 'HERMES_VERSION_NAME=v1.2.3', + 'HERMES_VERSION_NAME=01.2.3', + 'HERMES_VERSION_NAME=1.2.3-01', + 'HERMES_VERSION_NAME=1.2.3=invalid', + 'HERMES_VERSION_NAME=1.2.3 # comment', + 'HERMES_VERSION_NAME=1.2.3\n HERMES_VERSION_NAME = 1.2.3', + 'HERMES_VERSION_NAME=1.2.3\nHERMES_VERSION_NAME=2.0.0', +])('rejects invalid selected metadata: %s', input => { + expect(() => parseHermesMetadata(input)).toThrow( + 'Expected one exact HERMES_VERSION_NAME', + ); +}); + +test('validates only the selected key without a fallback to another key', () => { + const input = 'HERMES_VERSION_NAME=invalid\nHERMES_V1_VERSION_NAME=1.2.3'; + expect(parseHermesMetadata(input, 'legacy-default', '1').version).toBe( + '1.2.3', + ); + expect(() => parseHermesMetadata(input)).toThrow('HERMES_VERSION_NAME'); + expect(() => + parseHermesMetadata('HERMES_VERSION_NAME=1.2.3', 'v1-default'), + ).toThrow('HERMES_V1_VERSION_NAME'); + expect(() => + parseHermesMetadata( + `${properties}HERMES_V1_VERSION_NAME=1.2.3`, + 'v1-default', + ), + ).toThrow('HERMES_V1_VERSION_NAME'); +}); + +test('rejects an unknown policy', () => { + expect(() => parseHermesMetadata(properties, 'guess')).toThrow( + 'Unknown Hermes metadata policy', + ); +}); + +test('reads main metadata relative to the helper', () => { + expect(readHermesMetadata('legacy-default', '0').version).toBe('0.14.0'); + expect(readHermesMetadata('legacy-default', '1').version).toBe( + '250829098.0.2', + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/hermes-version.js b/packages/react-native/scripts/ios-prebuild/hermes-version.js new file mode 100644 index 000000000000..01f6a8bf46ce --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/hermes-version.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const semver = require('semver'); + +/*:: +type HermesPolicy = 'legacy-default' | 'v1-default' | 'single'; +type HermesMetadataSelection = { + versionKey: string, + tagFile: string, +}; +type HermesMetadata = { + ...HermesMetadataSelection, + version: string, +}; +*/ + +// Keep policy explicit so later release lines can change their default without +// inferring it from a React Native package version or the available keys. +function selectHermesMetadata( + policy /*: HermesPolicy */ = 'legacy-default', + hermesV1Enabled /*: ?string */, +) /*: HermesMetadataSelection */ { + let useV1; + switch (policy) { + case 'legacy-default': + useV1 = hermesV1Enabled === '1'; + break; + case 'v1-default': + useV1 = hermesV1Enabled !== '0'; + break; + case 'single': + useV1 = false; + break; + default: + throw new Error(`Unknown Hermes metadata policy: ${policy}`); + } + + const versionKey = useV1 ? 'HERMES_V1_VERSION_NAME' : 'HERMES_VERSION_NAME'; + const tagFile = useV1 ? '.hermesv1version' : '.hermesversion'; + return {versionKey, tagFile}; +} + +function parseHermesMetadata( + properties /*: string */, + policy /*: HermesPolicy */ = 'legacy-default', + hermesV1Enabled /*: ?string */, +) /*: HermesMetadata */ { + const {versionKey, tagFile} = selectHermesMetadata(policy, hermesV1Enabled); + const entries = properties.split(/\r?\n/).filter(line => { + const equals = line.indexOf('='); + return equals !== -1 && line.slice(0, equals).trim() === versionKey; + }); + const version = + entries.length === 1 + ? entries[0].slice(entries[0].indexOf('=') + 1).trim() + : ''; + // semver.valid removes build metadata; retain it in the artifact coordinate. + // 1000.0.0 is also an exact version. Any release-specific source exception + // belongs to the caller, not the metadata parser. + if (!version || semver.valid(version) !== version.replace(/\+.*/, '')) { + throw new Error( + `Expected one exact ${versionKey} version in Hermes version.properties`, + ); + } + return {version, versionKey, tagFile}; +} + +function readHermesMetadata( + policy /*: HermesPolicy */ = 'legacy-default', + hermesV1Enabled /*: ?string */ = process.env.RCT_HERMES_V1_ENABLED, +) /*: HermesMetadata */ { + const propertiesPath = path.resolve( + __dirname, + '../../sdks/hermes-engine/version.properties', + ); + return parseHermesMetadata( + fs.readFileSync(propertiesPath, 'utf8'), + policy, + hermesV1Enabled, + ); +} + +module.exports = { + selectHermesMetadata, + parseHermesMetadata, + readHermesMetadata, +}; diff --git a/packages/react-native/scripts/ios-prebuild/hermes.js b/packages/react-native/scripts/ios-prebuild/hermes.js index 72c0c4c2073a..c0a4ae51a78c 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes.js +++ b/packages/react-native/scripts/ios-prebuild/hermes.js @@ -8,6 +8,7 @@ * @format */ +const {readHermesMetadata} = require('./hermes-version'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); @@ -27,6 +28,8 @@ import type {BuildFlavor, Destination, Platform} from './types'; * version of hermes, use the HERMES_VERSION environment variable. The path to the artifacts will be inside * the .build/artifacts/hermes folder, but this can be overridden by setting the HERMES_ENGINE_TARBALL_PATH * environment variable. If this varuable is set, the script will use the local tarball instead of downloading it. + * [macOS] Without an override, use the selected version.properties pin. Only an explicit + * HERMES_VERSION=nightly resolves the npm nightly tag. */ async function prepareHermesArtifactsAsync( reactNativeVersion /*:string*/, @@ -54,7 +57,9 @@ async function prepareHermesArtifactsAsync( // Only check if the artifacts folder exists if we are not using a local tarball if (!localPath) { // Resolve the version from the environment variable or use the default version - let resolvedVersion = process.env.HERMES_VERSION ?? 'nightly'; + // [macOS] Hermes artifacts use the selected SDK pin, not the RN version. + let resolvedVersion = + process.env.HERMES_VERSION ?? readHermesMetadata().version; if (resolvedVersion === 'nightly') { hermesLog('Using latest nightly tarball'); From 2dd03eebc827314cee34942124e8b67e7740ff9a Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 00:02:51 -0500 Subject: [PATCH 23/38] test(hermes): validate each branch metadata without fixed versions (cherry picked from commit 1549ba34ad124c64fc91202ed0ed8a98681a9df8) --- .../scripts/__tests__/resolve-hermes-test.js | 30 +++++++++++---- .../ios-prebuild/__tests__/hermes-test.js | 37 +++++++++++-------- .../__tests__/hermes-version-test.js | 23 +++++++++--- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/.github/scripts/__tests__/resolve-hermes-test.js b/.github/scripts/__tests__/resolve-hermes-test.js index 534e6383c719..c511dc055768 100644 --- a/.github/scripts/__tests__/resolve-hermes-test.js +++ b/.github/scripts/__tests__/resolve-hermes-test.js @@ -12,10 +12,20 @@ const {spawnSync} = require('child_process'); const fs = require('fs'); +const ini = require('ini'); const os = require('os'); const path = require('path'); const root = path.resolve(__dirname, '../../..'); +const metadata = ini.parse( + fs.readFileSync( + path.join( + root, + 'packages/react-native/sdks/hermes-engine/version.properties', + ), + 'utf8', + ), +); const script = path.join(root, '.github/scripts/resolve-hermes.mts'); const preload = path.join(__dirname, '__fixtures__/resolve-hermes.cjs'); let tmp; @@ -61,8 +71,8 @@ function run(command, overrides = {}) { } test.each([ - ['0', '0.14.0', 'HERMES_VERSION_NAME', 'Debug'], - ['1', '250829098.0.2', 'HERMES_V1_VERSION_NAME', 'Release'], + ['0', metadata.HERMES_VERSION_NAME, 'HERMES_VERSION_NAME', 'Debug'], + ['1', metadata.HERMES_V1_VERSION_NAME, 'HERMES_V1_VERSION_NAME', 'Release'], ])( 'CI downloads flag %s with the selected key and version', (flag, version, key, flavor) => { @@ -83,13 +93,17 @@ test.each([ ); test('CI snapshot fallback preserves the four-argument URL helper contract', () => { - const result = run(['download-hermes'], {HERMES_TEST_DOWNLOAD: 'snapshot'}); + const version = '123.4.56'; + const result = run(['download-hermes'], { + HERMES_TEST_DOWNLOAD: 'snapshot', + HERMES_TEST_PROPERTIES: `HERMES_VERSION_NAME=${version}`, + }); expect(result.status).toBe(0); - expect(result.output).toContain('version=0.14.0\n'); + expect(result.output).toContain(`version=${version}\n`); expect(result.urls).toEqual([ - 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/0.14.0-SNAPSHOT/maven-metadata.xml', - 'https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/0.14.0/hermes-ios-0.14.0-hermes-ios-debug.tar.gz', - 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/0.14.0-SNAPSHOT/hermes-ios-0.14.0-20260101.010203-4-hermes-ios-debug.tar.gz', + `https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/${version}-SNAPSHOT/maven-metadata.xml`, + `https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/${version}/hermes-ios-${version}-hermes-ios-debug.tar.gz`, + `https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/${version}-SNAPSHOT/hermes-ios-${version}-20260101.010203-4-hermes-ios-debug.tar.gz`, ]); }); @@ -149,7 +163,7 @@ test.each([ test.each([ ['0', '.hermesversion', 'MISSING'], ['1', '.hermesv1version', 'MISSING'], - ['1', '.hermesv1version', 'HERMES_VERSION_NAME=0.14.0'], + ['1', '.hermesv1version', 'HERMES_VERSION_NAME=123.4.56'], ['0', '.hermesversion', 'HERMES_VERSION_NAME=invalid'], ])( 'CI resolve-commit reads flag %s tag %s independently of metadata %s', diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js index cd7567050b8c..6da217e7ee92 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js @@ -15,6 +15,7 @@ jest.mock('child_process', () => ({execSync: jest.fn()})); const {prepareHermesArtifactsAsync} = require('../hermes'); const {execSync} = require('child_process'); const fs = require('fs'); +const ini = require('ini'); const os = require('os'); const path = require('path'); const {Readable} = require('stream'); @@ -24,6 +25,8 @@ const propertiesPath = path.resolve( '../../../sdks/hermes-engine/version.properties', ); const readFileSync = fs.readFileSync.bind(fs); +const checkedInProperties = readFileSync(propertiesPath, 'utf8'); +const metadata = ini.parse(checkedInProperties); const originalFetch = global.fetch; const envKeys = [ 'RCT_HERMES_V1_ENABLED', @@ -50,7 +53,7 @@ beforeEach(() => { artifacts, 'destroot/Library/Frameworks/universal/hermesvm.xcframework', ); - properties = readFileSync(propertiesPath, 'utf8'); + properties = 'HERMES_VERSION_NAME=123.4.56\nHERMES_V1_VERSION_NAME=234.5.67'; jest.spyOn(process, 'cwd').mockReturnValue(tmp); jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...args) => { @@ -91,27 +94,34 @@ function releaseUrl(version, flavor = 'debug') { } test.each(['Debug', 'Release'])( - 'uses main metadata with the 1000.0.0 RN package for %s', + 'uses checked-in metadata with the 1000.0.0 RN package for %s', async flavor => { + properties = checkedInProperties; + const version = metadata.HERMES_VERSION_NAME; expect(await prepareHermesArtifactsAsync('1000.0.0', flavor)).toBe( artifacts, ); - const url = releaseUrl('0.14.0', flavor.toLowerCase()); + const url = releaseUrl(version, flavor.toLowerCase()); expect(global.fetch.mock.calls).toEqual([[url, {method: 'HEAD'}], [url]]); - expect(readFileSync(versionFile, 'utf8')).toBe(`0.14.0-${flavor}`); + expect(readFileSync(versionFile, 'utf8')).toBe(`${version}-${flavor}`); expect(fs.existsSync(path.join(artifacts, 'hermes-ios.download'))).toBe( false, ); expect( - fs.existsSync(path.join(artifacts, `hermes-ios-0.14.0-${flavor}.tar.gz`)), + fs.existsSync( + path.join(artifacts, `hermes-ios-${version}-${flavor}.tar.gz`), + ), ).toBe(false); }, ); test('selects V1 metadata only with flag 1', async () => { + properties = checkedInProperties; process.env.RCT_HERMES_V1_ENABLED = '1'; await prepareHermesArtifactsAsync('0.83.1', 'Debug'); - expect(global.fetch).toHaveBeenCalledWith(releaseUrl('250829098.0.2')); + expect(global.fetch).toHaveBeenCalledWith( + releaseUrl(metadata.HERMES_V1_VERSION_NAME), + ); }); test.each([ @@ -205,8 +215,8 @@ test('main does not infer a source exception from selected metadata 1000.0.0', a test('uses the selected pin for snapshot metadata and download', async () => { const base = - 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/0.14.0-SNAPSHOT'; - const url = `${base}/hermes-ios-0.14.0-20260101.010203-4-hermes-ios-debug.tar.gz`; + 'https://central.sonatype.com/repository/maven-snapshots/com/facebook/hermes/hermes-ios/123.4.56-SNAPSHOT'; + const url = `${base}/hermes-ios-123.4.56-20260101.010203-4-hermes-ios-debug.tar.gz`; global.fetch.mockImplementation(async (target, options) => { if (target.endsWith('/maven-metadata.xml')) { return { @@ -222,7 +232,7 @@ test('uses the selected pin for snapshot metadata and download', async () => { }); await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); expect(global.fetch.mock.calls).toEqual([ - [releaseUrl('0.14.0'), {method: 'HEAD'}], + [releaseUrl('123.4.56'), {method: 'HEAD'}], [`${base}/maven-metadata.xml`], [url, {method: 'HEAD'}], [`${base}/maven-metadata.xml`], @@ -234,7 +244,7 @@ test('preserves the enterprise repository override', async () => { process.env.ENTERPRISE_REPOSITORY = 'https://mirror.example/maven'; await prepareHermesArtifactsAsync('0.83.1', 'Release'); expect(global.fetch).toHaveBeenCalledWith( - releaseUrl('0.14.0', 'release').replace( + releaseUrl('123.4.56', 'release').replace( 'https://repo1.maven.org/maven2', process.env.ENTERPRISE_REPOSITORY, ), @@ -248,17 +258,14 @@ test('reuses only the matching Hermes version, flag and flavor cache', async () await prepareHermesArtifactsAsync('0.83.1', 'Debug'); expect(global.fetch).not.toHaveBeenCalled(); expect(execSync).not.toHaveBeenCalled(); - properties = - 'HERMES_VERSION_NAME=123.4.58\nHERMES_V1_VERSION_NAME=250829098.0.2'; + properties = 'HERMES_VERSION_NAME=123.4.58\nHERMES_V1_VERSION_NAME=234.5.67'; await prepareHermesArtifactsAsync('0.83.1', 'Debug'); expect(global.fetch).toHaveBeenCalledWith(releaseUrl('123.4.58')); await prepareHermesArtifactsAsync('0.83.1', 'Release'); expect(global.fetch).toHaveBeenCalledWith(releaseUrl('123.4.58', 'release')); process.env.RCT_HERMES_V1_ENABLED = '1'; await prepareHermesArtifactsAsync('0.83.1', 'Release'); - expect(global.fetch).toHaveBeenCalledWith( - releaseUrl('250829098.0.2', 'release'), - ); + expect(global.fetch).toHaveBeenCalledWith(releaseUrl('234.5.67', 'release')); }); test('unavailable artifacts fail without an npm or source fallback', async () => { diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js index 74d62a6137c1..a199b30fef2e 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js @@ -11,9 +11,12 @@ 'use strict'; const {parseHermesMetadata, readHermesMetadata} = require('../hermes-version'); +const fs = require('fs'); +const ini = require('ini'); +const path = require('path'); const properties = - 'HERMES_VERSION_NAME=0.14.0\nHERMES_V1_VERSION_NAME=250829098.0.2\n'; + 'HERMES_VERSION_NAME=123.4.56\nHERMES_V1_VERSION_NAME=234.5.67\n'; test.each([ ['legacy-default', undefined, false], @@ -31,7 +34,7 @@ test.each([ ['single', '1', false], ])('%s with flag %s selects the exact key and tag file', (policy, flag, v1) => { expect(parseHermesMetadata(properties, policy, flag)).toEqual({ - version: v1 ? '250829098.0.2' : '0.14.0', + version: v1 ? '234.5.67' : '123.4.56', versionKey: v1 ? 'HERMES_V1_VERSION_NAME' : 'HERMES_VERSION_NAME', tagFile: v1 ? '.hermesv1version' : '.hermesversion', }); @@ -41,7 +44,7 @@ test('the pure parser defaults to legacy without reading the environment', () => const previous = process.env.RCT_HERMES_V1_ENABLED; try { process.env.RCT_HERMES_V1_ENABLED = '1'; - expect(parseHermesMetadata(properties).version).toBe('0.14.0'); + expect(parseHermesMetadata(properties).version).toBe('123.4.56'); } finally { if (previous == null) { delete process.env.RCT_HERMES_V1_ENABLED; @@ -114,9 +117,17 @@ test('rejects an unknown policy', () => { ); }); -test('reads main metadata relative to the helper', () => { - expect(readHermesMetadata('legacy-default', '0').version).toBe('0.14.0'); +test('reads checked-in metadata relative to the helper', () => { + const metadata = ini.parse( + fs.readFileSync( + path.resolve(__dirname, '../../../sdks/hermes-engine/version.properties'), + 'utf8', + ), + ); + expect(readHermesMetadata('legacy-default', '0').version).toBe( + metadata.HERMES_VERSION_NAME, + ); expect(readHermesMetadata('legacy-default', '1').version).toBe( - '250829098.0.2', + metadata.HERMES_V1_VERSION_NAME, ); }); From 685d05a7e1f98629e9a9b43fcce9fbf8035dcbc6 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 00:10:02 -0500 Subject: [PATCH 24/38] fix(packaging): include the Hermes V1 source tag --- packages/react-native/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native/package.json b/packages/react-native/package.json index b3ef7499d0d6..734947fa5c34 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -152,6 +152,7 @@ "scripts/xcode/ccache.conf", "scripts/xcode/with-environment.sh", "sdks/.hermesversion", + "sdks/.hermesv1version", "sdks/hermes-engine/**", "sdks/hermesc", "settings.gradle.kts", From f2467af027e32ef11d3d427dfb998902970e10b3 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 03:09:52 -0500 Subject: [PATCH 25/38] test(release): validate graph policy across private and public branches --- .../__tests__/publishing-contract.test.mjs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/scripts/__tests__/publishing-contract.test.mjs b/.github/scripts/__tests__/publishing-contract.test.mjs index ea51fbf79614..776b34927eac 100644 --- a/.github/scripts/__tests__/publishing-contract.test.mjs +++ b/.github/scripts/__tests__/publishing-contract.test.mjs @@ -181,10 +181,39 @@ function releaseFixture(t, {versionPrivatePackages = false, workspaces = graph() const repositoryRoot = new URL('../../../', import.meta.url).pathname; const releasePolicy = JSON.parse(readFileSync(join(repositoryRoot, '.changeset/config.json'), 'utf8')); const getReleasePlan = require('@changesets/get-release-plan').default; +const semver = require('semver'); -test('repository Changesets policy accepts main with private lists and skips their release', async t => { +test('repository Changesets policy disables private versions and tags and fixes core with lists', () => { + assert.deepEqual(releasePolicy.privatePackages, {version: false, tag: false}); + assert.deepEqual(releasePolicy.fixed, [[core, lists]]); +}); + +test('repository Changesets policy follows the actual public and private workspace graph', async t => { const workspaces = readWorkspaces(repositoryRoot); - assert.equal(workspaces.find(pkg => pkg.name === lists).private, true); + const corePackage = workspaces.find(pkg => pkg.name === core); + const listsPackage = workspaces.find(pkg => pkg.name === lists); + assert.ok(corePackage && !corePackage.private, 'Missing public core workspace'); + assert.ok(listsPackage, 'Missing lists workspace'); + const publicPackages = listsPackage.private ? [corePackage] : [corePackage, listsPackage]; + // Derive expectations from manifests, never from the policy or release-plan output. + const nextVersion = semver.inc(publicPackages.map(pkg => pkg.version).sort(semver.rcompare)[0], 'patch'); + const expected = publicPackages.map(pkg => [pkg.name, nextVersion]).sort(); + const {root} = releaseFixture(t, {workspaces, config: releasePolicy}); + assert.deepEqual((await getReleasePlan(root)).releases, []); + for (const changed of [core, lists]) { + writeFileSync(join(root, '.changeset/fix.md'), `---\n"${changed}": patch\n---\n\nFix package.\n`); + const bumped = (await getReleasePlan(root)).releases.filter(pkg => pkg.type !== 'none'); + assert.deepEqual(bumped.map(pkg => [pkg.name, pkg.newVersion]).sort(), + changed === lists && listsPackage.private ? [] : expected); + } +}); + +test('repository Changesets policy accepts a private lists fixture and skips its release', async t => { + const workspaces = graph('1000.0.0'); + workspaces[1].private = true; + // A public package can use skipped private packages as development dependencies. + workspaces[0].devDependencies = {[lists]: workspaces[0].dependencies[lists]}; + delete workspaces[0].dependencies[lists]; const {root} = releaseFixture(t, {workspaces, config: releasePolicy}); assert.deepEqual((await getReleasePlan(root)).releases, []); writeFileSync(join(root, '.changeset/fix.md'), `---\n"${core}": patch\n---\n\nFix core.\n`); From 05d941b56001c1280cd71977b6f9ff6c279e69d8 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 04:24:42 -0500 Subject: [PATCH 26/38] fix(release): honor local Changesets bases and repository remotes --- .github/scripts/__tests__/change.test.mjs | 95 +++++++++++++++++++++++ .github/scripts/change.mts | 28 +++++-- 2 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/__tests__/change.test.mjs diff --git a/.github/scripts/__tests__/change.test.mjs b/.github/scripts/__tests__/change.test.mjs new file mode 100644 index 000000000000..1d49f666df5a --- /dev/null +++ b/.github/scripts/__tests__/change.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {test} from 'node:test'; +import {getBaseBranch} from '../change.mts'; + +const repository = {url: 'git+https://github.com/microsoft/react-native-macos.git'}; + +function fixture(t, {baseBranch = 'origin/main', rootRepository, coreRepository = repository, + remotes = {origin: 'https://github.com/contributor/react-native-macos.git', + upstream: 'git@github.com:microsoft/react-native-macos.git'}} = {}) { + const root = mkdtempSync(join(tmpdir(), 'rnm-change-base-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + const baseRef = process.env.GITHUB_BASE_REF; + t.after(() => { + if (baseRef === undefined) delete process.env.GITHUB_BASE_REF; + else process.env.GITHUB_BASE_REF = baseRef; + }); + delete process.env.GITHUB_BASE_REF; + mkdirSync(join(root, '.changeset')); + mkdirSync(join(root, 'packages/react-native'), {recursive: true}); + writeFileSync(join(root, 'package.json'), JSON.stringify({repository: rootRepository})); + writeFileSync(join(root, 'packages/react-native/package.json'), JSON.stringify({repository: coreRepository})); + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify({baseBranch})); + const git = args => execFileSync('git', args, {cwd: root, encoding: 'utf8'}); + git(['init', '-q']); + for (const [name, url] of Object.entries(remotes)) git(['remote', 'add', name, url]); + return root; +} + +for (const branch of ['main', '0.83-stable', 'release/0.83']) { + test(`local ${branch} uses core metadata to remap a fork origin to upstream`, async t => { + const root = fixture(t, {baseBranch: `origin/${branch}`}); + assert.equal(await getBaseBranch(root), `upstream/${branch}`); + }); +} + +test('root repository metadata takes precedence and remote matching is exact', async t => { + const root = fixture(t, {rootRepository: {url: 'https://github.com/example/project.git'}, + remotes: {origin: 'https://github.com/example/project-extra.git', + canonical: 'git@github.com:Example/Project.git', upstream: repository.url}}); + assert.equal(await getBaseBranch(root), 'canonical/main'); +}); + +test('a normal Microsoft origin honors main and the configured stable branch', async t => { + const root = fixture(t, {remotes: {origin: repository.url}}); + assert.equal(await getBaseBranch(root), 'origin/main'); + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify({baseBranch: 'origin/0.83-stable'})); + assert.equal(await getBaseBranch(root), 'origin/0.83-stable'); +}); + +test('an empty root URL falls back to string core metadata and an arbitrary remote name', async t => { + const root = fixture(t, {rootRepository: '', coreRepository: repository.url, + baseBranch: 'origin/0.83-stable', remotes: {canonical: repository.url}}); + assert.equal(await getBaseBranch(root), 'canonical/0.83-stable'); +}); + +test('an explicit configured remote or local branch remains authoritative', async t => { + const root = fixture(t, {remotes: {origin: repository.url, review: 'https://github.com/contributor/react-native-macos.git'}}); + for (const baseBranch of ['review/0.83-stable', 'main', 'release/0.83']) { + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify({baseBranch})); + assert.equal(await getBaseBranch(root), baseBranch); + } +}); + +test('GITHUB_BASE_REF takes precedence over local config for forks and CI checkouts', async t => { + const root = fixture(t, {baseBranch: 'review/main'}); + process.env.GITHUB_BASE_REF = '0.83-stable'; + assert.equal(await getBaseBranch(root), 'upstream/0.83-stable'); + execFileSync('git', ['remote', 'remove', 'upstream'], {cwd: root}); + execFileSync('git', ['remote', 'set-url', 'origin', repository.url], {cwd: root}); + assert.equal(await getBaseBranch(root), 'origin/0.83-stable'); +}); + +test('missing repository metadata retains the origin fallback', async t => { + const root = fixture(t, {baseBranch: 'origin/0.83-stable', coreRepository: {}}); + assert.equal(await getBaseBranch(root), 'origin/0.83-stable'); + rmSync(join(root, 'packages/react-native/package.json')); + assert.equal(await getBaseBranch(root), 'origin/0.83-stable'); +}); + +test('manifest, config, and Git errors propagate from the public resolver', async t => { + const root = fixture(t); + const config = join(root, '.changeset/config.json'); + writeFileSync(config, '{'); + await assert.rejects(getBaseBranch(root), SyntaxError); + writeFileSync(config, JSON.stringify({baseBranch: 'origin/main'})); + writeFileSync(join(root, 'packages/react-native/package.json'), '{'); + await assert.rejects(getBaseBranch(root), SyntaxError); + writeFileSync(join(root, 'packages/react-native/package.json'), JSON.stringify({repository})); + rmSync(join(root, '.git'), {recursive: true}); + await assert.rejects(getBaseBranch(root), /not a git repository/); +}); diff --git a/.github/scripts/change.mts b/.github/scripts/change.mts index c97bdae21b8a..731e2b98dabe 100644 --- a/.github/scripts/change.mts +++ b/.github/scripts/change.mts @@ -2,6 +2,7 @@ // @ts-ignore import { parseArgs, styleText } from 'node:util'; import { pathToFileURL } from 'node:url'; +import { join } from 'node:path'; import { $, echo, fs } from 'zx'; import { validatePreparedVersionPR } from './publishing-contract.mjs'; @@ -10,7 +11,7 @@ import { validatePreparedVersionPR } from './publishing-contract.mjs'; * Wrapper around `changeset add` (default) and `changeset status` validation (--check). * * Without --check: runs `changeset add` interactively with the correct upstream remote - * auto-detected from package.json's repository URL, temporarily patched into config.json. + * auto-detected from repository metadata and the base branch from Changesets config. * * With --check (CI mode): validates that all changed public packages have changesets and that * no major version bumps are introduced, or validates a fully prepared version PR. @@ -35,21 +36,32 @@ const log = { }; /** Find the remote that matches the repo's own URL (works for forks and CI alike). */ -async function getBaseBranch(): Promise { - const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); - const repoUrl: string = pkg.repository?.url ?? ''; +export async function getBaseBranch(root = process.cwd()): Promise { + const pkg = fs.readJsonSync(join(root, 'package.json')); + let repoUrl: string = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url ?? ''; + const coreManifest = join(root, 'packages/react-native/package.json'); + if (!repoUrl && fs.existsSync(coreManifest)) { + const core = fs.readJsonSync(coreManifest); + repoUrl = typeof core.repository === 'string' ? core.repository : core.repository?.url ?? ''; + } // Extract "org/repo" from https://github.com/org/repo.git or git@github.com:org/repo.git - const repoPath = repoUrl.match(/github\.com[:/](.+?)(?:\.git)?$/)?.[1] ?? ''; + const repoPath = (url: string) => url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/i)?.[1]?.toLowerCase(); + const repository = repoPath(repoUrl); - const remotes = (await $`git remote -v`.quiet()).stdout; - const remote = (repoPath && remotes.match(new RegExp(`^(\\S+)\\s+.*${repoPath}`, 'm'))?.[1]) || 'origin'; + const remotes = (await $({ cwd: root })`git remote -v`.quiet()).stdout; + const remote = (repository && remotes.trim().split('\n') + .map(line => line.split(/\s+/)) + .find(([, url, kind]) => kind === '(fetch)' && repoPath(url) === repository)?.[0]) || 'origin'; // In CI, use the PR target branch (e.g., origin/0.81-stable) if (process.env['GITHUB_BASE_REF']) { return `${remote}/${process.env['GITHUB_BASE_REF']}`; } - return `${remote}/main`; + const config = fs.readJsonSync(join(root, '.changeset/config.json')); + const baseBranch: string = config.baseBranch ?? 'origin/main'; + // origin is the shared config's checkout remote; preserve explicit local overrides. + return baseBranch.startsWith('origin/') ? `${remote}/${baseBranch.slice('origin/'.length)}` : baseBranch; } /** Run `changeset status` and return the output and exit code. */ From c440e99a1fd7edf5f533d0234e621ee83ee6c6cf Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 04:49:27 -0500 Subject: [PATCH 27/38] fix(release): generate exported package types before publication Prepare Node packages, local codegen and validated generated types only after release eligibility. Use the coupled public package selector for PR dry runs, excluding independently published init. --- .../__tests__/publishing-workflow.test.mjs | 179 ++++++++++++++++++ .github/workflows/microsoft-npm-publish.yml | 20 +- .github/workflows/microsoft-pr.yml | 7 +- 3 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/__tests__/publishing-workflow.test.mjs diff --git a/.github/scripts/__tests__/publishing-workflow.test.mjs b/.github/scripts/__tests__/publishing-workflow.test.mjs new file mode 100644 index 000000000000..53b119a4fc18 --- /dev/null +++ b/.github/scripts/__tests__/publishing-workflow.test.mjs @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import {execFileSync, fork} from 'node:child_process'; +import {once} from 'node:events'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {tmpdir} from 'node:os'; +import {dirname, join, resolve} from 'node:path'; +import {test} from 'node:test'; + +const require = createRequire(import.meta.url); +const {load} = createRequire(require.resolve('eslint'))('js-yaml'); +// An optional root lets the shared test check an equivalent release worktree. +const repositoryRoot = resolve(process.env.PUBLISH_WORKFLOW_ROOT ?? new URL('../../../', import.meta.url).pathname); +const yarnPath = join(repositoryRoot, '.yarn/releases/yarn-4.12.0.cjs'); +const core = 'react-native-macos'; +const lists = '@react-native-macos/virtualized-lists'; +const workflow = name => load(readFileSync(join(repositoryRoot, `.github/workflows/${name}.yml`), 'utf8')); +const publishSteps = workflow('microsoft-npm-publish').jobs.publish.steps; +const dryRunSteps = workflow('microsoft-pr').jobs['npm-publish-dry-run'].steps; + +async function fixture(t, {eligible = '1', fail = '', privateLists = false} = {}) { + const root = mkdtempSync(join(tmpdir(), 'rnm-publish-workflow-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + const write = (path, contents) => { + mkdirSync(dirname(join(root, path)), {recursive: true}); + writeFileSync(join(root, path), contents); + }; + const json = (path, value) => write(path, JSON.stringify(value)); + json('package.json', {name: 'fixture', private: true, workspaces: ['packages/*'], scripts: { + build: 'node fixture.cjs tooling', 'build-types': 'node fixture.cjs types', + }}); + for (const [directory, name, isPrivate] of [ + ['core', core, false], ['lists', lists, privateLists], + ['codegen', '@react-native/codegen', true], + ['init', 'react-native-macos-init', false], ['upstream', '@react-native/unrelated', false], + ['internal', '@react-native-macos/internal', true], + ]) { + json(`packages/${directory}/package.json`, {name, version: '1.0.0', private: isPrivate, + files: ['types_generated'], + ...(name === core ? {dependencies: {[lists]: 'workspace:*'}} : {}), + scripts: name === '@react-native/codegen' ? {build: 'node ../../fixture.cjs codegen'} + : {prepack: `node ../../fixture.cjs pack ${directory}`}, + }); + } + write('snapshot', 'checked-in API\n'); + write('yarn.lock', ''); + // --tolerate-republish queries metadata even during a dry run. A local + // registry returns 404 and rejects any attempted upload. + write('registry.cjs', ` + require('node:http').createServer((req, res) => { + if (req.method !== 'GET') { + require('node:fs').writeFileSync(__dirname + '/upload-attempt', req.method); + } + res.writeHead(req.method === 'GET' ? 404 : 500); + res.end('{}'); + }).listen(0, '127.0.0.1', function () { process.send(this.address().port); }); + `); + const registry = fork(join(root, 'registry.cjs'), [], {stdio: ['ignore', 'ignore', 'inherit', 'ipc']}); + t.after(() => registry.kill()); + const [port] = await once(registry, 'message'); + write('.fixture-yarnrc.yml', `npmRegistryServer: "http://127.0.0.1:${port}"\nunsafeHttpWhitelist:\n - 127.0.0.1\n`); + // Synthetic build outputs isolate workflow sequencing from the full compiler. + // Real Yarn runs the workspace selector, prepack hooks, and package dry run. + write('fixture.cjs', ` + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const path = require('node:path'); + const root = __dirname; + const [command, arg] = process.argv.slice(2); + const has = file => fs.existsSync(path.join(root, file)); + const record = value => fs.appendFileSync(path.join(root, 'events'), value + '\\n'); + assert.notEqual(command, process.env.FAIL, 'Injected build failure'); + if (command === 'tooling' || command === 'codegen') { + fs.writeFileSync(path.join(root, command), 'built'); + record(command); + } else if (command === 'types') { + assert.ok(has('tooling') && has('codegen'), 'Types ran before local builds'); + assert.equal(arg, '--validate', 'Snapshot must be validated'); + assert.equal(fs.readFileSync(path.join(root, 'snapshot'), 'utf8'), 'checked-in API\\n'); + for (const pkg of ['core', 'lists']) { + const output = path.join(root, 'packages', pkg, 'types_generated'); + fs.mkdirSync(output); + fs.writeFileSync(path.join(output, 'index.d.ts'), 'export {};'); + } + record('types'); + } else if (command === 'pack') { + assert.ok(['core', 'lists'].includes(arg), 'Packed an unrelated package'); + assert.ok(has('tooling') && has('codegen'), 'Packed without local builds'); + assert.ok(has('packages/' + arg + '/types_generated/index.d.ts'), 'Packed without generated types'); + record('pack ' + arg); + } else { + assert.fail('Unexpected fixture command: ' + command); + } + `); + // The contract suite covers eligibility semantics. Here its output exercises + // the actual workflow conditions, including skipped and failed preparation. + write('.ado/scripts/configure-publish.mts', ` + import assert from 'node:assert/strict'; + import {appendFileSync, existsSync} from 'node:fs'; + const publish = process.argv.includes('--publish'); + appendFileSync('events', publish ? 'publish\\n' : 'preview\\n'); + if (publish) { + assert.equal(process.env.ELIGIBLE, '1'); + for (const pkg of ['core', 'lists']) { + assert.ok(existsSync('packages/' + pkg + '/types_generated/index.d.ts')); + } + } else { + appendFileSync(process.env.GITHUB_OUTPUT, 'publish_react_native_macos=' + process.env.ELIGIBLE + '\\n'); + } + `); + const env = {...process.env, ELIGIBLE: eligible, FAIL: fail, + GITHUB_OUTPUT: join(root, 'outputs'), YARN_IGNORE_PATH: '1', + YARN_RC_FILENAME: '.fixture-yarnrc.yml', YARN_ENABLE_NETWORK: '1', YARN_ENABLE_IMMUTABLE_INSTALLS: '0', + YARN_ENABLE_HARDENED_MODE: '0', YARN_NPM_AUTH_TOKEN: 'fixture-only', + YARN_NPM_REGISTRY_SERVER: `http://127.0.0.1:${port}`, + YARN_NPM_PUBLISH_REGISTRY: `http://127.0.0.1:${port}`, + }; + const run = command => execFileSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', + `yarn() { ${JSON.stringify(process.execPath)} ${JSON.stringify(yarnPath)} "$@"; }\n${command}`], + {cwd: root, env, encoding: 'utf8', stdio: 'pipe'}); + run('yarn install'); + const events = () => existsSync(join(root, 'events')) ? readFileSync(join(root, 'events'), 'utf8').trim().split('\n') : []; + const execute = steps => { + for (const step of steps) { + if (step.if) { + assert.equal(step.if, "steps.configure-publish.outputs.publish_react_native_macos == '1'"); + assert.ok(existsSync(env.GITHUB_OUTPUT), 'Preparation ran before eligibility'); + if (!readFileSync(env.GITHUB_OUTPUT, 'utf8').includes('publish_react_native_macos=1\n')) continue; + } + const output = run(step.run); + if (step.run.includes('npm publish')) { + assert.match(step.run, /--dry-run\b/); + for (const name of privateLists ? [core] : [lists, core]) { + assert.ok(output.includes(`[${name}]: ➤ YN0000: types_generated/index.d.ts`), + `Dry-run package omitted generated types: ${name}`); + } + } + } + assert.equal(existsSync(join(root, 'upload-attempt')), false, 'Dry run attempted an upload'); + }; + assert.equal(existsSync(join(root, 'packages/core/types_generated')), false); + return {root, events, execute}; +} + +const releasePreparation = publishSteps.slice(publishSteps.findIndex(step => step.id === 'configure-publish')); +const dryRunPreparation = dryRunSteps.slice(dryRunSteps.findIndex(step => step.run === 'yarn build')); + +test('publish workflow previews eligibility, builds local tools and validated types, then publishes', async t => { + const f = await fixture(t); + f.execute(releasePreparation); + assert.deepEqual(f.events(), ['preview', 'tooling', 'codegen', 'types', 'publish']); + assert.equal(readFileSync(join(f.root, 'snapshot'), 'utf8'), 'checked-in API\n'); +}); + +test('ineligible publication skips every build and the publish command', async t => { + const f = await fixture(t, {eligible: '0'}); + f.execute(releasePreparation); + assert.deepEqual(f.events(), ['preview']); +}); + +test('build and snapshot validation failures stop release and dry-run publication', async t => { + for (const steps of [releasePreparation, dryRunPreparation]) { + for (const fail of ['tooling', 'codegen', 'types']) { + const f = await fixture(t, {fail}); + assert.throws(() => f.execute(steps), /Injected build failure/); + assert.ok(f.events().every(event => event !== 'publish' && !event.startsWith('pack '))); + } + } +}); + +test('PR dry run packs only public coupled workspaces with generated types from a clean fixture', async t => { + for (const privateLists of [false, true]) { + const f = await fixture(t, {privateLists}); + f.execute(dryRunPreparation); + assert.deepEqual(f.events(), ['tooling', 'codegen', 'types', + ...(privateLists ? [] : ['pack lists']), 'pack core']); + assert.equal(readFileSync(join(f.root, 'snapshot'), 'utf8'), 'checked-in API\n'); + } +}); diff --git a/.github/workflows/microsoft-npm-publish.yml b/.github/workflows/microsoft-npm-publish.yml index dbf5380d275a..f6e9a5456834 100644 --- a/.github/workflows/microsoft-npm-publish.yml +++ b/.github/workflows/microsoft-npm-publish.yml @@ -46,8 +46,26 @@ jobs: # Changesets prepares versions in its automatic PR. This step never bumps # versions and skips pushes with pending changesets or no unpublished versions. - - name: Publish prepared packages + - name: Check publish eligibility id: configure-publish + run: node .ado/scripts/configure-publish.mts --verbose + + # Build local Node tooling even when its upstream packages are private. + - name: Build packages + if: steps.configure-publish.outputs.publish_react_native_macos == '1' + run: yarn build + + - name: Build local codegen + if: steps.configure-publish.outputs.publish_react_native_macos == '1' + run: yarn workspace @react-native/codegen build + + # --validate still generates types_generated, but never rewrites the API snapshot. + - name: Build and validate generated types + if: steps.configure-publish.outputs.publish_react_native_macos == '1' + run: yarn build-types --validate + + - name: Publish prepared packages + if: steps.configure-publish.outputs.publish_react_native_macos == '1' run: node .ado/scripts/configure-publish.mts --publish --verbose env: YARN_NPM_PUBLISH_ACCESS: public diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index c082f6242651..54dad415a991 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -59,9 +59,14 @@ jobs: run: yarn changeset status || true - name: Build packages run: yarn build + - name: Build local codegen + run: yarn workspace @react-native/codegen build + # Generate package types without rewriting the checked-in API snapshot. + - name: Build and validate generated types + run: yarn build-types --validate - name: Simulate publish (dry run) run: | - yarn workspaces foreach -vv --all --topological --no-private npm publish --tag dry-run --tolerate-republish --dry-run + yarn workspaces foreach -vv --all --topological --no-private --include react-native-macos --include '@react-native-macos/*' npm publish --tag dry-run --tolerate-republish --dry-run check-changesets: name: "Check for Changesets" From 41572356f490de05f98893e806fbc2ea129bbc55 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 09:30:30 -0500 Subject: [PATCH 28/38] fix(release): preserve private upstream workspace versions --- .../scripts/__tests__/publishing-contract.test.mjs | 8 +++++--- yarn.config.cjs | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/scripts/__tests__/publishing-contract.test.mjs b/.github/scripts/__tests__/publishing-contract.test.mjs index 776b34927eac..5219fa4e53a1 100644 --- a/.github/scripts/__tests__/publishing-contract.test.mjs +++ b/.github/scripts/__tests__/publishing-contract.test.mjs @@ -243,12 +243,12 @@ test('repository Changesets policy couples public stable packages without regist } }); -test('real Yarn constraints preserve workspace fork edges and distinguish public and private upstream consumers', t => { +test('real Yarn constraints preserve private upstream versions, align public versions, and preserve workspace fork edges', t => { for (const main of [true, false]) { const workspaces = graph(main ? '1000.0.0' : '0.83.2'); workspaces[1].private = main; workspaces[1].version = '0.82.0'; - workspaces[2].private = false; + workspaces[2].version = '0.82.7'; workspaces[3].version = '0.82.0'; for (const index of [0, 1, 3]) { for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { @@ -271,8 +271,10 @@ test('real Yarn constraints preserve workspace fork edges and distinguish public assert.equal(actual[0].version, workspaces[0].version); assert.equal(actual[1].version, main ? '1000.0.0' : '0.83.2'); assert.equal(actual[2].private, true); - assert.equal(actual[2].version, '0.83.1'); + assert.equal(actual[2].version, '0.82.7'); assert.equal(actual[3].version, '1000.0.0'); + assert.equal(actual[5].private, true); + assert.equal(actual[5].version, main ? '0.83.0' : '0.83.1'); for (const index of [0, 1, 3]) { for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { assert.equal(actual[index][field]['@react-native/codegen'], main || index === 3 ? 'workspace:*' : '0.83.1'); diff --git a/yarn.config.cjs b/yarn.config.cjs index 0faff7ef621d..d8262bd655d2 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -69,7 +69,7 @@ function enforcePrivateReactNativeScopedPackages({Yarn}) { /** * Enforce that react-native-macos declares a peer dependency on react-native on release branches, - * and that this version is consistent across all @react-native/ scoped packages. + * and that this version is consistent across public @react-native/ scoped packages. * Do not enforce on the main branch, where there is no published version of React Native to align to. * @param {Context} context */ @@ -77,9 +77,14 @@ function enforceReactNativeVersionConsistency({Yarn}) { if (!isMainBranch({Yarn})) { const reactNativePeerDependency = getReactNativePeerDependency({Yarn}); - // Enforce this version on all @react-native/ scoped packages + // Private workspaces are never published and retain their branch-local + // development versions. for (const workspace of Yarn.workspaces()) { - if (workspace.ident?.startsWith('@react-native/') && !PACKAGES_TO_IGNORE.includes(workspace.ident)) { + if ( + workspace.ident?.startsWith('@react-native/') && + !PACKAGES_TO_IGNORE.includes(workspace.ident) && + !workspace.manifest.private + ) { workspace.set('version', reactNativePeerDependency); } } From 0145de8fb4a40468c73ee7a1b0351e5fdd79b5ee Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 09:32:16 -0500 Subject: [PATCH 29/38] fix(types): preserve canonical fork event declarations --- packages/react-native/Libraries/Components/Button.js | 5 ----- .../react-native/Libraries/Types/CodegenTypesNamespace.d.ts | 3 +-- packages/react-native/Libraries/Types/CoreEventTypes.d.ts | 4 +--- packages/react-native/types/modules/Codegen.d.ts | 3 +-- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/react-native/Libraries/Components/Button.js b/packages/react-native/Libraries/Components/Button.js index 0b838f8f8d34..6a52a8d570e2 100644 --- a/packages/react-native/Libraries/Components/Button.js +++ b/packages/react-native/Libraries/Components/Button.js @@ -153,11 +153,6 @@ export type ButtonProps = $ReadOnly<{ */ accessibilityRole?: ?AccessibilityRole, - /** - * Accessibility action handlers - */ - onAccessibilityAction?: ?(event: AccessibilityActionEvent) => mixed, - /** * Handler to be called when the button receives key focus */ diff --git a/packages/react-native/Libraries/Types/CodegenTypesNamespace.d.ts b/packages/react-native/Libraries/Types/CodegenTypesNamespace.d.ts index 1727e69eae2f..79b7b50478d3 100644 --- a/packages/react-native/Libraries/Types/CodegenTypesNamespace.d.ts +++ b/packages/react-native/Libraries/Types/CodegenTypesNamespace.d.ts @@ -7,8 +7,7 @@ * @format */ -import type {NativeSyntheticEvent} from 'react-native'; -import type {EventSubscription} from 'react-native/Libraries/vendor/emitter/EventEmitter'; +import type {EventSubscription, NativeSyntheticEvent} from 'react-native'; // Event types // We're not using the PaperName, it is only used to codegen view config settings diff --git a/packages/react-native/Libraries/Types/CoreEventTypes.d.ts b/packages/react-native/Libraries/Types/CoreEventTypes.d.ts index 504b26603022..a9ff474f530e 100644 --- a/packages/react-native/Libraries/Types/CoreEventTypes.d.ts +++ b/packages/react-native/Libraries/Types/CoreEventTypes.d.ts @@ -250,7 +250,7 @@ export interface TargetedEvent { export type BlurEvent = NativeSyntheticEvent; -export type FocusEvent = NativeSyntheticEvent; +export interface FocusEvent extends NativeSyntheticEvent {} // [macOS] Preserve the public native payload interface. export interface PointerEvents { onPointerEnter?: ((event: PointerEvent) => void) | undefined; @@ -311,8 +311,6 @@ export interface NativeFocusEvent extends TargetedEvent {} export interface NativeBlurEvent extends TargetedEvent {} -export interface FocusEvent extends NativeSyntheticEvent {} - export interface BlueEvent extends NativeSyntheticEvent {} // Drag and Drop types diff --git a/packages/react-native/types/modules/Codegen.d.ts b/packages/react-native/types/modules/Codegen.d.ts index 698c922fe6b7..e507d95bf188 100644 --- a/packages/react-native/types/modules/Codegen.d.ts +++ b/packages/react-native/types/modules/Codegen.d.ts @@ -40,8 +40,7 @@ declare module 'react-native/Libraries/Utilities/codegenNativeComponent' { } declare module 'react-native/Libraries/Types/CodegenTypes' { - import type {NativeSyntheticEvent} from 'react-native'; - import type {EventSubscription} from 'react-native/Libraries/vendor/emitter/EventEmitter'; + import type {EventSubscription, NativeSyntheticEvent} from 'react-native'; // Event types // We're not using the PaperName, it is only used to codegen view config settings From e8a68d08e3f444a8828a668838aa4a41a34d7a65 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 09:30:30 -0500 Subject: [PATCH 30/38] fix(hermes): preserve macOS slices and symbols during recomposition (cherry picked from commit 76ade17c8699445aac8dac215b4bebc0bf3efccb) --- .../__tests__/hermes-framework-test.js | 464 ++++++++++++++++++ .../ios-prebuild/__tests__/hermes-test.js | 131 ++++- .../scripts/ios-prebuild/hermes-framework.js | 200 ++++++++ .../scripts/ios-prebuild/hermes.js | 7 + 4 files changed, 800 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/hermes-framework.js diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js new file mode 100644 index 000000000000..8d69be2fb6f7 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js @@ -0,0 +1,464 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +'use strict'; + +jest.mock('child_process', () => ({execFileSync: jest.fn()})); + +const {recomposeHermesXCFramework} = require('../hermes-framework'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const libraries = [ + { + LibraryIdentifier: 'ios-arm64', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'ios', + }, + { + LibraryIdentifier: 'ios-arm64_x86_64-simulator', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + }, + { + LibraryIdentifier: 'ios-arm64_x86_64-maccatalyst', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'maccatalyst', + }, + { + LibraryIdentifier: 'xros-arm64', + LibraryPath: 'nested path/hermesvm.framework', + SupportedPlatform: 'xros', + }, +]; +const macOSLibrary = { + LibraryIdentifier: 'macos-arm64_x86_64', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'macos', +}; +let tmp; +let framework; +let standalone; +let replacement; +let infoPath; + +function writeInfo(folder, availableLibraries) { + fs.writeFileSync( + path.join(folder, 'Info.plist'), + JSON.stringify({AvailableLibraries: availableLibraries}), + ); +} + +function expectOriginalInputs(expectedLibraries = libraries) { + expect(JSON.parse(fs.readFileSync(infoPath, 'utf8'))).toEqual({ + AvailableLibraries: expectedLibraries, + }); + libraries.forEach(library => { + expect( + fs.readFileSync( + path.join( + framework, + library.LibraryIdentifier, + library.LibraryPath, + 'hermesvm', + ), + 'utf8', + ), + ).toBe(library.LibraryIdentifier); + }); + expect(fs.readFileSync(path.join(standalone, 'hermesvm'), 'utf8')).toBe( + 'macOS binary', + ); + expect(fs.readlinkSync(path.join(standalone, 'hermesvm'))).toBe( + 'Versions/Current/hermesvm', + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes framework test-')); + const frameworks = path.join(tmp, 'destroot/Library/Frameworks'); + framework = path.join(frameworks, 'universal/hermesvm.xcframework'); + replacement = path.join(frameworks, 'universal/hermesvm-new.xcframework'); + standalone = path.join(frameworks, 'macosx/hermesvm.framework'); + infoPath = path.join(framework, 'Info.plist'); + fs.mkdirSync(framework, {recursive: true}); + writeInfo(framework, libraries); + libraries.forEach(library => { + const slice = path.join( + framework, + library.LibraryIdentifier, + library.LibraryPath, + ); + fs.mkdirSync(slice, {recursive: true}); + fs.writeFileSync(path.join(slice, 'hermesvm'), library.LibraryIdentifier); + }); + fs.mkdirSync(path.join(standalone, 'Versions/Current'), {recursive: true}); + fs.writeFileSync( + path.join(standalone, 'Versions/Current/hermesvm'), + 'macOS binary', + ); + fs.symlinkSync( + 'Versions/Current/hermesvm', + path.join(standalone, 'hermesvm'), + ); + jest.spyOn(console, 'log').mockImplementation(() => {}); + execFileSync.mockImplementation((command, args) => { + if (command === 'plutil') { + return fs.readFileSync(args[4], 'utf8'); + } + if (command === 'xcodebuild') { + // Exercise the real helper against disk; only simulate Xcode's output. + expectOriginalInputs(); + expect(fs.existsSync(replacement)).toBe(false); + fs.mkdirSync(replacement); + writeInfo(replacement, [...libraries, macOSLibrary]); + return; + } + throw new Error(`Unexpected command: ${command}`); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tmp, {recursive: true, force: true}); +}); + +test('preserves every plist slice path and replaces only after composition succeeds', () => { + fs.mkdirSync(replacement); + fs.writeFileSync(path.join(replacement, 'stale'), 'old failed output'); + recomposeHermesXCFramework(tmp); + expect(execFileSync).toHaveBeenCalledWith( + 'plutil', + ['-convert', 'json', '-o', '-', infoPath], + {encoding: 'utf8'}, + ); + expect(execFileSync).toHaveBeenCalledWith( + 'xcodebuild', + [ + '-create-xcframework', + ...libraries.flatMap(library => [ + '-framework', + path.join(framework, library.LibraryIdentifier, library.LibraryPath), + ]), + '-framework', + standalone, + '-output', + replacement, + '-allow-internal-distribution', + ], + {stdio: 'inherit'}, + ); + expect(JSON.parse(fs.readFileSync(infoPath, 'utf8'))).toEqual({ + AvailableLibraries: [...libraries, macOSLibrary], + }); + expect(fs.readdirSync(path.dirname(framework))).toEqual([ + 'hermesvm.xcframework', + ]); + expect(fs.readFileSync(path.join(standalone, 'hermesvm'), 'utf8')).toBe( + 'macOS binary', + ); + execFileSync.mockClear(); + recomposeHermesXCFramework(tmp); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + ]); +}); + +test('an existing macOS slice needs no standalone framework', () => { + writeInfo(framework, [...libraries, macOSLibrary]); + fs.rmSync(standalone, {recursive: true}); + recomposeHermesXCFramework(tmp); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + ]); +}); + +test.each(['plist', 'framework', 'binary', 'broken symlink'])( + 'requires the missing %s unless macOS is optional', + missing => { + const missingPath = + missing === 'plist' + ? infoPath + : missing === 'framework' + ? standalone + : missing === 'binary' + ? path.join(standalone, 'hermesvm') + : path.join(standalone, 'Versions/Current/hermesvm'); + fs.rmSync(missingPath, {recursive: true}); + expect(() => recomposeHermesXCFramework(tmp)).toThrow( + 'Cannot prepare required macOS slice: missing', + ); + expect(() => recomposeHermesXCFramework(tmp, false)).not.toThrow(); + expect( + execFileSync.mock.calls.some(([command]) => command === 'xcodebuild'), + ).toBe(false); + expect(fs.existsSync(framework)).toBe(true); + }, +); + +test.each([true, false])( + 'propagates plist failures even when macOS is optional (%s)', + requireMacOS => { + execFileSync.mockImplementationOnce(() => { + throw new Error('invalid plist'); + }); + expect(() => recomposeHermesXCFramework(tmp, requireMacOS)).toThrow( + 'invalid plist', + ); + expectOriginalInputs(); + execFileSync.mockReturnValueOnce('not JSON'); + expect(() => recomposeHermesXCFramework(tmp, requireMacOS)).toThrow(); + expectOriginalInputs(); + }, +); + +test.each([true, false])( + 'preserves original inputs on failed composition (required: %s)', + requireMacOS => { + const execute = execFileSync.getMockImplementation(); + execFileSync.mockImplementation((command, args) => { + if (command === 'xcodebuild') { + fs.mkdirSync(replacement); + fs.writeFileSync(path.join(replacement, 'partial'), 'failed output'); + throw new Error('unsupported framework input'); + } + return execute(command, args); + }); + expect(() => recomposeHermesXCFramework(tmp, requireMacOS)).toThrow( + 'unsupported framework input', + ); + expectOriginalInputs(); + expect(fs.existsSync(replacement)).toBe(false); + }, +); + +test('restores the original if replacement installation fails', () => { + const rename = fs.renameSync.bind(fs); + jest.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (from === replacement) { + throw new Error('replacement rename failed'); + } + rename(from, to); + }); + expect(() => recomposeHermesXCFramework(tmp)).toThrow( + 'replacement rename failed', + ); + expectOriginalInputs(); + expect(fs.readdirSync(path.dirname(framework))).toEqual([ + 'hermesvm.xcframework', + ]); +}); + +describe('symbol sidecars', () => { + const identifier = libraries[0].LibraryIdentifier; + const dsymName = 'hermesvm.framework.dSYM'; + const mapNames = ['first.bcsymbolmap', 'second.bcsymbolmap']; + const symbolLibraries = [ + { + ...libraries[0], + DebugSymbolsPath: 'original symbols', + BitcodeSymbolMapsPath: 'original maps', + }, + ...libraries.slice(1), + ]; + // Xcode chooses its own output directories; the helper must read them. + const outputLibraries = [ + { + ...libraries[0], + DebugSymbolsPath: 'dSYMs', + BitcodeSymbolMapsPath: 'BCSymbolMaps', + }, + ...libraries.slice(1), + macOSLibrary, + ]; + let inputDSYM; + let inputMaps; + + function writeSymbols(root, library) { + const slice = path.join(root, identifier); + const dsym = path.join(slice, library.DebugSymbolsPath, dsymName); + fs.mkdirSync(path.join(dsym, 'Contents/Resources/DWARF'), { + recursive: true, + }); + fs.writeFileSync(path.join(dsym, 'Contents/Info.plist'), 'dSYM plist'); + fs.writeFileSync( + path.join(dsym, 'Contents/Resources/DWARF/hermesvm'), + 'DWARF data', + ); + const maps = path.join(slice, library.BitcodeSymbolMapsPath); + fs.mkdirSync(maps, {recursive: true}); + mapNames.forEach(name => fs.writeFileSync(path.join(maps, name), name)); + } + + function expectInputSymbols() { + expectOriginalInputs(symbolLibraries); + expect( + fs.readFileSync(path.join(inputDSYM, 'Contents/Info.plist'), 'utf8'), + ).toBe('dSYM plist'); + expect( + fs.readFileSync( + path.join(inputDSYM, 'Contents/Resources/DWARF/hermesvm'), + 'utf8', + ), + ).toBe('DWARF data'); + inputMaps.forEach((file, index) => { + expect(fs.readFileSync(file, 'utf8')).toBe(mapNames[index]); + }); + } + + beforeEach(() => { + writeInfo(framework, symbolLibraries); + writeSymbols(framework, symbolLibraries[0]); + inputDSYM = path.join(framework, identifier, 'original symbols', dsymName); + inputMaps = mapNames.map(name => + path.join(framework, identifier, 'original maps', name), + ); + execFileSync.mockImplementation((command, args) => { + if (command === 'plutil') { + return fs.readFileSync(args[4], 'utf8'); + } + if (command === 'xcodebuild') { + expectInputSymbols(); + expect(args).toEqual([ + '-create-xcframework', + '-framework', + path.join(framework, identifier, libraries[0].LibraryPath), + '-debug-symbols', + inputDSYM, + ...inputMaps.flatMap(file => ['-debug-symbols', file]), + ...libraries + .slice(1) + .flatMap(library => [ + '-framework', + path.join( + framework, + library.LibraryIdentifier, + library.LibraryPath, + ), + ]), + '-framework', + standalone, + '-output', + replacement, + '-allow-internal-distribution', + ]); + fs.mkdirSync(replacement); + writeInfo(replacement, outputLibraries); + writeSymbols(replacement, outputLibraries[0]); + return; + } + throw new Error(`Unexpected command: ${command}`); + }); + }); + + test('passes each actual symbol path and validates Xcode metadata and files', () => { + recomposeHermesXCFramework(tmp); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + 'xcodebuild', + 'plutil', + ]); + expect(JSON.parse(fs.readFileSync(infoPath, 'utf8'))).toEqual({ + AvailableLibraries: outputLibraries, + }); + expect( + fs.readFileSync( + path.join( + framework, + identifier, + 'dSYMs', + dsymName, + 'Contents/Resources/DWARF/hermesvm', + ), + 'utf8', + ), + ).toBe('DWARF data'); + mapNames.forEach(name => { + expect( + fs.readFileSync( + path.join(framework, identifier, 'BCSymbolMaps', name), + 'utf8', + ), + ).toBe(name); + }); + expect(fs.readdirSync(path.dirname(framework))).toEqual([ + 'hermesvm.xcframework', + ]); + }); + + test.each(['metadata', 'slice', 'dSYM', 'DWARF', 'map', 'map metadata'])( + 'preserves all inputs when Xcode output lacks %s', + missing => { + const execute = execFileSync.getMockImplementation(); + execFileSync.mockImplementation((command, args) => { + const result = execute(command, args); + if (command === 'xcodebuild') { + const slice = path.join(replacement, identifier); + if (missing === 'metadata') { + writeInfo(replacement, [...libraries, macOSLibrary]); + } else if (missing === 'slice') { + writeInfo(replacement, outputLibraries.slice(1)); + } else if (missing === 'map metadata') { + const {BitcodeSymbolMapsPath, ...library} = outputLibraries[0]; + writeInfo(replacement, [library, ...outputLibraries.slice(1)]); + } else { + const file = + missing === 'map' + ? path.join(slice, 'BCSymbolMaps', mapNames[1]) + : path.join( + slice, + 'dSYMs', + dsymName, + ...(missing === 'DWARF' + ? ['Contents/Resources/DWARF/hermesvm'] + : []), + ); + fs.rmSync(file, {recursive: true}); + } + } + return result; + }); + expect(() => recomposeHermesXCFramework(tmp)).toThrow(); + expectInputSymbols(); + expect(fs.readdirSync(path.dirname(framework))).toEqual([ + 'hermesvm.xcframework', + ]); + }, + ); + + test.each(['directory', 'empty directory', 'DWARF', 'map file'])( + 'rejects invalid input symbols before Xcode: %s', + invalid => { + if (invalid === 'directory') { + fs.rmSync(path.dirname(inputDSYM), {recursive: true}); + } else if (invalid === 'empty directory') { + fs.rmSync(inputDSYM, {recursive: true}); + } else if (invalid === 'DWARF') { + fs.unlinkSync( + path.join(inputDSYM, 'Contents/Resources/DWARF/hermesvm'), + ); + } else { + fs.unlinkSync(inputMaps[0]); + fs.mkdirSync(inputMaps[0]); + } + expect(() => recomposeHermesXCFramework(tmp)).toThrow(); + expectOriginalInputs(symbolLibraries); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + ]); + expect(fs.existsSync(replacement)).toBe(false); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js index 6da217e7ee92..f4746f9e0bc9 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js @@ -10,10 +10,13 @@ 'use strict'; -jest.mock('child_process', () => ({execSync: jest.fn()})); +jest.mock('child_process', () => ({ + execSync: jest.fn(), + execFileSync: jest.fn(), +})); const {prepareHermesArtifactsAsync} = require('../hermes'); -const {execSync} = require('child_process'); +const {execFileSync, execSync} = require('child_process'); const fs = require('fs'); const ini = require('ini'); const os = require('os'); @@ -40,6 +43,9 @@ let versionFile; let framework; let savedEnv; let properties; +let libraries; +let standaloneMacOS; +let includeInfo; beforeEach(() => { jest.useRealTimers(); @@ -54,6 +60,9 @@ beforeEach(() => { 'destroot/Library/Frameworks/universal/hermesvm.xcframework', ); properties = 'HERMES_VERSION_NAME=123.4.56\nHERMES_V1_VERSION_NAME=234.5.67'; + libraries = [{SupportedPlatform: 'macos'}]; + standaloneMacOS = false; + includeInfo = true; jest.spyOn(process, 'cwd').mockReturnValue(tmp); jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...args) => { @@ -73,6 +82,35 @@ beforeEach(() => { }); execSync.mockImplementation(() => { fs.mkdirSync(framework, {recursive: true}); + if (includeInfo) { + fs.writeFileSync( + path.join(framework, 'Info.plist'), + JSON.stringify({AvailableLibraries: libraries}), + ); + } + if (standaloneMacOS) { + const macOSFramework = path.resolve( + framework, + '../../macosx/hermesvm.framework', + ); + fs.mkdirSync(macOSFramework, {recursive: true}); + fs.writeFileSync(path.join(macOSFramework, 'hermesvm'), 'macOS binary'); + } + }); + execFileSync.mockImplementation((command, args) => { + if (command === 'plutil') { + return readFileSync(args[4], 'utf8'); + } + if (command === 'xcodebuild') { + const output = args[args.indexOf('-output') + 1]; + fs.mkdirSync(output, {recursive: true}); + fs.writeFileSync( + path.join(output, 'Info.plist'), + JSON.stringify({ + AvailableLibraries: [...libraries, {SupportedPlatform: 'macos'}], + }), + ); + } }); }); @@ -282,3 +320,92 @@ test('unavailable artifacts fail without an npm or source fallback', async () => false, ); }); + +describe('macOS slice capabilities', () => { + beforeEach(() => { + libraries = [ + { + LibraryIdentifier: 'ios-arm64', + LibraryPath: 'hermesvm.framework', + SupportedPlatform: 'ios', + }, + ]; + }); + + test.each(['download', 'cache', 'local'])( + 'recomposes older artifacts from %s before returning', + async source => { + standaloneMacOS = true; + if (source === 'local') { + process.env.HERMES_ENGINE_TARBALL_PATH = path.join(tmp, 'local.tar.gz'); + } + if (source === 'cache') { + execSync(); // Populate the old extracted layout without recomposition. + fs.writeFileSync(versionFile, '123.4.56-Debug'); + execSync.mockClear(); + } + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(execFileSync).toHaveBeenCalledWith( + 'xcodebuild', + expect.arrayContaining(['-create-xcframework']), + {stdio: 'inherit'}, + ); + expect( + JSON.parse(readFileSync(path.join(framework, 'Info.plist'), 'utf8')) + .AvailableLibraries, + ).toEqual([...libraries, {SupportedPlatform: 'macos'}]); + if (source !== 'download') { + expect(global.fetch).not.toHaveBeenCalled(); + } + if (source === 'cache') { + expect(execSync).not.toHaveBeenCalled(); + } + }, + ); + + test('checks existing macOS support after download and on cache reuse', async () => { + libraries.push({SupportedPlatform: 'macos'}); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + await prepareHermesArtifactsAsync('1000.0.0', 'Debug'); + expect(execSync).toHaveBeenCalledTimes(1); + expect(execFileSync.mock.calls.map(([command]) => command)).toEqual([ + 'plutil', + 'plutil', + ]); + }); + + test.each(['plist', 'binary'])( + 'rejects missing required %s after download and on cache reuse', + async missing => { + includeInfo = missing !== 'plist'; + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow('Cannot prepare required macOS slice: missing'); + global.fetch.mockClear(); + execSync.mockClear(); + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).rejects.toThrow('Cannot prepare required macOS slice: missing'); + expect(global.fetch).not.toHaveBeenCalled(); + expect(execSync).not.toHaveBeenCalled(); + }, + ); + + test.each([true, false])( + 'permits local tarballs without macOS inputs (plist: %s)', + async hasInfo => { + includeInfo = hasInfo; + const tarball = path.join(tmp, 'local.tar.gz'); + fs.writeFileSync(tarball, 'local archive'); + process.env.HERMES_ENGINE_TARBALL_PATH = tarball; + await expect( + prepareHermesArtifactsAsync('1000.0.0', 'Debug'), + ).resolves.toBe(artifacts); + expect(fs.existsSync(tarball)).toBe(true); + expect(global.fetch).not.toHaveBeenCalled(); + expect( + execFileSync.mock.calls.some(([command]) => command === 'xcodebuild'), + ).toBe(false); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/hermes-framework.js b/packages/react-native/scripts/ios-prebuild/hermes-framework.js new file mode 100644 index 000000000000..5ca46f5d4575 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/hermes-framework.js @@ -0,0 +1,200 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +const {createLogger} = require('./utils'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const hermesLog = createLogger('Hermes'); + +// XCFramework symbol metadata names directories, but xcodebuild needs each +// individual dSYM bundle and bcsymbolmap as an absolute -debug-symbols argument. +function symbolPaths( + slicePath /*: string */, + library /*: {DebugSymbolsPath?: string, BitcodeSymbolMapsPath?: string, ...} */, +) /*: Array */ { + return ['DebugSymbolsPath', 'BitcodeSymbolMapsPath'].flatMap(key => { + if (library[key] == null) { + return []; + } + const directory = path.resolve(slicePath, library[key]); + const extension = key === 'DebugSymbolsPath' ? '.dSYM' : '.bcsymbolmap'; + const symbols = fs + .readdirSync(directory) + .filter(name => name.endsWith(extension)) + .map(name => path.join(directory, name)); + if (symbols.length === 0) { + throw new Error(`[Hermes] Missing symbol sidecars in ${directory}`); + } + for (const symbol of symbols) { + const files = + key === 'DebugSymbolsPath' + ? [ + path.join(symbol, 'Contents', 'Info.plist'), + path.join(symbol, 'Contents', 'Resources', 'DWARF', 'hermesvm'), + ] + : [symbol]; + for (const file of files) { + if (!fs.statSync(file).isFile()) { + throw new Error(`[Hermes] Invalid symbol sidecar: ${file}`); + } + } + } + return symbols; + }); +} + +// [macOS] Older artifacts, including main's legacy Hermes artifacts, keep macOS +// outside the universal XCFramework. Check capabilities rather than versions: +// newer defaults do not cover cached artifacts or explicit version overrides. +function recomposeHermesXCFramework( + artifactsPath /*: string */, + requireMacOS /*: boolean */ = true, +) { + const frameworksPath = path.join( + artifactsPath, + 'destroot', + 'Library', + 'Frameworks', + ); + const xcframeworkPath = path.join( + frameworksPath, + 'universal', + 'hermesvm.xcframework', + ); + const infoPath = path.join(xcframeworkPath, 'Info.plist'); + const macOSFrameworkPath = path.join( + frameworksPath, + 'macosx', + 'hermesvm.framework', + ); + + if (!fs.existsSync(infoPath)) { + if (requireMacOS) { + throw new Error( + `[Hermes] Cannot prepare required macOS slice: missing ${infoPath}`, + ); + } + return; + } + + const info = JSON.parse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPath], { + encoding: 'utf8', + }), + ); + if ( + info.AvailableLibraries.some( + library => library.SupportedPlatform === 'macos', + ) + ) { + return; + } + + const macOSBinaryPath = path.join(macOSFrameworkPath, 'hermesvm'); + if (!fs.existsSync(macOSBinaryPath)) { + if (requireMacOS) { + throw new Error( + `[Hermes] Cannot prepare required macOS slice: missing ${macOSBinaryPath}`, + ); + } + return; + } + + const symbolsByLibrary = new Map(); + const frameworkArgs = info.AvailableLibraries.flatMap(library => { + const slicePath = path.join(xcframeworkPath, library.LibraryIdentifier); + const symbols = symbolPaths(slicePath, library); + if (symbols.length > 0) { + symbolsByLibrary.set(library.LibraryIdentifier, symbols); + } + return [ + '-framework', + path.join(slicePath, library.LibraryPath), + ...symbols.flatMap(symbol => ['-debug-symbols', symbol]), + ]; + }); + frameworkArgs.push('-framework', macOSFrameworkPath); + + const replacementPath = path.join( + frameworksPath, + 'universal', + 'hermesvm-new.xcframework', + ); + fs.rmSync(replacementPath, {recursive: true, force: true}); + try { + execFileSync( + 'xcodebuild', + [ + '-create-xcframework', + ...frameworkArgs, + '-output', + replacementPath, + '-allow-internal-distribution', + ], + {stdio: 'inherit'}, + ); + // Trust only Xcode's generated metadata and actual output files. Validate + // before moving the original so a successful command cannot lose symbols. + if (symbolsByLibrary.size > 0) { + const replacementInfo = JSON.parse( + execFileSync( + 'plutil', + [ + '-convert', + 'json', + '-o', + '-', + path.join(replacementPath, 'Info.plist'), + ], + {encoding: 'utf8'}, + ), + ); + for (const [identifier, symbols] of symbolsByLibrary) { + const library = replacementInfo.AvailableLibraries.find( + entry => entry.LibraryIdentifier === identifier, + ); + const outputSymbols = library + ? symbolPaths(path.join(replacementPath, identifier), library).map( + symbol => path.basename(symbol), + ) + : []; + for (const symbol of symbols) { + if (!outputSymbols.includes(path.basename(symbol))) { + throw new Error( + `[Hermes] Missing recomposed symbol sidecar: ${symbol}`, + ); + } + } + } + } + // Keep the original until the replacement is installed, including if the + // final rename fails after xcodebuild succeeds. + const backupFolder = fs.mkdtempSync(`${xcframeworkPath}-backup-`); + const backupPath = path.join(backupFolder, 'hermesvm.xcframework'); + fs.renameSync(xcframeworkPath, backupPath); + try { + fs.renameSync(replacementPath, xcframeworkPath); + } catch (error) { + fs.renameSync(backupPath, xcframeworkPath); + fs.rmSync(backupFolder, {recursive: true, force: true}); + throw error; + } + fs.rmSync(backupFolder, {recursive: true, force: true}); + } finally { + fs.rmSync(replacementPath, {recursive: true, force: true}); + } + hermesLog( + 'Added the standalone macOS framework to the universal XCFramework', + ); +} + +module.exports = {recomposeHermesXCFramework}; diff --git a/packages/react-native/scripts/ios-prebuild/hermes.js b/packages/react-native/scripts/ios-prebuild/hermes.js index c0a4ae51a78c..25878355e686 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes.js +++ b/packages/react-native/scripts/ios-prebuild/hermes.js @@ -8,6 +8,7 @@ * @format */ +const {recomposeHermesXCFramework} = require('./hermes-framework'); // [macOS] const {readHermesMetadata} = require('./hermes-version'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); @@ -98,6 +99,11 @@ async function prepareHermesArtifactsAsync( execSync(`tar -xzf "${localPath}" -C "${artifactsPath}"`, { stdio: 'inherit', }); + // [macOS] All-Apple prebuilds require macOS; local overrides may omit it. + recomposeHermesXCFramework( + artifactsPath, + !hermesEngineTarballEnvvarDefined(), + ); // Delete the tarball after extraction if (!process.env.HERMES_ENGINE_TARBALL_PATH) { @@ -164,6 +170,7 @@ function checkExistingVersion( if (fs.existsSync(versionFilePath) && fs.existsSync(hermesXCFramework)) { const versionFileContent = fs.readFileSync(versionFilePath, 'utf8'); if (versionFileContent.trim() === resolvedVersion) { + recomposeHermesXCFramework(artifactsPath); // [macOS] hermesLog( `Hermes artifacts already downloaded and up to date: ${artifactsPath}`, ); From febd7d6b6fc24ed827512a80c4e2873b36380154 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 10:28:16 -0500 Subject: [PATCH 31/38] fix(flow): type Hermes framework validation metadata (cherry picked from commit b09a500b5bfdc9c60eb1c9c8d86af5435982af20) --- .../react-native/scripts/ios-prebuild/hermes-framework.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react-native/scripts/ios-prebuild/hermes-framework.js b/packages/react-native/scripts/ios-prebuild/hermes-framework.js index 5ca46f5d4575..69644d45af5c 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes-framework.js +++ b/packages/react-native/scripts/ios-prebuild/hermes-framework.js @@ -21,7 +21,11 @@ function symbolPaths( slicePath /*: string */, library /*: {DebugSymbolsPath?: string, BitcodeSymbolMapsPath?: string, ...} */, ) /*: Array */ { - return ['DebugSymbolsPath', 'BitcodeSymbolMapsPath'].flatMap(key => { + const keys /*: Array<'DebugSymbolsPath' | 'BitcodeSymbolMapsPath'> */ = [ + 'DebugSymbolsPath', + 'BitcodeSymbolMapsPath', + ]; + return keys.flatMap(key => { if (library[key] == null) { return []; } @@ -109,7 +113,7 @@ function recomposeHermesXCFramework( return; } - const symbolsByLibrary = new Map(); + const symbolsByLibrary /*: Map> */ = new Map(); const frameworkArgs = info.AvailableLibraries.flatMap(library => { const slicePath = path.join(xcframeworkPath, library.LibraryIdentifier); const symbols = symbolPaths(slicePath, library); From 4de3e75bb48c58b149be924c6f3b94e72af51202 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Sun, 20 Sep 2026 18:42:41 -0500 Subject: [PATCH 32/38] fix(flow): normalize Hermes metadata command output --- .../react-native/scripts/ios-prebuild/hermes-framework.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native/scripts/ios-prebuild/hermes-framework.js b/packages/react-native/scripts/ios-prebuild/hermes-framework.js index 69644d45af5c..7889e5dd98bf 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes-framework.js +++ b/packages/react-native/scripts/ios-prebuild/hermes-framework.js @@ -93,7 +93,7 @@ function recomposeHermesXCFramework( const info = JSON.parse( execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPath], { encoding: 'utf8', - }), + }).toString(), ); if ( info.AvailableLibraries.some( @@ -160,7 +160,7 @@ function recomposeHermesXCFramework( path.join(replacementPath, 'Info.plist'), ], {encoding: 'utf8'}, - ), + ).toString(), ); for (const [identifier, symbols] of symbolsByLibrary) { const library = replacementInfo.AvailableLibraries.find( From f741b50dfa191e8dbfc465df05c037847ecd1ccd Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Sun, 20 Sep 2026 23:31:27 -0500 Subject: [PATCH 33/38] fix(types): resolve private workspace dependencies during generation Apply the independently approved four-file candidate from main-4de3-fix-evidence/main-4de3-fix.patch, based on 4de3e75bb48c58b149be924c6f3b94e72af51202. Approval: ses_f3dcf284. Include private workspace dependencies in type resolution, exclude node_modules links from snapshot discovery, and preserve the reviewed regression test and generated API snapshot exactly. Prior measured validation: all 18 generated TypeScript diagnostics resolved; final full Flow check reported 0 errors. Provenance: TEMP/main-4de3-fix-evidence/REPORT.md. Tests were not repeated during application. Candidate stable patch-id: e3289b3576a155e45acde2069dc00660ccc00ac1. All four staged file blobs match the reviewed candidate. --- packages/react-native/ReactNativeApi.d.ts | 518 +++++++++++++++--- .../js-api/build-types/buildApiSnapshot.js | 5 +- .../__tests__/simpleResolve-test.js | 65 +++ .../build-types/resolution/simpleResolve.js | 2 +- 4 files changed, 503 insertions(+), 87 deletions(-) create mode 100644 scripts/js-api/build-types/resolution/__tests__/simpleResolve-test.js diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 796a00ed71f6..d81f44d1829e 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<4e0d1f9ebc86fb237989b7099bf6f9df>> + * @generated SignedSource<<38cf603123a1b85c7517d48f502218be>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -21,7 +21,15 @@ /* eslint-disable redundant-undefined/redundant-undefined */ +import type { FocusEvent as FocusEvent_2 } from "react-native" +import type { LayoutChangeEvent as LayoutChangeEvent_2 } from "react-native" +import type { LayoutRectangle as LayoutRectangle_2 } from "react-native" import * as React from "react" +import type { ScrollResponderType as ScrollResponderType_2 } from "react-native" +import { ScrollView as ScrollView_2 } from "react-native" +import type { ScrollViewProps as ScrollViewProps_2 } from "react-native" +import type { StyleProp as StyleProp_2 } from "react-native" +import type { ViewStyle as ViewStyle_2 } from "react-native" declare const $$AndroidSwitchNativeComponent: NativeType declare const $$AnimatedFlatList: ( props: Omit>, "ref"> & { @@ -96,6 +104,7 @@ declare const absoluteFill: AbsoluteFillStyle declare const absoluteFillObject: AbsoluteFillStyle declare const AccessibilityInfo: typeof AccessibilityInfo_default declare const AccessibilityInfo_default: { + isHighContrastEnabled: () => Promise addEventListener( eventName: K, handler: (...$$REST$$: AccessibilityEventDefinitions[K]) => void, @@ -171,6 +180,10 @@ declare const Clipboard: { } declare const codegenNativeCommands: typeof codegenNativeCommands_default declare const codegenNativeComponent: typeof codegenNativeComponent_default +declare const ColorWithSystemEffectMacOS: ( + color: ColorValue, + effect: SystemEffectMacOS, +) => ColorValue declare const compose: typeof composeStyles_default declare const create: ( obj: S & ____Styles_Internal, @@ -209,6 +222,7 @@ declare const divideImpl: ( ) => AnimatedDivision_default declare const DrawerLayoutAndroid: typeof DrawerLayoutAndroid_default declare const DynamicColorIOS: (tuple: DynamicColorIOSTuple) => ColorValue +declare const DynamicColorMacOS: (tuple: DynamicColorMacOSTuple) => ColorValue declare const Easing: typeof EasingStatic_default declare const EasingStatic_default: { back(s?: number): EasingFunction @@ -833,6 +847,7 @@ declare type ____TextStyle_Internal = Readonly< > declare type ____TextStyle_InternalBase = { readonly color?: ____ColorValue_Internal + readonly cursor?: CursorValue readonly fontFamily?: string readonly fontSize?: number readonly fontStyle?: "italic" | "normal" @@ -997,6 +1012,7 @@ declare class _TextInputInstance extends ReactNativeElement_default { clear(): void getNativeRef(): ReactNativeElement_default | undefined isFocused(): boolean + setGhostText(ghostText: string | undefined): void setSelection(start: number, end: number): void } declare type $$AndroidSwitchNativeComponent = @@ -1053,7 +1069,8 @@ declare type AccessibilityActionName = | "magicTap" declare type AccessibilityEventDefinitions = AccessibilityEventDefinitionsAndroid & - AccessibilityEventDefinitionsIOS & { + AccessibilityEventDefinitionsIOS & + AccessibilityEventDefinitionsMacOS & { change: [boolean] reduceMotionChanged: [boolean] screenReaderChanged: [boolean] @@ -1075,6 +1092,9 @@ declare type AccessibilityEventDefinitionsIOS = { invertColorsChanged: [boolean] reduceTransparencyChanged: [boolean] } +declare type AccessibilityEventDefinitionsMacOS = { + highContrastChanged: [boolean] +} declare type AccessibilityEventTypes = "click" | "focus" | "viewHoverEnter" declare type AccessibilityInfo = typeof AccessibilityInfo declare type AccessibilityProps = Readonly< @@ -1144,6 +1164,7 @@ declare type AccessibilityRole = | "list" | "menu" | "menubar" + | "menubutton" | "menuitem" | "none" | "pager" @@ -1235,6 +1256,17 @@ declare class Alert { keyboardType?: string, options?: AlertOptions, ): void + static promptMacOS( + title: null | string | undefined, + message?: null | string | undefined, + callbackOrButtons?: + | (((text: string) => void) | null | undefined) + | AlertButtons, + type?: AlertType | null | undefined, + defaultInputs?: DefaultInputsArray, + modal?: boolean | null | undefined, + critical?: boolean | null | undefined, + ): void } declare type AlertButton = { isPreferred?: boolean @@ -1246,6 +1278,8 @@ declare type AlertButtons = Array declare type AlertButtonStyle = "cancel" | "default" | "destructive" declare type AlertOptions = { cancelable?: boolean + critical?: boolean + modal?: boolean onDismiss?: () => void userInterfaceStyle?: "dark" | "light" | "unspecified" } @@ -1764,6 +1798,7 @@ declare type ButtonProps = { readonly accessibilityHint?: string readonly accessibilityLabel?: string readonly accessibilityLanguage?: string + readonly accessibilityRole?: AccessibilityRole readonly accessibilityState?: AccessibilityState readonly accessible?: boolean readonly "aria-busy"?: boolean @@ -1786,8 +1821,11 @@ declare type ButtonProps = { readonly nextFocusRight?: number readonly nextFocusUp?: number readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown + readonly onBlur?: (e: BlurEvent) => void + readonly onFocus?: (e: FocusEvent) => void readonly testID?: string readonly title: string + readonly tooltip?: string readonly touchSoundDisabled?: boolean readonly onPress?: (event?: GestureResponderEvent) => unknown } @@ -1817,9 +1855,9 @@ declare type CellRendererProps = { readonly children: React.ReactNode readonly index: number readonly item: ItemT - readonly style: StyleProp - readonly onFocusCapture?: (event: FocusEvent) => void - readonly onLayout?: (event: LayoutChangeEvent) => void + readonly style: StyleProp_2 + readonly onFocusCapture?: (event: FocusEvent_2) => void + readonly onLayout?: (event: LayoutChangeEvent_2) => void } declare class CellRenderMask { addCells(cells: { first: number; last: number }): void @@ -1866,6 +1904,7 @@ declare namespace CodegenTypes { declare type ColorListenerCallback = (value: ColorValue) => unknown declare type ColorSchemeName = "dark" | "light" | "unspecified" declare type ColorValue = ____ColorValue_Internal +declare type ColorWithSystemEffectMacOS = typeof ColorWithSystemEffectMacOS declare type ComponentProvider = () => React.ComponentType declare type ComponentProviderInstrumentationHook = ( component_: ComponentProvider, @@ -1938,17 +1977,80 @@ declare function createPublicTextInstance( ownerDocument: ReactNativeDocument_default, ): ReadOnlyText_default declare type createPublicTextInstanceT = typeof createPublicTextInstance -declare type CursorValue = "auto" | "pointer" +declare type CursorValue = + | "alias" + | "all-scroll" + | "auto" + | "cell" + | "col-resize" + | "context-menu" + | "copy" + | "crosshair" + | "default" + | "e-resize" + | "ew-resize" + | "grab" + | "grabbing" + | "help" + | "move" + | "n-resize" + | "ne-resize" + | "nesw-resize" + | "no-drop" + | "none" + | "not-allowed" + | "ns-resize" + | "nw-resize" + | "nwse-resize" + | "pointer" + | "progress" + | "row-resize" + | "s-resize" + | "se-resize" + | "sw-resize" + | "text" + | "url" + | "vertical-text" + | "w-resize" + | "wait" + | "zoom-in" + | "zoom-out" declare type DataDetectorTypesType = | "address" | "all" | "calendarEvent" + | "correction" + | "dash" | "flightNumber" + | "grammar" | "link" | "lookupSuggestion" | "none" + | "ortography" | "phoneNumber" + | "quote" + | "regularExpression" + | "replacement" + | "spelling" | "trackingNumber" + | "transitInformation" +declare type DataTransfer = { + readonly files: ReadonlyArray + readonly items: ReadonlyArray + readonly types: ReadonlyArray +} +declare type DataTransferFile = { + readonly height?: number + readonly name: string + readonly size?: number + readonly type: string | undefined + readonly uri: string + readonly width?: number +} +declare type DataTransferItem = { + readonly kind: string + readonly type: string | undefined +} declare type decay = typeof decay declare type DecayAnimationConfig = Readonly< AnimationConfig & { @@ -1962,6 +2064,11 @@ declare type DecayAnimationConfig = Readonly< } > declare type DecelerationRateType = "fast" | "normal" | number +declare type DefaultInputsArray = Array<{ + default?: string + placeholder?: string + style?: AlertButtonStyle +}> declare type DefaultSectionT = { [key: string]: any } @@ -2022,8 +2129,10 @@ declare type DirectEventProps = { readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown readonly onAccessibilityEscape?: () => unknown readonly onAccessibilityTap?: () => unknown + readonly onInvertedDidChange?: () => unknown readonly onLayout?: (event: LayoutChangeEvent) => unknown readonly onMagicTap?: () => unknown + readonly onPreferredScrollerStyleDidChange?: (event: ScrollEvent) => unknown } declare type DisplayMetrics = { fontScale: number @@ -2089,6 +2198,16 @@ declare class DOMRectReadOnly_default { get y(): number } declare type Double = number +declare type DragEvent = NativeSyntheticEvent<{ + readonly clientX: number + readonly clientY: number + readonly dataTransfer?: DataTransfer + readonly pageX: number + readonly pageY: number + readonly timestamp: number +}> +declare type DraggedType = "fileUrl" | "image" | "string" +declare type DraggedTypesType = DraggedType | ReadonlyArray declare type DrawerLayoutAndroid = typeof DrawerLayoutAndroid declare class DrawerLayoutAndroid_default extends React.Component @@ -2157,6 +2276,13 @@ declare type DynamicColorIOSTuple = { highContrastLight?: ColorValue light: ColorValue } +declare type DynamicColorMacOS = typeof DynamicColorMacOS +declare type DynamicColorMacOSTuple = { + dark: ColorValue + highContrastDark?: ColorValue + highContrastLight?: ColorValue + light: ColorValue +} declare type Easing = typeof Easing declare type EasingFunction = (t: number) => number declare type EdgeInsetsOrSizeProp = RectOrSize @@ -2231,6 +2357,8 @@ declare type EventHandlers = { readonly onBlur: (event: BlurEvent) => void readonly onClick: (event: GestureResponderEvent) => void readonly onFocus: (event: FocusEvent) => void + readonly onKeyDown?: (event: KeyEvent) => void + readonly onKeyUp?: (event: KeyEvent) => void readonly onMouseEnter?: (event: MouseEvent) => void readonly onMouseLeave?: (event: MouseEvent) => void readonly onPointerEnter?: (event: PointerEvent) => void @@ -2363,18 +2491,38 @@ declare class FlatList extends React.PureComponent< viewPosition?: number }): void scrollToOffset(params: { animated?: boolean; offset: number }): void + selectRowAtIndex(index: number): void setNativeProps(props: { [$$Key$$: string]: unknown }): void } declare type FlatListBaseProps = RequiredFlatListProps & OptionalFlatListProps declare type FlatListProps = Omit< - VirtualizedListProps, + Omit< + VirtualizedListProps, + | "data" + | "getItem" + | "getItemCount" + | "getItemLayout" + | "keyExtractor" + | "renderItem" + >, + | "columnWrapperStyle" | "data" - | "getItem" - | "getItemCount" + | "enableSelectionOnKeyPress" + | "extraData" + | "fadingEdgeLength" | "getItemLayout" + | "horizontal" + | "initialNumToRender" + | "initialScrollIndex" + | "initialSelectedIndex" + | "inverted" | "keyExtractor" + | "numColumns" + | "removeClippedSubviews" | "renderItem" + | "strictMode" + | never > & FlatListBaseProps declare type flatten = typeof flatten @@ -2481,6 +2629,13 @@ declare function getWithFallback_DEPRECATED( ): React.ComponentType declare type hairlineWidth = typeof hairlineWidth declare type Handle = number +declare type HandledKeyEvent = { + readonly altKey?: boolean + readonly ctrlKey?: boolean + readonly key: string + readonly metaKey?: boolean + readonly shiftKey?: boolean +} declare type Headers = { [name: string]: string } @@ -2632,6 +2787,7 @@ declare type ImagePropsBase = Readonly< | "children" | "onLayout" | "testID" + | "tooltip" > & { accessibilityLabel?: string accessible?: boolean @@ -2664,6 +2820,7 @@ declare type ImagePropsBase = Readonly< srcSet?: string testID?: string tintColor?: ColorValue + tooltip?: string width?: number } > @@ -2892,6 +3049,12 @@ declare type KeyboardEventEasing = | "keyboard" | "linear" declare type KeyboardEventName = keyof KeyboardEventDefinitions +declare type KeyboardEventProps = { + readonly keyDownEvents?: Array + readonly keyUpEvents?: Array + readonly onKeyDown?: (event: KeyEvent) => void + readonly onKeyUp?: (event: KeyEvent) => void +} declare class KeyboardImpl { addListener( eventType: K, @@ -2933,6 +3096,21 @@ declare type KeyboardTypeOptions = | KeyboardType | KeyboardTypeAndroid | KeyboardTypeIOS +declare type KeyEvent = NativeSyntheticEvent<{ + readonly altKey: boolean + readonly ArrowDown: boolean + readonly ArrowLeft: boolean + readonly ArrowRight: boolean + readonly ArrowUp: boolean + readonly capsLockKey: boolean + readonly ctrlKey: boolean + readonly functionKey: boolean + readonly helpKey: boolean + readonly key: string + readonly metaKey: boolean + readonly numericPadKey: boolean + readonly shiftKey: boolean +}> declare function keyExtractor(item: any, index: number): string declare type KeysOfUnion = T extends any ? keyof T : never declare type LayoutAnimation = typeof LayoutAnimation @@ -3022,7 +3200,7 @@ declare class LinkingImpl extends NativeEventEmitter { declare class ListMetricsAggregator_default { cartesianOffset(flowRelativeOffset: number): number flowRelativeOffset( - layout: LayoutRectangle, + layout: LayoutRectangle_2, referenceContentLength?: null | number | undefined, ): number getAverageCellLength(): number @@ -3038,7 +3216,7 @@ declare class ListMetricsAggregator_default { notifyCellLayout($$PARAM_0$$: { cellIndex: number cellKey: string - layout: LayoutRectangle + layout: LayoutRectangle_2 orientation: ListOrientation }): boolean notifyCellUnmounted(cellKey: string): void @@ -3059,6 +3237,7 @@ declare type ListRenderItem = ( ) => React.ReactNode declare type ListRenderItemInfo = { index: number + isSelected: boolean | undefined item: ItemT separators: Separators } @@ -3108,6 +3287,18 @@ declare type MacOSPlatform = { get isVision(): boolean get Version(): string } +declare type MacOSViewProps = { + readonly acceptsFirstMouse?: boolean + readonly allowsVibrancy?: boolean + readonly draggedTypes?: DraggedTypesType + readonly enableFocusRing?: boolean + readonly inverted?: boolean + readonly mouseDownCanMoveWindow?: boolean + readonly tooltip?: string + readonly onDragEnter?: (event: DragEvent) => void + readonly onDragLeave?: (event: DragEvent) => void + readonly onDrop?: (event: DragEvent) => void +} declare type Mapping = | AnimatedValue_default | AnimatedValueXY_default @@ -3201,6 +3392,7 @@ declare type MouseEvent = NativeSyntheticEvent<{ readonly timestamp: number }> declare type MouseEventProps = { + readonly onDoubleClick?: (event: MouseEvent) => void readonly onMouseEnter?: (event: MouseEvent) => void readonly onMouseLeave?: (event: MouseEvent) => void } @@ -3324,6 +3516,7 @@ declare type NativeScrollEvent = { readonly contentOffset: NativeScrollPoint readonly contentSize: NativeScrollSize readonly layoutMeasurement: NativeScrollSize + readonly preferredScrollerStyle?: string readonly responderIgnoreScroll?: boolean readonly targetContentOffset?: NativeScrollPoint readonly velocity?: NativeScrollVelocity @@ -3381,13 +3574,18 @@ declare type NativeTextProps = Readonly< } > declare type NativeTouchEvent = { + readonly altKey?: boolean + readonly button?: number readonly changedTouches: ReadonlyArray + readonly ctrlKey?: boolean readonly force?: number readonly identifier: number readonly locationX: number readonly locationY: number + readonly metaKey?: boolean readonly pageX: number readonly pageY: number + readonly shiftKey?: boolean readonly target: number | undefined readonly timestamp: number readonly touches: ReadonlyArray @@ -3431,6 +3629,7 @@ declare type OnAnimationDidFailCallback = () => void declare type OpaqueColorValue = NativeColorValue declare type OptionalFlatListProps = { columnWrapperStyle?: ViewStyleProp + enableSelectionOnKeyPress?: boolean extraData?: any fadingEdgeLength?: | (number | undefined) @@ -3441,6 +3640,7 @@ declare type OptionalFlatListProps = { horizontal?: boolean initialNumToRender?: number initialScrollIndex?: number + initialSelectedIndex?: number inverted?: boolean keyExtractor?: (item: ItemT, index: number) => string numColumns?: number @@ -3479,9 +3679,9 @@ declare type OptionalVirtualizedListProps = { keyExtractor?: (item: Item, index: number) => string ListEmptyComponent?: React.ComponentType | React.JSX.Element ListFooterComponent?: React.ComponentType | React.JSX.Element - ListFooterComponentStyle?: StyleProp + ListFooterComponentStyle?: StyleProp_2 ListHeaderComponent?: React.ComponentType | React.JSX.Element - ListHeaderComponentStyle?: StyleProp + ListHeaderComponentStyle?: StyleProp_2 ListItemComponent?: React.ComponentType | React.JSX.Element maxToRenderPerBatch?: number onEndReached?: (info: { distanceFromEnd: number }) => void @@ -3516,7 +3716,7 @@ declare type OptionalVirtualizedListProps = { length: number offset: number } - renderScrollComponent?: (props: ScrollViewProps) => React.JSX.Element + renderScrollComponent?: (props: ScrollViewProps_2) => React.JSX.Element } declare type OptionalVirtualizedSectionListProps< ItemT, @@ -3529,6 +3729,7 @@ declare type OptionalVirtualizedSectionListProps< stickySectionHeadersEnabled?: boolean renderItem?: (info: { index: number + isSelected: boolean | undefined item: ItemT section: SectionT separators: { @@ -3591,6 +3792,11 @@ declare type PassThroughProps = { readonly passthroughAnimatedPropExplicitValues?: null | ViewProps } declare type PasswordRules = string +declare type PastedTypesType = PasteType | ReadonlyArray +declare type PasteEvent = NativeSyntheticEvent<{ + readonly dataTransfer: DataTransfer +}> +declare type PasteType = "fileUrl" | "image" | "string" declare type Permission = PermissionsType[keyof PermissionsType] declare type PermissionsAndroid = typeof PermissionsAndroid declare class PermissionsAndroidImpl { @@ -3753,10 +3959,12 @@ declare type PressabilityConfig = { readonly disabled?: boolean readonly hitSlop?: RectOrSize readonly minPressDuration?: number - readonly onBlur?: (event: BlurEvent) => unknown - readonly onFocus?: (event: FocusEvent) => unknown + readonly onBlur?: (event: BlurEvent) => void + readonly onFocus?: (event: FocusEvent) => void readonly onHoverIn?: (event: MouseEvent) => unknown readonly onHoverOut?: (event: MouseEvent) => unknown + readonly onKeyDown?: (event: KeyEvent) => void + readonly onKeyUp?: (event: KeyEvent) => void readonly onLongPress?: (event: GestureResponderEvent) => unknown readonly onPress?: (event: GestureResponderEvent) => unknown readonly onPressIn?: (event: GestureResponderEvent) => unknown @@ -3772,6 +3980,8 @@ declare type PressableAndroidRippleConfig = { radius?: number } declare type PressableBaseProps = { + readonly acceptsFirstMouse?: boolean + readonly allowsVibrancy?: boolean readonly android_disableSound?: boolean readonly android_ripple?: PressableAndroidRippleConfig readonly blockNativeResponder?: boolean @@ -3783,9 +3993,18 @@ declare type PressableBaseProps = { readonly delayHoverOut?: number readonly delayLongPress?: number readonly disabled?: boolean + readonly draggedTypes?: DraggedTypesType + readonly enableFocusRing?: boolean readonly hitSlop?: RectOrSize + readonly keyDownEvents?: Array + readonly keyUpEvents?: Array + readonly mouseDownCanMoveWindow?: boolean + readonly onBlur?: (event: BlurEvent) => void + readonly onFocus?: (event: FocusEvent) => void readonly onHoverIn?: (event: MouseEvent) => unknown readonly onHoverOut?: (event: MouseEvent) => unknown + readonly onKeyDown?: (event: KeyEvent) => void + readonly onKeyUp?: (event: KeyEvent) => void readonly onLayout?: (event: LayoutChangeEvent) => unknown readonly onLongPress?: (event: GestureResponderEvent) => unknown readonly onPress?: (event: GestureResponderEvent) => unknown @@ -3798,6 +4017,10 @@ declare type PressableBaseProps = { | ViewStyleProp readonly testID?: string readonly testOnly_pressed?: boolean + readonly tooltip?: string + readonly onDragEnter?: (event: DragEvent) => void + readonly onDragLeave?: (event: DragEvent) => void + readonly onDrop?: (event: DragEvent) => void } declare type PressableProps = Readonly< Omit & PressableBaseProps @@ -4333,8 +4556,10 @@ declare type ScrollViewBaseProps = { readonly contentOffset?: PointProp readonly decelerationRate?: DecelerationRateType readonly disableIntervalMomentum?: boolean + readonly hasOverlayStyleIndicator?: boolean readonly horizontal?: boolean readonly innerViewRef?: React.Ref + readonly inverted?: boolean readonly invertStickyHeaders?: boolean readonly keyboardDismissMode?: "interactive" | "none" | "on-drag" readonly keyboardShouldPersistTaps?: @@ -4638,6 +4863,11 @@ declare function setSurfaceProps( appParameters: Object, displayMode?: number, ): void +declare type SettingChangeEvent = NativeSyntheticEvent<{ + readonly autoCorrectEnabled: boolean + readonly grammarCheckEnabled: boolean + readonly spellCheckEnabled: boolean +}> declare type Settings = typeof Settings declare function setWrapperComponentProvider( provider: WrapperComponentProvider, @@ -4882,6 +5112,7 @@ declare type State = { firstVisibleItemKey: string | undefined pendingScrollUpdateCount: number renderMask: CellRenderMask + selectedRowIndex: number } declare class StateSafePureComponent_default< Props, @@ -4939,9 +5170,9 @@ declare type StatusBarPropsIOS = { readonly showHideTransition?: "fade" | "none" | "slide" } declare type StatusBarStyle = keyof { - "dark-content": string + "dark-content": ColorValue default: string - "light-content": string + "light-content": ColorValue } declare type StickyHeaderComponentType = ( props: ScrollViewStickyHeaderProps & { @@ -4970,6 +5201,14 @@ declare namespace StyleSheet { } } declare type SubmitBehavior = "blurAndSubmit" | "newline" | "submit" +declare type SubmitKeyEvent = { + readonly altKey?: boolean + readonly ctrlKey?: boolean + readonly functionKey?: boolean + readonly key: string + readonly metaKey?: boolean + readonly shiftKey?: boolean +} declare type subtract = typeof subtract declare type Switch = typeof Switch declare type SwitchChangeEvent = NativeSyntheticEvent @@ -5013,6 +5252,12 @@ declare type SwitchPropsIOS = { declare type SwitchRef = React.ComponentRef< typeof $$AndroidSwitchNativeComponent | typeof $$SwitchNativeComponent > +declare type SystemEffectMacOS = + | "deepPressed" + | "disabled" + | "none" + | "pressed" + | "rollover" declare namespace Systrace { export { isEnabled, @@ -5225,7 +5470,7 @@ declare type TextInputBaseProps = { readonly onChangeText?: (text: string) => unknown readonly onContentSizeChange?: (e: TextInputContentSizeChangeEvent) => unknown readonly onEndEditing?: (e: TextInputEndEditingEvent) => unknown - readonly onFocus?: (e: TextInputFocusEvent) => unknown + readonly onFocus?: (e: TextInputFocusEvent) => void readonly onKeyPress?: (e: TextInputKeyPressEvent) => unknown readonly onPress?: (event: GestureResponderEvent) => unknown readonly onPressIn?: (event: GestureResponderEvent) => unknown @@ -5263,6 +5508,8 @@ declare type TextInputComponentStatics = { readonly currentlyFocusedField: () => number | undefined readonly currentlyFocusedInput: () => HostInstance | undefined readonly focusTextInput: (textField: HostInstance | undefined) => void + readonly onTextInputBlur: (textField: HostInstance | undefined) => void + readonly onTextInputFocus: (textField: HostInstance | undefined) => void } } declare type TextInputContentSizeChangeEvent = @@ -5327,11 +5574,28 @@ declare type TextInputKeyPressEventData = Readonly< target?: number } > +declare type TextInputMacOSProps = { + readonly clearTextOnSubmit?: boolean + readonly grammarCheck?: boolean + readonly hideVerticalScrollIndicator?: boolean + readonly keyDownEvents?: ReadonlyArray + readonly keyUpEvents?: ReadonlyArray + readonly onAutoCorrectChange?: (e: SettingChangeEvent) => unknown + readonly onGrammarCheckChange?: (e: SettingChangeEvent) => unknown + readonly onKeyDown?: (e: KeyEvent) => unknown + readonly onKeyUp?: (e: KeyEvent) => unknown + readonly onSpellCheckChange?: (e: SettingChangeEvent) => unknown + readonly pastedTypes?: PastedTypesType + readonly submitKeyEvents?: ReadonlyArray + readonly tooltip?: string + readonly onPaste?: (event: PasteEvent) => void +} declare type TextInputProps = Readonly< Omit & TextInputIOSProps & TextInputAndroidProps & - TextInputBaseProps + TextInputBaseProps & + TextInputMacOSProps > declare type TextInputSelectionChangeEvent = NativeSyntheticEvent @@ -5371,6 +5635,7 @@ declare type TextProps = Readonly< TextPointerEventProps & TextPropsIOS & TextPropsAndroid & + TextPropsMacOS & TextBaseProps & AccessibilityProps > @@ -5399,6 +5664,11 @@ declare type TextPropsIOS = { lineBreakStrategyIOS?: "hangul-word" | "none" | "push-out" | "standard" suppressHighlighting?: boolean } +declare type TextPropsMacOS = { + enableFocusRing?: boolean + focusable?: boolean + tooltip?: string +} declare type TextStyle = ____TextStyle_Internal declare type TextStyleProp = ____TextStyleProp_Internal declare type Timespan = { @@ -5558,8 +5828,8 @@ declare type TouchableWithoutFeedbackProps = Readonly< importantForAccessibility?: "auto" | "no-hide-descendants" | "no" | "yes" nativeID?: string onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown - onBlur?: (event: BlurEvent) => unknown - onFocus?: (event: FocusEvent) => unknown + onBlur?: (event: BlurEvent) => void + onFocus?: (event: FocusEvent) => void onLayout?: (event: LayoutChangeEvent) => unknown onLongPress?: (event: GestureResponderEvent) => unknown onPress?: (event: GestureResponderEvent) => unknown @@ -5574,7 +5844,17 @@ declare type TouchableWithoutFeedbackProps = Readonly< declare type TouchableWithoutFeedbackPropsAndroid = { touchSoundDisabled?: boolean } -declare type TouchableWithoutFeedbackPropsIOS = {} +declare type TouchableWithoutFeedbackPropsIOS = { + acceptsFirstMouse?: boolean + draggedTypes?: DraggedTypesType + enableFocusRing?: boolean + tooltip?: string + onDragEnter?: (event: DragEvent) => void + onDragLeave?: (event: DragEvent) => void + onDrop?: (event: DragEvent) => void + onMouseEnter?: (event: MouseEvent) => void + onMouseLeave?: (event: MouseEvent) => void +} declare type TouchEventProps = { readonly onTouchCancel?: (e: GestureResponderEvent) => void readonly onTouchCancelCapture?: (e: GestureResponderEvent) => void @@ -5738,8 +6018,10 @@ declare type ViewProps = Readonly< PointerEventProps & FocusEventProps & TouchEventProps & + KeyboardEventProps & ViewPropsAndroid & ViewPropsIOS & + MacOSViewProps & AccessibilityProps & ViewBaseProps > @@ -5773,14 +6055,15 @@ declare class VirtualizedList_default extends StateSafePureComponent_default< componentDidUpdate(prevProps: VirtualizedListProps): void componentWillUnmount(): void constructor(props: VirtualizedListProps) + ensureItemAtIndexIsVisible(rowIndex: number): void flashScrollIndicators(): void static getDerivedStateFromProps( newProps: VirtualizedListProps, prevState: State, ): State getScrollableNode(): null | number | undefined - getScrollRef(): null | React.ComponentRef | undefined - getScrollResponder(): null | ScrollResponderType | undefined + getScrollRef(): null | React.ComponentRef | undefined + getScrollResponder(): null | ScrollResponderType_2 | undefined hasMore(): boolean measureLayoutRelativeToContainingList(): void recordInteraction(): void @@ -5806,6 +6089,7 @@ declare class VirtualizedList_default extends StateSafePureComponent_default< viewPosition?: number }): void scrollToOffset(params: { animated?: boolean; offset: number }): void + selectRowAtIndex(rowIndex: number): void setNativeProps(props: Object): void } declare type VirtualizedListContext = typeof VirtualizedListContext @@ -5814,9 +6098,70 @@ declare function VirtualizedListContextResetter($$PARAM_0$$: { }): React.ReactNode declare type VirtualizedListContextResetterT = typeof VirtualizedListContextResetter -declare type VirtualizedListProps = ScrollViewProps & +declare type VirtualizedListMacOSProps = { + enableSelectionOnKeyPress?: boolean + initialSelectedIndex?: number + onSelectionChanged?: (info: { + item: Item | undefined + newSelection: number + previousSelection: number + }) => void + onSelectionEntered?: (item: Item | undefined) => void + rowIndex?: number + sectionIndex?: number +} +declare type VirtualizedListProps = Omit< + ScrollViewProps_2, + | "data" + | "getItem" + | "getItemCount" + | "enableSelectionOnKeyPress" + | "initialSelectedIndex" + | "onSelectionChanged" + | "onSelectionEntered" + | "rowIndex" + | "sectionIndex" + | "CellRendererComponent" + | "debug" + | "disableVirtualization" + | "extraData" + | "getItemLayout" + | "horizontal" + | "initialNumToRender" + | "initialScrollIndex" + | "inverted" + | "ItemSeparatorComponent" + | "keyExtractor" + | "ListEmptyComponent" + | "ListFooterComponent" + | "ListFooterComponentStyle" + | "ListHeaderComponent" + | "ListHeaderComponentStyle" + | "ListItemComponent" + | "maxToRenderPerBatch" + | "onEndReached" + | "onEndReachedThreshold" + | "onRefresh" + | "onScrollToIndexFailed" + | "onStartReached" + | "onStartReachedThreshold" + | "onViewableItemsChanged" + | "persistentScrollbar" + | "progressViewOffset" + | "refreshControl" + | "refreshing" + | "removeClippedSubviews" + | "renderItem" + | "renderScrollComponent" + | "updateCellsBatchingPeriod" + | "viewabilityConfig" + | "viewabilityConfigCallbackPairs" + | "windowSize" + | never +> & RequiredVirtualizedListProps & - OptionalVirtualizedListProps + OptionalVirtualizedListProps & + VirtualizedListMacOSProps declare type VirtualizedListT = typeof VirtualizedList_default declare type VirtualizedListType = typeof $$index.VirtualizedList declare type VirtualizedSectionList = typeof VirtualizedSectionList @@ -5914,23 +6259,23 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // f6181a2c AccessibilityInfo, // 70604904 - AccessibilityProps, // 5a2836fc - AccessibilityRole, // f2f2e066 + AccessibilityProps, // d961eb5c + AccessibilityRole, // 3cf8c6c5 AccessibilityState, // b0c2b3f7 AccessibilityValue, // cf8bcb74 ActionSheetIOS, // 88e6bfb0 ActionSheetIOSOptions, // 1756eb5a ActivityIndicator, // 8d041a45 - ActivityIndicatorProps, // 0fa4e79d - Alert, // 5bf12165 + ActivityIndicatorProps, // 3e85d5eb + Alert, // 24958ab5 AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 - AlertOptions, // a0cdac0f + AlertOptions, // 39b16cfa AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 6b6a0b2e + Animated, // df69db8d AppConfig, // ebddad4b - AppRegistry, // 6cdee1d6 + AppRegistry, // eb54df94 AppState, // f7097b1b AppStateEvent, // 80f034c3 AppStateStatus, // 447e5ef2 @@ -5941,14 +6286,15 @@ export { BlurEvent, // 870b9bb5 BoxShadowValue, // b679703f Button, // dd130b61 - ButtonProps, // 3c081e75 + ButtonProps, // 9ec3afed Clipboard, // 9b8c878e CodegenTypes, // 030a94b8 ColorSchemeName, // 31a4350e ColorValue, // 98989a8f + ColorWithSystemEffectMacOS, // df1263b4 ComponentProvider, // b5c60ddd ComponentProviderInstrumentationHook, // 9f640048 - CursorValue, // 26522595 + CursorValue, // 2c77888f DevMenu, // 99e9fcd6 DevSettings, // 1a2f3a5f DeviceEventEmitter, // 31dc96e7 @@ -5960,11 +6306,13 @@ export { DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb DrawerLayoutAndroid, // 14121b61 - DrawerLayoutAndroidProps, // 123d3a9d + DrawerLayoutAndroidProps, // 094b05e9 DrawerSlideEvent, // cc43db83 DropShadowValue, // e9df2606 DynamicColorIOS, // 1f9b3410 DynamicColorIOSTuple, // 023ce58e + DynamicColorMacOS, // f47c1c7d + DynamicColorMacOSTuple, // 692ec192 Easing, // b624f91d EasingFunction, // 14aee4c0 EdgeInsetsValue, // bd44afe6 @@ -5974,12 +6322,12 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // cbb48cbe - FlatListProps, // 451be810 + FlatList, // a2024a35 + FlatListProps, // 3069cec7 FocusEvent, // 529b43eb FontVariant, // 7c7558bb - GestureResponderEvent, // b466f6d6 - GestureResponderHandlers, // 8356843d + GestureResponderEvent, // 52d0886d + GestureResponderHandlers, // 1c246fde Handle, // 2d65285d HostComponent, // 5e13ff5a HostInstance, // 489cbe7f @@ -5987,30 +6335,30 @@ export { IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece Image, // 04474205 - ImageBackground, // 489b1c17 - ImageBackgroundProps, // 1b209e36 + ImageBackground, // 207a3d82 + ImageBackgroundProps, // dec98729 ImageErrorEvent, // b7b2ae63 ImageLoadEvent, // 5baae813 ImageProgressEventIOS, // adb35052 - ImageProps, // 40c727e1 + ImageProps, // 8724fc7d ImagePropsAndroid, // 9fd9bcbb - ImagePropsBase, // 715b84bf + ImagePropsBase, // a2ad423b ImagePropsIOS, // 318adce2 ImageRequireSource, // 681d683b ImageResolvedAssetSource, // f3060931 ImageSize, // 1c47cf88 ImageSource, // 48c7f316 ImageSourcePropType, // bfb5e5c6 - ImageStyle, // 8b22ac76 + ImageStyle, // 6d9dfbb6 ImageURISource, // 016eb083 InputAccessoryView, // 591855d8 - InputAccessoryViewProps, // 4b6f5450 + InputAccessoryViewProps, // fc574890 InputModeOptions, // 4e8581b9 Insets, // e7fe432a InteractionManager, // 301bfa63 Keyboard, // 87311c77 - KeyboardAvoidingView, // d88d0d4c - KeyboardAvoidingViewProps, // bc844418 + KeyboardAvoidingView, // 3511ce7b + KeyboardAvoidingViewProps, // 80af58f8 KeyboardEvent, // c3f895d4 KeyboardEventEasing, // af4091c8 KeyboardEventName, // 59299ad6 @@ -6027,8 +6375,8 @@ export { LayoutConformanceProps, // 055f03b8 LayoutRectangle, // 6601b294 Linking, // 292de0a0 - ListRenderItem, // b5353fd8 - ListRenderItemInfo, // e8595b03 + ListRenderItem, // 3ba527db + ListRenderItemInfo, // d21a2b9b ListViewToken, // 833d3481 LogBox, // b58880c6 LogData, // 89af6d4c @@ -6037,7 +6385,7 @@ export { MeasureOnSuccessCallback, // 82824e59 Modal, // 78e8a79d ModalBaseProps, // 0c81c9b1 - ModalProps, // 270223fa + ModalProps, // c6a42659 ModalPropsAndroid, // 515fb173 ModalPropsIOS, // 4fbcedf6 ModeChangeEvent, // b889a7ce @@ -6053,14 +6401,14 @@ export { NativeModules, // 1cf72876 NativeMouseEvent, // ff25cf35 NativePointerEvent, // 89c1f3ad - NativeScrollEvent, // caad7f53 + NativeScrollEvent, // 431aad2d NativeSyntheticEvent, // d2a1fe6a - NativeTouchEvent, // 59b676df + NativeTouchEvent, // e9fce623 NativeUIEvent, // 44ac26ac Networking, // b674447b OpaqueColorValue, // 25f3fa5b PanResponder, // 98a9b6fc - PanResponderCallbacks, // d325aa56 + PanResponderCallbacks, // 218dfc6b PanResponderGestureState, // 54baf558 PanResponderInstance, // c8b0d00c Permission, // 06473f4f @@ -6075,11 +6423,11 @@ export { PointerEvent, // ff3129ff Pressable, // 3c6e4eb9 PressableAndroidRippleConfig, // 42bc9727 - PressableProps, // 96c8132d + PressableProps, // 5f0ec9ab PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 ProgressBarAndroid, // 03e66cf5 - ProgressBarAndroidProps, // 29338dc2 + ProgressBarAndroidProps, // e748eac9 PromiseTask, // 5102c862 PublicRootInstance, // 8040afd7 PublicTextInstance, // 7d73f802 @@ -6088,8 +6436,8 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // 036f45cf - RefreshControlProps, // b7de1e77 + RefreshControl, // b974da75 + RefreshControlProps, // 53497d9a RefreshControlPropsAndroid, // 99f64c97 RefreshControlPropsIOS, // 72a36381 Registry, // e1ed403e @@ -6098,26 +6446,26 @@ export { Role, // af7b889d RootTag, // 3cd10504 RootTagContext, // 15b60335 - RootViewStyleProvider, // cc8d50e9 + RootViewStyleProvider, // 267bc77f Runnable, // 2cb32c54 Runnables, // d3749ae1 SafeAreaView, // 4364c7bb ScaledSize, // 07e417c7 - ScrollEvent, // 84e5b805 + ScrollEvent, // 7d125b6d ScrollResponderType, // d39056e7 ScrollToLocationParamsType, // d7ecdad1 ScrollView, // 7fb7c469 ScrollViewImperativeMethods, // eb20aa46 - ScrollViewProps, // 27986ff5 + ScrollViewProps, // 100d5524 ScrollViewPropsAndroid, // 84e2134b - ScrollViewPropsIOS, // d83c9733 + ScrollViewPropsIOS, // fb759b3a ScrollViewScrollToOptions, // 3313411e SectionBase, // b376bddc - SectionList, // ff1193b2 + SectionList, // 50858078 SectionListData, // 119baf83 - SectionListProps, // c9ac8e07 - SectionListRenderItem, // 1fad0435 - SectionListRenderItemInfo, // 745e1992 + SectionListProps, // 1c9565a0 + SectionListRenderItem, // 9600767c + SectionListRenderItemInfo, // 158b913b Separators, // 6a45f7e3 Settings, // 4282b0da Share, // e4591b32 @@ -6127,16 +6475,16 @@ export { ShareContent, // 7c627896 ShareOptions, // 800c3a4e SimpleTask, // 0e619d11 - StatusBar, // 5e08d563 + StatusBar, // e7cd8aa8 StatusBarAnimation, // 7fd047e6 StatusBarProps, // 06c98add - StatusBarStyle, // 986b2051 + StatusBarStyle, // 49e3b6de StyleProp, // fa0e9b4a StyleSheet, // 366689d4 SubmitBehavior, // c4ddf490 Switch, // aebc9941 SwitchChangeEvent, // 2e5bd2de - SwitchProps, // cb21930d + SwitchProps, // fef27f18 Systrace, // b5aa21fc TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 @@ -6149,24 +6497,24 @@ export { TextInputContentSizeChangeEvent, // 5fba3f54 TextInputEndEditingEvent, // 8c22fac3 TextInputFocusEvent, // c36e977c - TextInputIOSProps, // 0d05a855 + TextInputIOSProps, // e8905f3e TextInputKeyPressEvent, // 967178c2 - TextInputProps, // 8f3237f1 + TextInputProps, // 361942cb TextInputSelectionChangeEvent, // a1a7622f TextInputSubmitEditingEvent, // 48d903af TextLayoutEvent, // 45b0a8d7 - TextProps, // 95d8874d - TextStyle, // f3404e2b + TextProps, // 0c068ca2 + TextStyle, // 88ac4ff3 ToastAndroid, // b4875e35 Touchable, // 93eb6c63 TouchableHighlight, // b4304a98 - TouchableHighlightProps, // c871f353 - TouchableNativeFeedback, // aaa5b42c - TouchableNativeFeedbackProps, // 372d3213 + TouchableHighlightProps, // d9baf596 + TouchableNativeFeedback, // f9c414f6 + TouchableNativeFeedbackProps, // 9587d6ed TouchableOpacity, // 7e33acfd - TouchableOpacityProps, // ba6c0ba4 - TouchableWithoutFeedback, // 7363a906 - TouchableWithoutFeedbackProps, // 68e3d87f + TouchableOpacityProps, // 77031327 + TouchableWithoutFeedback, // 68ac8437 + TouchableWithoutFeedbackProps, // ca6e7192 TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 @@ -6174,15 +6522,15 @@ export { UTFSequence, // baacd11b Vibration, // 315e131d View, // 39dd4de4 - ViewProps, // f8aca212 - ViewPropsAndroid, // 21385d96 + ViewProps, // 197f314a + ViewPropsAndroid, // 6d846811 ViewPropsIOS, // 58ee19bf - ViewStyle, // c2db0e6e + ViewStyle, // c0170ec0 VirtualViewMode, // 85a69ef6 VirtualizedList, // 4d513939 - VirtualizedListProps, // a99d36db + VirtualizedListProps, // cf03a29a VirtualizedSectionList, // 446ba0df - VirtualizedSectionListProps, // 6cd4b378 + VirtualizedSectionListProps, // e136f7bb WrapperComponentProvider, // 9cf3844c codegenNativeCommands, // e16d62f7 codegenNativeComponent, // ed4c8103 diff --git a/scripts/js-api/build-types/buildApiSnapshot.js b/scripts/js-api/build-types/buildApiSnapshot.js index 9f1f50f0644a..ef37f688dce3 100644 --- a/scripts/js-api/build-types/buildApiSnapshot.js +++ b/scripts/js-api/build-types/buildApiSnapshot.js @@ -168,7 +168,10 @@ async function validateSnapshots( async function findPackagesWithTypedef() { const packagesWithGeneratedTypes = glob - .sync(`${PACKAGES_DIR}/**/types_generated`, {nodir: false}) + .sync(`${PACKAGES_DIR}/**/types_generated`, { + nodir: false, + ignore: '**/node_modules/**', // [macOS] Use workspaces, not their dependency links. + }) .map(typesPath => path.relative(PACKAGES_DIR, typesPath).split('/').slice(0, -1).join('/'), ); diff --git a/scripts/js-api/build-types/resolution/__tests__/simpleResolve-test.js b/scripts/js-api/build-types/resolution/__tests__/simpleResolve-test.js new file mode 100644 index 000000000000..53cbb0c28fdc --- /dev/null +++ b/scripts/js-api/build-types/resolution/__tests__/simpleResolve-test.js @@ -0,0 +1,65 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +const {PACKAGES_DIR} = require('../../../../shared/consts'); +const {promises: fs} = require('fs'); +const glob = require('glob'); +const path = require('path'); + +describe('simpleResolve workspace dependencies', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + test.each([ + [true, undefined, 'index.js'], + [true, 'src/index.js', 'src/index.js'], + [false, undefined, 'index.js'], + ])( + 'resolves a workspace with private=%s and main=%s', + async (isPrivate, main, entryPoint) => { + const packagePath = path.join(PACKAGES_DIR, 'type-dependency'); + jest + .spyOn(glob, 'sync') + .mockReturnValue([path.join(packagePath, 'package.json')]); + jest.spyOn(fs, 'readFile').mockResolvedValue( + JSON.stringify({ + name: '@react-native-macos/type-dependency', + private: isPrivate, + main, + }), + ); + // Load the real package filter with an empty resolver cache each time. + const simpleResolve = require('../simpleResolve'); + const reportUnresolvedDependency = jest.fn(); + + await expect( + simpleResolve( + '@react-native-macos/type-dependency', + path.join(PACKAGES_DIR, 'react-native/index.js.flow'), + {reportUnresolvedDependency}, + ), + ).resolves.toBe(path.join(packagePath, entryPoint)); + expect(reportUnresolvedDependency).not.toHaveBeenCalled(); + + await expect( + simpleResolve( + 'external-package', + path.join(PACKAGES_DIR, 'react-native/index.js.flow'), + {reportUnresolvedDependency}, + ), + ).resolves.toBeNull(); + expect(reportUnresolvedDependency).toHaveBeenCalledWith( + 'external-package', + ); + }, + ); +}); diff --git a/scripts/js-api/build-types/resolution/simpleResolve.js b/scripts/js-api/build-types/resolution/simpleResolve.js index a571f04a6424..c3688625f112 100644 --- a/scripts/js-api/build-types/resolution/simpleResolve.js +++ b/scripts/js-api/build-types/resolution/simpleResolve.js @@ -34,7 +34,7 @@ async function simpleResolve( if (cachedProjectInfo == null) { cachedProjectInfo = await getPackages({ includeReactNative: true, - includePrivate: false, + includePrivate: true, // [macOS] Main keeps the fork's type dependencies private. }); } From 226a7abc2fb5d630d2c5915d2e082acbbdc94e60 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 22 Sep 2026 11:05:31 -0700 Subject: [PATCH 34/38] fix(ci): apply reviewed release validation repairs Preserve validated per-branch source and corrected stack dependencies. --- .changeset/config.json | 2 +- .../__tests__/publishing-contract.test.mjs | 87 +++++++++++++++-- .../__tests__/publishing-workflow.test.mjs | 51 +++++++--- .github/scripts/publishing-contract.md | 2 + .github/scripts/publishing-contract.mjs | 8 +- .../workflows/microsoft-build-rntester.yml | 4 + .github/workflows/microsoft-pr.yml | 2 +- ...soft-react-native-test-app-integration.yml | 1 + .yarnrc.yml | 3 - packages/react-native/Package.swift | 24 ++--- yarn.lock | 96 +++++++++---------- 11 files changed, 195 insertions(+), 85 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 83c142522a99..d94a90f458c1 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -6,7 +6,7 @@ "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [["react-native-macos", "@react-native-macos/virtualized-lists"]], - "ignore": [], + "ignore": ["react-native-macos", "@react-native/tester"], "privatePackages": { "version": false, "tag": false diff --git a/.github/scripts/__tests__/publishing-contract.test.mjs b/.github/scripts/__tests__/publishing-contract.test.mjs index 5219fa4e53a1..685d9e821cd5 100644 --- a/.github/scripts/__tests__/publishing-contract.test.mjs +++ b/.github/scripts/__tests__/publishing-contract.test.mjs @@ -182,6 +182,10 @@ const repositoryRoot = new URL('../../../', import.meta.url).pathname; const releasePolicy = JSON.parse(readFileSync(join(repositoryRoot, '.changeset/config.json'), 'utf8')); const getReleasePlan = require('@changesets/get-release-plan').default; const semver = require('semver'); +// Stable preparation clears main's deferral of the unreleasable development graph. +const stablePolicy = {...releasePolicy, baseBranch: `origin/${branch}`, ignore: []}; +// Older Changesets also requires private dependents of ignored packages here. +const mainPolicy = {...releasePolicy, baseBranch: 'origin/main', ignore: [core, '@react-native/tester']}; test('repository Changesets policy disables private versions and tags and fixes core with lists', () => { assert.deepEqual(releasePolicy.privatePackages, {version: false, tag: false}); @@ -194,27 +198,81 @@ test('repository Changesets policy follows the actual public and private workspa const listsPackage = workspaces.find(pkg => pkg.name === lists); assert.ok(corePackage && !corePackage.private, 'Missing public core workspace'); assert.ok(listsPackage, 'Missing lists workspace'); - const publicPackages = listsPackage.private ? [corePackage] : [corePackage, listsPackage]; + const main = corePackage.version === '1000.0.0'; + assert.deepEqual(releasePolicy.ignore, main ? mainPolicy.ignore : []); + assert.equal(releasePolicy.baseBranch, main ? 'origin/main' + : `origin/${semver.major(corePackage.version)}.${semver.minor(corePackage.version)}-stable`); + assert.equal(Boolean(listsPackage.private), main, 'Lists must be public on stable and private on main'); + assert.equal(listsPackage.version, corePackage.version); + const publicPackages = [corePackage, listsPackage]; // Derive expectations from manifests, never from the policy or release-plan output. const nextVersion = semver.inc(publicPackages.map(pkg => pkg.version).sort(semver.rcompare)[0], 'patch'); - const expected = publicPackages.map(pkg => [pkg.name, nextVersion]).sort(); + const expected = main ? [] : publicPackages.map(pkg => [pkg.name, nextVersion]).sort(); const {root} = releaseFixture(t, {workspaces, config: releasePolicy}); assert.deepEqual((await getReleasePlan(root)).releases, []); for (const changed of [core, lists]) { writeFileSync(join(root, '.changeset/fix.md'), `---\n"${changed}": patch\n---\n\nFix package.\n`); const bumped = (await getReleasePlan(root)).releases.filter(pkg => pkg.type !== 'none'); - assert.deepEqual(bumped.map(pkg => [pkg.name, pkg.newVersion]).sort(), - changed === lists && listsPackage.private ? [] : expected); + assert.deepEqual(bumped.map(pkg => [pkg.name, pkg.newVersion]).sort(), expected); } }); +test('main defers the private runtime graph, preserves pending core changes, and releases them after stable preparation', async t => { + const workspaces = graph('1000.0.0'); + workspaces[1].private = true; + workspaces[0].dependencies['@react-native/codegen'] = 'workspace:*'; + workspaces.push({name: '@react-native/tester', version: '1000.0.0', private: true, + devDependencies: {[core]: 'workspace:*'}}); + const {root} = releaseFixture(t, {workspaces, config: mainPolicy}); + const coreChange = '---\n"react-native-macos": patch\n---\n\nFix core.\n'; + writeFileSync(join(root, '.changeset/core.md'), coreChange); + writeFileSync(join(root, '.changeset/init.md'), '---\n"react-native-macos-init": patch\n---\n\nFix init.\n'); + assert.deepEqual((await getReleasePlan(root)).releases.map(pkg => [pkg.name, pkg.newVersion]), + [['react-native-macos-init', '2.1.4']]); + const version = () => execFileSync(process.execPath, [require.resolve('@changesets/cli/bin.js'), 'version'], { + cwd: root, encoding: 'utf8', env: {...process.env, CI: 'true'}, + }); + version(); + for (const [index, pkg] of workspaces.entries()) { + const expected = pkg.name === 'react-native-macos-init' ? {...pkg, version: '2.1.4'} : pkg; + assert.deepEqual(JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8')), expected); + } + assert.equal(readFileSync(join(root, '.changeset/core.md'), 'utf8'), coreChange); + assert.deepEqual((await getReleasePlan(root)).releases, []); + + // Model the committed stable preparation: public coupled versions and registry + // inputs for upstream packages, with no ignored public release packages. + workspaces[0].version = workspaces[1].version = '0.83.0'; + workspaces[0].dependencies['@react-native/codegen'] = '0.83.1'; + delete workspaces[1].private; + for (const index of [0, 1]) { + writeFileSync(join(root, `packages/p${index}/package.json`), JSON.stringify(workspaces[index])); + } + writeFileSync(join(root, '.changeset/config.json'), JSON.stringify(stablePolicy)); + assert.deepEqual((await getReleasePlan(root)).releases.filter(pkg => pkg.type !== 'none').map(pkg => [pkg.name, pkg.newVersion]).sort(), + [[core, '0.83.1'], [lists, '0.83.1']].sort()); + version(); + for (const index of [0, 1]) { + const pkg = JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8')); + assert.equal(pkg.version, '0.83.1'); + assert.ok(!pkg.private); + assert.match(readFileSync(join(root, `packages/p${index}/CHANGELOG.md`), 'utf8'), /^## 0\.83\.1$/m); + } + for (const [index, pkg] of workspaces.entries()) { + if (pkg.private) { + assert.deepEqual(JSON.parse(readFileSync(join(root, `packages/p${index}/package.json`), 'utf8')), pkg); + } + } + assert.deepEqual((await getReleasePlan(root)).changesets, []); +}); + test('repository Changesets policy accepts a private lists fixture and skips its release', async t => { const workspaces = graph('1000.0.0'); workspaces[1].private = true; // A public package can use skipped private packages as development dependencies. workspaces[0].devDependencies = {[lists]: workspaces[0].dependencies[lists]}; delete workspaces[0].dependencies[lists]; - const {root} = releaseFixture(t, {workspaces, config: releasePolicy}); + const {root} = releaseFixture(t, {workspaces, config: stablePolicy}); assert.deepEqual((await getReleasePlan(root)).releases, []); writeFileSync(join(root, '.changeset/fix.md'), `---\n"${core}": patch\n---\n\nFix core.\n`); const bumped = (await getReleasePlan(root)).releases.filter(pkg => pkg.type !== 'none'); @@ -224,7 +282,7 @@ test('repository Changesets policy accepts a private lists fixture and skips its }); test('repository Changesets policy couples public stable packages without registry or private release edges', async t => { - const {root, workspaces} = releaseFixture(t, {config: releasePolicy}); + const {root, workspaces} = releaseFixture(t, {config: stablePolicy}); for (const changed of [core, lists, '@react-native/codegen', 'react-native-macos-init']) { writeFileSync(join(root, '.changeset/fix.md'), `---\n"${changed}": patch\n---\n\nFix package.\n`); const releases = (await getReleasePlan(root)).releases.map(pkg => [pkg.name, pkg.newVersion]).sort(); @@ -641,6 +699,23 @@ test('every changed public package needs its own new nonempty version section', } }); +test('reconstructed HTML comments are not release evidence, but visible notes remain valid', async t => { + for (const comment of [ + '', + '<!-- hidden -->', + '<!-- hidden -->', + '<<!---->!-- hidden -->', + ]) { + for (const notes of ['', '- Visible release note.']) { + const fixture = preparedFixture(t, {editHead: ({writeChangelog}) => { + writeChangelog(0, `# Changelog\n\n## 0.83.2\n\n${comment}\n${notes}\n`); + }}); + if (notes) assert.equal(await fixture.validate(), true); + else await assert.rejects(fixture.validate(), /Missing nonempty new changelog section/); + } + } +}); + test('a new changelog file is valid, but an absent base manifest is not a version transition', async t => { const fixture = preparedFixture(t, {editBase: ({root}) => { for (const index of [0, 1]) rmSync(join(root, `packages/p${index}/CHANGELOG.md`)); diff --git a/.github/scripts/__tests__/publishing-workflow.test.mjs b/.github/scripts/__tests__/publishing-workflow.test.mjs index 53b119a4fc18..893d2ab7a1b6 100644 --- a/.github/scripts/__tests__/publishing-workflow.test.mjs +++ b/.github/scripts/__tests__/publishing-workflow.test.mjs @@ -1,15 +1,16 @@ import assert from 'node:assert/strict'; import {execFileSync, fork} from 'node:child_process'; import {once} from 'node:events'; -import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; import {createRequire} from 'node:module'; import {tmpdir} from 'node:os'; import {dirname, join, resolve} from 'node:path'; import {test} from 'node:test'; +import {stripVTControlCharacters} from 'node:util'; const require = createRequire(import.meta.url); const {load} = createRequire(require.resolve('eslint'))('js-yaml'); -// An optional root lets the shared test check an equivalent release worktree. +// An optional trusted root lets the shared test execute an equivalent release worktree's workflows. const repositoryRoot = resolve(process.env.PUBLISH_WORKFLOW_ROOT ?? new URL('../../../', import.meta.url).pathname); const yarnPath = join(repositoryRoot, '.yarn/releases/yarn-4.12.0.cjs'); const core = 'react-native-macos'; @@ -18,7 +19,7 @@ const workflow = name => load(readFileSync(join(repositoryRoot, `.github/workflo const publishSteps = workflow('microsoft-npm-publish').jobs.publish.steps; const dryRunSteps = workflow('microsoft-pr').jobs['npm-publish-dry-run'].steps; -async function fixture(t, {eligible = '1', fail = '', privateLists = false} = {}) { +async function fixture(t, {eligible = '1', fail = '', privateLists = false, enableColors} = {}) { const root = mkdtempSync(join(tmpdir(), 'rnm-publish-workflow-')); t.after(() => rmSync(root, {recursive: true, force: true})); const write = (path, contents) => { @@ -114,9 +115,11 @@ async function fixture(t, {eligible = '1', fail = '', privateLists = false} = {} YARN_ENABLE_HARDENED_MODE: '0', YARN_NPM_AUTH_TOKEN: 'fixture-only', YARN_NPM_REGISTRY_SERVER: `http://127.0.0.1:${port}`, YARN_NPM_PUBLISH_REGISTRY: `http://127.0.0.1:${port}`, + ...(enableColors === undefined ? {} : {FORCE_COLOR: enableColors, YARN_ENABLE_COLORS: enableColors}), }; const run = command => execFileSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', - `yarn() { ${JSON.stringify(process.execPath)} ${JSON.stringify(yarnPath)} "$@"; }\n${command}`], + `node_path=$1\nyarn_path=$2\nyarn() { "$node_path" "$yarn_path" "$@"; }\n${command}`, + 'publishing-workflow', process.execPath, yarnPath], {cwd: root, env, encoding: 'utf8', stdio: 'pipe'}); run('yarn install'); const events = () => existsSync(join(root, 'events')) ? readFileSync(join(root, 'events'), 'utf8').trim().split('\n') : []; @@ -127,12 +130,12 @@ async function fixture(t, {eligible = '1', fail = '', privateLists = false} = {} assert.ok(existsSync(env.GITHUB_OUTPUT), 'Preparation ran before eligibility'); if (!readFileSync(env.GITHUB_OUTPUT, 'utf8').includes('publish_react_native_macos=1\n')) continue; } - const output = run(step.run); + const output = stripVTControlCharacters(run(step.run)); if (step.run.includes('npm publish')) { assert.match(step.run, /--dry-run\b/); for (const name of privateLists ? [core] : [lists, core]) { assert.ok(output.includes(`[${name}]: ➤ YN0000: types_generated/index.d.ts`), - `Dry-run package omitted generated types: ${name}`); + `Dry-run package omitted generated types: ${name}\n${output}`); } } } @@ -169,11 +172,35 @@ test('build and snapshot validation failures stop release and dry-run publicatio }); test('PR dry run packs only public coupled workspaces with generated types from a clean fixture', async t => { - for (const privateLists of [false, true]) { - const f = await fixture(t, {privateLists}); - f.execute(dryRunPreparation); - assert.deepEqual(f.events(), ['tooling', 'codegen', 'types', - ...(privateLists ? [] : ['pack lists']), 'pack core']); - assert.equal(readFileSync(join(f.root, 'snapshot'), 'utf8'), 'checked-in API\n'); + for (const enableColors of ['0', '1']) { + for (const privateLists of [false, true]) { + const f = await fixture(t, {privateLists, enableColors}); + f.execute(dryRunPreparation); + assert.deepEqual(f.events(), ['tooling', 'codegen', 'types', + ...(privateLists ? [] : ['pack lists']), 'pack core']); + assert.equal(readFileSync(join(f.root, 'snapshot'), 'utf8'), 'checked-in API\n'); + } } }); + +test('PR dry run rejects generated types omitted from the package file list', async t => { + const f = await fixture(t, {enableColors: '1'}); + const path = join(f.root, 'packages/lists/package.json'); + const pkg = JSON.parse(readFileSync(path, 'utf8')); + writeFileSync(path, JSON.stringify({...pkg, files: ['package.json']})); + assert.throws(() => f.execute(dryRunPreparation), /Dry-run package omitted generated types/); +}); + +test('workflow root paths with shell syntax remain literal arguments', t => { + const root = mkdtempSync(join(tmpdir(), 'rnm-workflow-path-')); + t.after(() => rmSync(root, {recursive: true, force: true})); + const literalRoot = join(root, 'repository with spaces \' " $HOME $(exit 97) `exit 98`'); + symlinkSync(repositoryRoot, literalRoot, 'dir'); + const env = {...process.env, PUBLISH_WORKFLOW_ROOT: literalRoot}; + delete env.NODE_TEST_CONTEXT; + const output = execFileSync(process.execPath, ['--test', '--test-reporter=tap', '--test-name-pattern=^ineligible publication', + new URL(import.meta.url).pathname], { + env, encoding: 'utf8', stdio: 'pipe', + }); + assert.match(output, /# pass 1\b/); +}); diff --git a/.github/scripts/publishing-contract.md b/.github/scripts/publishing-contract.md index 7dacacb1cfc0..43cd64cbc27c 100644 --- a/.github/scripts/publishing-contract.md +++ b/.github/scripts/publishing-contract.md @@ -14,6 +14,8 @@ Pending Changesets come from the declared `@changesets/get-release-plan` API, wi Stable branches must already have their initial release version and React Native peer configured. The publication script does not turn `1000.0.0` into a release. All public release packages must match the core version and branch. Private runtime workspace links are invalid; explicit registry references to the separately published upstream `@react-native/*` packages are valid. Development-only private workspace links are allowed. +Main and merge-stage branches keep the `1000.0.0` development graph, including private virtualized-lists and upstream workspace links. Their Changesets config defers `react-native-macos` with `ignore`; `@react-native/tester` is also listed for compatibility with older Changesets' dependent validation. This preserves pending core changesets until stable preparation, without changing package privacy or enabling private versions or tags. The independent init package remains versionable. Stable preparation must clear `ignore`, set the stable `baseBranch`, make virtualized-lists public, and configure the release versions and upstream registry dependencies before versioning. The repository-graph test checks these branch-specific source settings. + ## Publication and tags Any pending Changeset, including an empty Changeset, skips publication. Registry failures fail the run. The script validates the complete package graph and queries every selected package before publication. It publishes only absent versions in dependency order, so retries skip versions already published. A release consists of multiple npm writes, not an atomic registry transaction. diff --git a/.github/scripts/publishing-contract.mjs b/.github/scripts/publishing-contract.mjs index 70b166439987..b72da982723e 100644 --- a/.github/scripts/publishing-contract.mjs +++ b/.github/scripts/publishing-contract.mjs @@ -118,8 +118,12 @@ function changelogSection(changelog, version) { if (!matches.length) return undefined; const heading = matches[0]; const next = headings[headings.indexOf(heading) + 1]; - return changelog.slice(heading.index + heading[0].length, next?.index) - .replace(//g, '').replace(/^#{1,6} .+$/gm, '').trim(); + let section = changelog.slice(heading.index + heading[0].length, next?.index); + // Removing a comment can reconstruct another comment delimiter. + while (//.test(section)) { + section = section.replace(//g, ''); + } + return section.replace(/^#{1,6} .+$/gm, '').trim(); } // A consumed Changeset is valid only when the PR contains the complete release diff --git a/.github/workflows/microsoft-build-rntester.yml b/.github/workflows/microsoft-build-rntester.yml index e78f4669dcc2..67838728491d 100644 --- a/.github/workflows/microsoft-build-rntester.yml +++ b/.github/workflows/microsoft-build-rntester.yml @@ -60,6 +60,10 @@ jobs: continue-on-error: ${{ matrix.linkage == 'dynamic' }} env: RCT_NEW_ARCH_ENABLED: '1' + # Maven's upstream binaries only contain iOS slices. Build this fork's + # core and dependencies for each destination in the RNTester matrix. + RCT_USE_PREBUILT_RNCORE: '0' + RCT_USE_RN_DEP: '0' USE_FRAMEWORKS: ${{ matrix.use_frameworks }} run: | set -eox pipefail diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index 54dad415a991..a3cfbed62d7a 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -3,7 +3,7 @@ name: PR on: pull_request: types: [opened, synchronize, edited] - branches: [ "main", "*-stable", "release/*", "*-merge" ] + branches: [ "main", "*-stable", "release/*", "*-merge", "review/**", "saadnajmi/0-85-redbox2-merge" ] concurrency: # Ensure single build of a pull request. `main` should not be affected. diff --git a/.github/workflows/microsoft-react-native-test-app-integration.yml b/.github/workflows/microsoft-react-native-test-app-integration.yml index 1d0995d371fc..d81d889453c8 100644 --- a/.github/workflows/microsoft-react-native-test-app-integration.yml +++ b/.github/workflows/microsoft-react-native-test-app-integration.yml @@ -49,6 +49,7 @@ jobs: - name: Clone react-native-test-app run: | git clone --filter=blob:none --progress https://github.com/microsoft/react-native-test-app.git + git -C react-native-test-app rev-parse HEAD - name: Configure react-native-test-app dependencies working-directory: react-native-test-app/packages/example-macos diff --git a/.yarnrc.yml b/.yarnrc.yml index 2fb7124f02d3..3fc29bf50556 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -13,9 +13,6 @@ yarnPath: .yarn/releases/yarn-4.12.0.cjs # packageExtensions, so no @react-native/* deps belong here (they must stay in a # manifest to remain under release-branch version-pinning). packageExtensions: - "@react-native/codegen@*": - dependencies: - "@babel/parser": "^7.25.2" "@react-native/babel-plugin-codegen@*": dependencies: "@babel/plugin-syntax-flow": "^7.25.0" diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index dacb6f8beeef..a6958a8a7889 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -421,15 +421,10 @@ let reactCore = RNTarget( ) /// React-Fabric.podspec -// [macOS -#if os(macOS) -let reactFabricViewPlatformSources = ["components/view/platform/macos"] -let reactFabricViewPlatformExcludes = ["components/view/platform/cxx"] -#else -let reactFabricViewPlatformSources = ["components/view/platform/cxx"] -let reactFabricViewPlatformExcludes = ["components/view/platform/macos"] -#endif -// macOS] +// [macOS] Compile the guarded macOS implementations through components/view. +// The implementations use TargetConditionals for the build destination. +// Do not add a platform directory to sources: RNTarget also adds it to the header +// search paths, bypassing those dispatch headers when cross-compiling. let reactFabric = RNTarget( name: .reactFabric, path: "ReactCommon/react/renderer", @@ -465,9 +460,9 @@ let reactFabric = RNTarget( "components/virtualview", "components/virtualviewexperimental", "components/root/tests", - ] + reactFabricViewPlatformExcludes, // [macOS] + ], dependencies: [.reactNativeDependencies, .reactJsiExecutor, .rctTypesafety, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .reactRendererDebug, .reactGraphics, .yoga], - sources: ["animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/scrollview", "components/scrollview/platform/cxx", "components/legacyviewmanagerinterop", "dom", "scheduler", "mounting", "observers/events", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency"] + reactFabricViewPlatformSources // [macOS] + sources: ["animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/scrollview", "components/scrollview/platform/cxx", "components/legacyviewmanagerinterop", "dom", "scheduler", "mounting", "observers/events", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency"] ) let reactFabricInputAccessory = RNTarget( @@ -933,7 +928,12 @@ extension Target { let numOfSlash = path.count { $0 == "/" } let cxxCommonHeaderPaths: [CXXSetting] = - Set(searchPaths).map { + // [macOS] Select headers by destination, before the shared/generated paths. + // SwiftPM evaluates manifest #if os(...) on the host, not the destination. + [ + CXXSetting.headerSearchPath(relativeSearchPath(numOfSlash + 1, "ReactCommon/react/renderer/components/view/platform/macos"), .when(platforms: [.macOS])), + CXXSetting.headerSearchPath(relativeSearchPath(numOfSlash + 1, "ReactCommon/react/renderer/components/view/platform/cxx"), .when(platforms: [.iOS, .visionOS, .macCatalyst])), + ] + Set(searchPaths).map { CXXSetting.headerSearchPath(relativeSearchPath(numOfSlash + 1, $0)) } + [ CXXSetting.headerSearchPath(relativeSearchPath(numOfSlash + 1, ".build/headers")), diff --git a/yarn.lock b/yarn.lock index 6e6807194fd3..37769e1943d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2282,7 +2282,7 @@ __metadata: glob: "npm:^7.1.6" minimatch: "npm:^3.0.4" bin: - asar: bin/asar.js + asar: ./bin/asar.js checksum: 10c0/9df7983125faaa29c266e4beec6ceb205e139ede0e8fb81dde84c73ac8114388a99aad21379412a972163d8879ca959621f4e4896214bf8d296ba217e7cf8170 languageName: node linkType: hard @@ -3266,7 +3266,7 @@ __metadata: source-map: "npm:~0.6.1" typescript: "npm:5.8.2" bin: - api-extractor: bin/api-extractor + api-extractor: ./bin/api-extractor checksum: 10c0/060f7231b388326a39df0bfcfce026164fc17b6afc83aeb4bb7535432085a8b01e2df792f03326d4673a32e3fc94498efce6031dec2d27ca32ab4da0ea3e7905 languageName: node linkType: hard @@ -6879,7 +6879,7 @@ __metadata: jest-util: "npm:^29.7.0" prompts: "npm:^2.0.1" bin: - create-jest: bin/create-jest.js + create-jest: ./bin/create-jest.js checksum: 10c0/e7e54c280692470d3398f62a6238fd396327e01c6a0757002833f06d00afc62dd7bfe04ff2b9cd145264460e6b4d1eb8386f2925b7e567f97939843b7b0e812f languageName: node linkType: hard @@ -7832,7 +7832,7 @@ __metadata: strip-ansi: "npm:^6.0.1" text-table: "npm:^0.2.0" bin: - eslint: bin/eslint.js + eslint: ./bin/eslint.js checksum: 10c0/00bb96fd2471039a312435a6776fe1fd557c056755eaa2b96093ef3a8508c92c8775d5f754768be6b1dddd09fdd3379ddb231eeb9b6c579ee17ea7d68000a529 languageName: node linkType: hard @@ -8074,7 +8074,7 @@ __metadata: dependencies: strnum: "npm:^1.0.5" bin: - fxparser: src/cli/cli.js + fxparser: ./src/cli/cli.js checksum: 10c0/7989148650fc1fce988798b62467f7dee0fd5a7ad049373e00e65fbc68f689c119975d03da32c427f0a1f5aad01de2efbd783a48dcdaf3ca41817a8b161ad3e8 languageName: node linkType: hard @@ -8323,8 +8323,8 @@ __metadata: pirates: "npm:^3.0.2" vlq: "npm:^0.2.1" bin: - flow-node: flow-node - flow-remove-types: flow-remove-types + flow-node: ./flow-node + flow-remove-types: ./flow-remove-types checksum: 10c0/d6ac127799c4e59edf21503a2e81db8f3be4cafe6f3a066f2b3f41bd8f457d06321987f423c636046f6f58189d4630e73b0b187326ef510c164813e364acca66 languageName: node linkType: hard @@ -8654,7 +8654,7 @@ __metadata: package-json-from-dist: "npm:^1.0.0" path-scurry: "npm:^1.11.1" bin: - glob: dist/esm/bin.mjs + glob: ./dist/esm/bin.mjs checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 languageName: node linkType: hard @@ -9937,7 +9937,7 @@ __metadata: node-notifier: optional: true bin: - jest: bin/jest.js + jest: ./bin/jest.js checksum: 10c0/a658fd55050d4075d65c1066364595962ead7661711495cfa1dfeecf3d6d0a8ffec532f3dbd8afbb3e172dd5fd2fb2e813c5e10256e7cf2fea766314942fb43a languageName: node linkType: hard @@ -10323,7 +10323,7 @@ __metadata: node-notifier: optional: true bin: - jest: bin/jest.js + jest: ./bin/jest.js checksum: 10c0/f40eb8171cf147c617cc6ada49d062fbb03b4da666cb8d39cdbfb739a7d75eea4c3ca150fb072d0d273dce0c753db4d0467d54906ad0293f59c54f9db4a09d8b languageName: node linkType: hard @@ -11618,7 +11618,7 @@ __metadata: source-map: "npm:^0.5.6" vlq: "npm:^1.0.0" bin: - metro-symbolicate: src/index.js + metro-symbolicate: ./src/index.js checksum: 10c0/39c53b878ae9392586e23ff3a8071eceb1feed2d226e3ac9a170eb6bcd46fe6b69b8204851ee8eb231fdc3eac9012af3c6940ad48f6d1c04810ea9c4a75e1c7c languageName: node linkType: hard @@ -11634,7 +11634,7 @@ __metadata: source-map: "npm:^0.5.6" vlq: "npm:^1.0.0" bin: - metro-symbolicate: src/index.js + metro-symbolicate: ./src/index.js checksum: 10c0/b4347222cc2f0ddbb6a7d79876aa1ee136ad7bbab450b2127c4f60b8700371afcbcfe66073bf4376cc4eae034c448431a0bf957df9c52efc3a5a9dc558a53099 languageName: node linkType: hard @@ -12474,7 +12474,7 @@ __metadata: tar: "npm:^6.1.2" which: "npm:^4.0.0" bin: - node-gyp: bin/node-gyp.js + node-gyp: ./bin/node-gyp.js checksum: 10c0/abddfff7d873312e4ed4a5fb75ce893a5c4fb69e7fcb1dfa71c28a6b92a7f1ef6b62790dffb39181b5a82728ba8f2f32d229cf8cbe66769fe02cea7db4a555aa languageName: node linkType: hard @@ -12991,7 +12991,7 @@ __metadata: version: 1.0.3 resolution: "parse-github-url@npm:1.0.3" bin: - parse-github-url: cli.js + parse-github-url: ./cli.js checksum: 10c0/8a56103f0cdb6f9bd0ffcd7fd4fe1404a414f18441c4d89ab9d9c5eca3b43d6f7cdb899cb979f061df9d8a85d5af275cab05beff953b07f2ff65a6c2826b9293 languageName: node linkType: hard @@ -13221,7 +13221,7 @@ __metadata: dependencies: commander: "npm:^9.4.0" bin: - postject: dist/cli.js + postject: ./dist/cli.js checksum: 10c0/7d5c5ffdb63190d48cc4b4b617ef2fa144f2bfd5f035231af08a8d7465298ed2a08ee4b354df198b4c96b32d3d873e562b6d23caacd80eb0ec722d81381e4a3e languageName: node linkType: hard @@ -13246,7 +13246,7 @@ __metadata: version: 3.6.2 resolution: "prettier@npm:3.6.2" bin: - prettier: bin/prettier.cjs + prettier: ./bin/prettier.cjs checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812 languageName: node linkType: hard @@ -13255,7 +13255,7 @@ __metadata: version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: - prettier: bin-prettier.js + prettier: ./bin-prettier.js checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a languageName: node linkType: hard @@ -13278,7 +13278,7 @@ __metadata: colors: "npm:1.4.0" minimist: "npm:^1.2.0" bin: - prettyjson: bin/prettyjson + prettyjson: ./bin/prettyjson checksum: 10c0/94ea84205fc5103e32d562f515631c22440f7bcf4de5f5687522692e3f270bf4f450170857e098926adaec1b4ef33c9a8c97ae8911079a50fe7f584dd9ae5058 languageName: node linkType: hard @@ -13829,7 +13829,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: bin/resolve + resolve: ./bin/resolve checksum: 10c0/07e179f4375e1fd072cfb72ad66d78547f86e6196c4014b31cb0b8bb1db5f7ca871f922d08da0fbc05b94e9fd42206f819648fa3b5b873ebbc8e1dc68fec433a languageName: node linkType: hard @@ -13842,7 +13842,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: bin/resolve + resolve: ./bin/resolve checksum: 10c0/f657191507530f2cbecb5815b1ee99b20741ea6ee02a59c57028e9ec4c2c8d7681afcc35febbd554ac0ded459db6f2d8153382c53a2f266cee2575e512674409 languageName: node linkType: hard @@ -13855,7 +13855,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: bin/resolve + resolve: ./bin/resolve checksum: 10c0/a6c33555e3482ea2ec4c6e3d3bf0d78128abf69dca99ae468e64f1e30acaa318fd267fb66c8836b04d558d3e2d6ed875fe388067e7d8e0de647d3c21af21c43a languageName: node linkType: hard @@ -13868,7 +13868,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: bin/resolve + resolve: ./bin/resolve checksum: 10c0/0446f024439cd2e50c6c8fa8ba77eaa8370b4180f401a96abf3d1ebc770ac51c1955e12764cde449fde3fff480a61f84388e3505ecdbab778f4bef5f8212c729 languageName: node linkType: hard @@ -13881,7 +13881,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: bin/resolve + resolve: ./bin/resolve checksum: 10c0/ee5b182f2e37cb1165465e58c6abc797fec0a80b5ba3231607beb4677db0c9291ac010c47cf092b6daa2b7f518d69a0e21888e7e2b633f68d501a874212a8c63 languageName: node linkType: hard @@ -13894,7 +13894,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: bin/resolve + resolve: ./bin/resolve checksum: 10c0/78ad6edb8309a2bfb720c2c1898f7907a37f858866ce11a5974643af1203a6a6e05b2fa9c53d8064a673a447b83d42569260c306d43628bff5bb101969708355 languageName: node linkType: hard @@ -13955,7 +13955,7 @@ __metadata: dependencies: glob: "npm:^7.1.3" bin: - rimraf: bin.js + rimraf: ./bin.js checksum: 10c0/9cb7757acb489bd83757ba1a274ab545eafd75598a9d817e0c3f8b164238dd90eba50d6b848bd4dcc5f3040912e882dc7ba71653e35af660d77b25c381d402e8 languageName: node linkType: hard @@ -14082,7 +14082,7 @@ __metadata: version: 5.7.2 resolution: "semver@npm:5.7.2" bin: - semver: bin/semver + semver: ./bin/semver checksum: 10c0/e4cf10f86f168db772ae95d86ba65b3fd6c5967c94d97c708ccb463b778c2ee53b914cd7167620950fc07faf5a564e6efe903836639e512a1aa15fbc9667fa25 languageName: node linkType: hard @@ -14091,7 +14091,7 @@ __metadata: version: 6.3.1 resolution: "semver@npm:6.3.1" bin: - semver: bin/semver.js + semver: ./bin/semver.js checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d languageName: node linkType: hard @@ -14263,7 +14263,7 @@ __metadata: interpret: "npm:^1.0.0" rechoir: "npm:^0.6.2" bin: - shjs: bin/shjs + shjs: ./bin/shjs checksum: 10c0/feb25289a12e4bcd04c40ddfab51aff98a3729f5c2602d5b1a1b95f6819ec7804ac8147ebd8d9a85dfab69d501bcf92d7acef03247320f51c1552cec8d8e2382 languageName: node linkType: hard @@ -15140,8 +15140,8 @@ __metadata: version: 5.3.2 resolution: "typescript@npm:5.3.2" bin: - tsc: bin/tsc - tsserver: bin/tsserver + tsc: ./bin/tsc + tsserver: ./bin/tsserver checksum: 10c0/d7dbe1fbe19039e36a65468ea64b5d338c976550394ba576b7af9c68ed40c0bc5d12ecce390e4b94b287a09a71bd3229f19c2d5680611f35b7c53a3898791159 languageName: node linkType: hard @@ -15150,8 +15150,8 @@ __metadata: version: 5.8.2 resolution: "typescript@npm:5.8.2" bin: - tsc: bin/tsc - tsserver: bin/tsserver + tsc: ./bin/tsc + tsserver: ./bin/tsserver checksum: 10c0/5c4f6fbf1c6389b6928fe7b8fcd5dc73bb2d58cd4e3883f1d774ed5bd83b151cbac6b7ecf11723de56d4676daeba8713894b1e9af56174f2f9780ae7848ec3c6 languageName: node linkType: hard @@ -15160,8 +15160,8 @@ __metadata: version: 5.8.3 resolution: "typescript@npm:5.8.3" bin: - tsc: bin/tsc - tsserver: bin/tsserver + tsc: ./bin/tsc + tsserver: ./bin/tsserver checksum: 10c0/5f8bb01196e542e64d44db3d16ee0e4063ce4f3e3966df6005f2588e86d91c03e1fb131c2581baf0fb65ee79669eea6e161cd448178986587e9f6844446dbb48 languageName: node linkType: hard @@ -15170,8 +15170,8 @@ __metadata: version: 5.6.3 resolution: "typescript@npm:5.6.3" bin: - tsc: bin/tsc - tsserver: bin/tsserver + tsc: ./bin/tsc + tsserver: ./bin/tsserver checksum: 10c0/44f61d3fb15c35359bc60399cb8127c30bae554cd555b8e2b46d68fa79d680354b83320ad419ff1b81a0bdf324197b29affe6cc28988cd6a74d4ac60c94f9799 languageName: node linkType: hard @@ -15180,8 +15180,8 @@ __metadata: version: 5.3.2 resolution: "typescript@patch:typescript@npm%3A5.3.2#optional!builtin::version=5.3.2&hash=e012d7" bin: - tsc: bin/tsc - tsserver: bin/tsserver + tsc: ./bin/tsc + tsserver: ./bin/tsserver checksum: 10c0/73c8bad74e732d93211c9d77f28b03307e2f5fc6a0afc73f4b783261ab567686a16d6ae958bdaef383a00be1b0b8c8b6741dd6ca3d13af4963fa7e47456d49c7 languageName: node linkType: hard @@ -15190,8 +15190,8 @@ __metadata: version: 5.8.2 resolution: "typescript@patch:typescript@npm%3A5.8.2#optional!builtin::version=5.8.2&hash=5786d5" bin: - tsc: bin/tsc - tsserver: bin/tsserver + tsc: ./bin/tsc + tsserver: ./bin/tsserver checksum: 10c0/5448a08e595cc558ab321e49d4cac64fb43d1fa106584f6ff9a8d8e592111b373a995a1d5c7f3046211c8a37201eb6d0f1566f15cdb7a62a5e3be01d087848e2 languageName: node linkType: hard @@ -15200,8 +15200,8 @@ __metadata: version: 5.8.3 resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" bin: - tsc: bin/tsc - tsserver: bin/tsserver + tsc: ./bin/tsc + tsserver: ./bin/tsserver checksum: 10c0/39117e346ff8ebd87ae1510b3a77d5d92dae5a89bde588c747d25da5c146603a99c8ee588c7ef80faaf123d89ed46f6dbd918d534d641083177d5fac38b8a1cb languageName: node linkType: hard @@ -15210,8 +15210,8 @@ __metadata: version: 5.6.3 resolution: "typescript@patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40" bin: - tsc: bin/tsc - tsserver: bin/tsserver + tsc: ./bin/tsc + tsserver: ./bin/tsserver checksum: 10c0/7c9d2e07c81226d60435939618c91ec2ff0b75fbfa106eec3430f0fcf93a584bc6c73176676f532d78c3594fe28a54b36eb40b3d75593071a7ec91301533ace7 languageName: node linkType: hard @@ -15393,7 +15393,7 @@ __metadata: version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: - uuid: dist/bin/uuid + uuid: ./dist/bin/uuid checksum: 10c0/bcbb807a917d374a49f475fae2e87fdca7da5e5530820ef53f65ba1d12131bd81a92ecf259cc7ce317cbe0f289e7d79fdfebcef9bfa3087c8c8a2fa304c9be54 languageName: node linkType: hard @@ -15596,7 +15596,7 @@ __metadata: dependencies: isexe: "npm:^3.1.1" bin: - node-which: bin/which.js + node-which: ./bin/which.js checksum: 10c0/449fa5c44ed120ccecfe18c433296a4978a7583bf2391c50abce13f76878d2476defde04d0f79db8165bdf432853c1f8389d0485ca6e8ebce3bbcded513d5e6a languageName: node linkType: hard @@ -15742,7 +15742,7 @@ __metadata: version: 2.8.2 resolution: "yaml@npm:2.8.2" bin: - yaml: bin.mjs + yaml: ./bin.mjs checksum: 10c0/703e4dc1e34b324aa66876d63618dcacb9ed49f7e7fe9b70f1e703645be8d640f68ab84f12b86df8ac960bac37acf5513e115de7c970940617ce0343c8c9cd96 languageName: node linkType: hard @@ -15834,7 +15834,7 @@ __metadata: commander: optional: true bin: - z-schema: bin/z-schema + z-schema: ./bin/z-schema checksum: 10c0/3242da6b2d8da3bc9a66876ef01a1d5f0d0ad7bd70b0e3e24f5dc6ef5f6213e6e660f14f3dceee9b000692a47b86b365c0ea43b5340153efcb2808ccbfb3fc6f languageName: node linkType: hard From 9b59f96da1144c7ada28026a8d5521c52766c8e9 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 22 Sep 2026 12:06:30 -0700 Subject: [PATCH 35/38] fix(ci): align lock metadata with the public registry Preserve package versions and the linear stack; change only equivalent executable-path metadata. --- yarn.lock | 96 +++++++++++++++++++++++++++---------------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/yarn.lock b/yarn.lock index 37769e1943d0..6e6807194fd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2282,7 +2282,7 @@ __metadata: glob: "npm:^7.1.6" minimatch: "npm:^3.0.4" bin: - asar: ./bin/asar.js + asar: bin/asar.js checksum: 10c0/9df7983125faaa29c266e4beec6ceb205e139ede0e8fb81dde84c73ac8114388a99aad21379412a972163d8879ca959621f4e4896214bf8d296ba217e7cf8170 languageName: node linkType: hard @@ -3266,7 +3266,7 @@ __metadata: source-map: "npm:~0.6.1" typescript: "npm:5.8.2" bin: - api-extractor: ./bin/api-extractor + api-extractor: bin/api-extractor checksum: 10c0/060f7231b388326a39df0bfcfce026164fc17b6afc83aeb4bb7535432085a8b01e2df792f03326d4673a32e3fc94498efce6031dec2d27ca32ab4da0ea3e7905 languageName: node linkType: hard @@ -6879,7 +6879,7 @@ __metadata: jest-util: "npm:^29.7.0" prompts: "npm:^2.0.1" bin: - create-jest: ./bin/create-jest.js + create-jest: bin/create-jest.js checksum: 10c0/e7e54c280692470d3398f62a6238fd396327e01c6a0757002833f06d00afc62dd7bfe04ff2b9cd145264460e6b4d1eb8386f2925b7e567f97939843b7b0e812f languageName: node linkType: hard @@ -7832,7 +7832,7 @@ __metadata: strip-ansi: "npm:^6.0.1" text-table: "npm:^0.2.0" bin: - eslint: ./bin/eslint.js + eslint: bin/eslint.js checksum: 10c0/00bb96fd2471039a312435a6776fe1fd557c056755eaa2b96093ef3a8508c92c8775d5f754768be6b1dddd09fdd3379ddb231eeb9b6c579ee17ea7d68000a529 languageName: node linkType: hard @@ -8074,7 +8074,7 @@ __metadata: dependencies: strnum: "npm:^1.0.5" bin: - fxparser: ./src/cli/cli.js + fxparser: src/cli/cli.js checksum: 10c0/7989148650fc1fce988798b62467f7dee0fd5a7ad049373e00e65fbc68f689c119975d03da32c427f0a1f5aad01de2efbd783a48dcdaf3ca41817a8b161ad3e8 languageName: node linkType: hard @@ -8323,8 +8323,8 @@ __metadata: pirates: "npm:^3.0.2" vlq: "npm:^0.2.1" bin: - flow-node: ./flow-node - flow-remove-types: ./flow-remove-types + flow-node: flow-node + flow-remove-types: flow-remove-types checksum: 10c0/d6ac127799c4e59edf21503a2e81db8f3be4cafe6f3a066f2b3f41bd8f457d06321987f423c636046f6f58189d4630e73b0b187326ef510c164813e364acca66 languageName: node linkType: hard @@ -8654,7 +8654,7 @@ __metadata: package-json-from-dist: "npm:^1.0.0" path-scurry: "npm:^1.11.1" bin: - glob: ./dist/esm/bin.mjs + glob: dist/esm/bin.mjs checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 languageName: node linkType: hard @@ -9937,7 +9937,7 @@ __metadata: node-notifier: optional: true bin: - jest: ./bin/jest.js + jest: bin/jest.js checksum: 10c0/a658fd55050d4075d65c1066364595962ead7661711495cfa1dfeecf3d6d0a8ffec532f3dbd8afbb3e172dd5fd2fb2e813c5e10256e7cf2fea766314942fb43a languageName: node linkType: hard @@ -10323,7 +10323,7 @@ __metadata: node-notifier: optional: true bin: - jest: ./bin/jest.js + jest: bin/jest.js checksum: 10c0/f40eb8171cf147c617cc6ada49d062fbb03b4da666cb8d39cdbfb739a7d75eea4c3ca150fb072d0d273dce0c753db4d0467d54906ad0293f59c54f9db4a09d8b languageName: node linkType: hard @@ -11618,7 +11618,7 @@ __metadata: source-map: "npm:^0.5.6" vlq: "npm:^1.0.0" bin: - metro-symbolicate: ./src/index.js + metro-symbolicate: src/index.js checksum: 10c0/39c53b878ae9392586e23ff3a8071eceb1feed2d226e3ac9a170eb6bcd46fe6b69b8204851ee8eb231fdc3eac9012af3c6940ad48f6d1c04810ea9c4a75e1c7c languageName: node linkType: hard @@ -11634,7 +11634,7 @@ __metadata: source-map: "npm:^0.5.6" vlq: "npm:^1.0.0" bin: - metro-symbolicate: ./src/index.js + metro-symbolicate: src/index.js checksum: 10c0/b4347222cc2f0ddbb6a7d79876aa1ee136ad7bbab450b2127c4f60b8700371afcbcfe66073bf4376cc4eae034c448431a0bf957df9c52efc3a5a9dc558a53099 languageName: node linkType: hard @@ -12474,7 +12474,7 @@ __metadata: tar: "npm:^6.1.2" which: "npm:^4.0.0" bin: - node-gyp: ./bin/node-gyp.js + node-gyp: bin/node-gyp.js checksum: 10c0/abddfff7d873312e4ed4a5fb75ce893a5c4fb69e7fcb1dfa71c28a6b92a7f1ef6b62790dffb39181b5a82728ba8f2f32d229cf8cbe66769fe02cea7db4a555aa languageName: node linkType: hard @@ -12991,7 +12991,7 @@ __metadata: version: 1.0.3 resolution: "parse-github-url@npm:1.0.3" bin: - parse-github-url: ./cli.js + parse-github-url: cli.js checksum: 10c0/8a56103f0cdb6f9bd0ffcd7fd4fe1404a414f18441c4d89ab9d9c5eca3b43d6f7cdb899cb979f061df9d8a85d5af275cab05beff953b07f2ff65a6c2826b9293 languageName: node linkType: hard @@ -13221,7 +13221,7 @@ __metadata: dependencies: commander: "npm:^9.4.0" bin: - postject: ./dist/cli.js + postject: dist/cli.js checksum: 10c0/7d5c5ffdb63190d48cc4b4b617ef2fa144f2bfd5f035231af08a8d7465298ed2a08ee4b354df198b4c96b32d3d873e562b6d23caacd80eb0ec722d81381e4a3e languageName: node linkType: hard @@ -13246,7 +13246,7 @@ __metadata: version: 3.6.2 resolution: "prettier@npm:3.6.2" bin: - prettier: ./bin/prettier.cjs + prettier: bin/prettier.cjs checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812 languageName: node linkType: hard @@ -13255,7 +13255,7 @@ __metadata: version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: - prettier: ./bin-prettier.js + prettier: bin-prettier.js checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a languageName: node linkType: hard @@ -13278,7 +13278,7 @@ __metadata: colors: "npm:1.4.0" minimist: "npm:^1.2.0" bin: - prettyjson: ./bin/prettyjson + prettyjson: bin/prettyjson checksum: 10c0/94ea84205fc5103e32d562f515631c22440f7bcf4de5f5687522692e3f270bf4f450170857e098926adaec1b4ef33c9a8c97ae8911079a50fe7f584dd9ae5058 languageName: node linkType: hard @@ -13829,7 +13829,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: ./bin/resolve + resolve: bin/resolve checksum: 10c0/07e179f4375e1fd072cfb72ad66d78547f86e6196c4014b31cb0b8bb1db5f7ca871f922d08da0fbc05b94e9fd42206f819648fa3b5b873ebbc8e1dc68fec433a languageName: node linkType: hard @@ -13842,7 +13842,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: ./bin/resolve + resolve: bin/resolve checksum: 10c0/f657191507530f2cbecb5815b1ee99b20741ea6ee02a59c57028e9ec4c2c8d7681afcc35febbd554ac0ded459db6f2d8153382c53a2f266cee2575e512674409 languageName: node linkType: hard @@ -13855,7 +13855,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: ./bin/resolve + resolve: bin/resolve checksum: 10c0/a6c33555e3482ea2ec4c6e3d3bf0d78128abf69dca99ae468e64f1e30acaa318fd267fb66c8836b04d558d3e2d6ed875fe388067e7d8e0de647d3c21af21c43a languageName: node linkType: hard @@ -13868,7 +13868,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: ./bin/resolve + resolve: bin/resolve checksum: 10c0/0446f024439cd2e50c6c8fa8ba77eaa8370b4180f401a96abf3d1ebc770ac51c1955e12764cde449fde3fff480a61f84388e3505ecdbab778f4bef5f8212c729 languageName: node linkType: hard @@ -13881,7 +13881,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: ./bin/resolve + resolve: bin/resolve checksum: 10c0/ee5b182f2e37cb1165465e58c6abc797fec0a80b5ba3231607beb4677db0c9291ac010c47cf092b6daa2b7f518d69a0e21888e7e2b633f68d501a874212a8c63 languageName: node linkType: hard @@ -13894,7 +13894,7 @@ __metadata: path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: - resolve: ./bin/resolve + resolve: bin/resolve checksum: 10c0/78ad6edb8309a2bfb720c2c1898f7907a37f858866ce11a5974643af1203a6a6e05b2fa9c53d8064a673a447b83d42569260c306d43628bff5bb101969708355 languageName: node linkType: hard @@ -13955,7 +13955,7 @@ __metadata: dependencies: glob: "npm:^7.1.3" bin: - rimraf: ./bin.js + rimraf: bin.js checksum: 10c0/9cb7757acb489bd83757ba1a274ab545eafd75598a9d817e0c3f8b164238dd90eba50d6b848bd4dcc5f3040912e882dc7ba71653e35af660d77b25c381d402e8 languageName: node linkType: hard @@ -14082,7 +14082,7 @@ __metadata: version: 5.7.2 resolution: "semver@npm:5.7.2" bin: - semver: ./bin/semver + semver: bin/semver checksum: 10c0/e4cf10f86f168db772ae95d86ba65b3fd6c5967c94d97c708ccb463b778c2ee53b914cd7167620950fc07faf5a564e6efe903836639e512a1aa15fbc9667fa25 languageName: node linkType: hard @@ -14091,7 +14091,7 @@ __metadata: version: 6.3.1 resolution: "semver@npm:6.3.1" bin: - semver: ./bin/semver.js + semver: bin/semver.js checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d languageName: node linkType: hard @@ -14263,7 +14263,7 @@ __metadata: interpret: "npm:^1.0.0" rechoir: "npm:^0.6.2" bin: - shjs: ./bin/shjs + shjs: bin/shjs checksum: 10c0/feb25289a12e4bcd04c40ddfab51aff98a3729f5c2602d5b1a1b95f6819ec7804ac8147ebd8d9a85dfab69d501bcf92d7acef03247320f51c1552cec8d8e2382 languageName: node linkType: hard @@ -15140,8 +15140,8 @@ __metadata: version: 5.3.2 resolution: "typescript@npm:5.3.2" bin: - tsc: ./bin/tsc - tsserver: ./bin/tsserver + tsc: bin/tsc + tsserver: bin/tsserver checksum: 10c0/d7dbe1fbe19039e36a65468ea64b5d338c976550394ba576b7af9c68ed40c0bc5d12ecce390e4b94b287a09a71bd3229f19c2d5680611f35b7c53a3898791159 languageName: node linkType: hard @@ -15150,8 +15150,8 @@ __metadata: version: 5.8.2 resolution: "typescript@npm:5.8.2" bin: - tsc: ./bin/tsc - tsserver: ./bin/tsserver + tsc: bin/tsc + tsserver: bin/tsserver checksum: 10c0/5c4f6fbf1c6389b6928fe7b8fcd5dc73bb2d58cd4e3883f1d774ed5bd83b151cbac6b7ecf11723de56d4676daeba8713894b1e9af56174f2f9780ae7848ec3c6 languageName: node linkType: hard @@ -15160,8 +15160,8 @@ __metadata: version: 5.8.3 resolution: "typescript@npm:5.8.3" bin: - tsc: ./bin/tsc - tsserver: ./bin/tsserver + tsc: bin/tsc + tsserver: bin/tsserver checksum: 10c0/5f8bb01196e542e64d44db3d16ee0e4063ce4f3e3966df6005f2588e86d91c03e1fb131c2581baf0fb65ee79669eea6e161cd448178986587e9f6844446dbb48 languageName: node linkType: hard @@ -15170,8 +15170,8 @@ __metadata: version: 5.6.3 resolution: "typescript@npm:5.6.3" bin: - tsc: ./bin/tsc - tsserver: ./bin/tsserver + tsc: bin/tsc + tsserver: bin/tsserver checksum: 10c0/44f61d3fb15c35359bc60399cb8127c30bae554cd555b8e2b46d68fa79d680354b83320ad419ff1b81a0bdf324197b29affe6cc28988cd6a74d4ac60c94f9799 languageName: node linkType: hard @@ -15180,8 +15180,8 @@ __metadata: version: 5.3.2 resolution: "typescript@patch:typescript@npm%3A5.3.2#optional!builtin::version=5.3.2&hash=e012d7" bin: - tsc: ./bin/tsc - tsserver: ./bin/tsserver + tsc: bin/tsc + tsserver: bin/tsserver checksum: 10c0/73c8bad74e732d93211c9d77f28b03307e2f5fc6a0afc73f4b783261ab567686a16d6ae958bdaef383a00be1b0b8c8b6741dd6ca3d13af4963fa7e47456d49c7 languageName: node linkType: hard @@ -15190,8 +15190,8 @@ __metadata: version: 5.8.2 resolution: "typescript@patch:typescript@npm%3A5.8.2#optional!builtin::version=5.8.2&hash=5786d5" bin: - tsc: ./bin/tsc - tsserver: ./bin/tsserver + tsc: bin/tsc + tsserver: bin/tsserver checksum: 10c0/5448a08e595cc558ab321e49d4cac64fb43d1fa106584f6ff9a8d8e592111b373a995a1d5c7f3046211c8a37201eb6d0f1566f15cdb7a62a5e3be01d087848e2 languageName: node linkType: hard @@ -15200,8 +15200,8 @@ __metadata: version: 5.8.3 resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" bin: - tsc: ./bin/tsc - tsserver: ./bin/tsserver + tsc: bin/tsc + tsserver: bin/tsserver checksum: 10c0/39117e346ff8ebd87ae1510b3a77d5d92dae5a89bde588c747d25da5c146603a99c8ee588c7ef80faaf123d89ed46f6dbd918d534d641083177d5fac38b8a1cb languageName: node linkType: hard @@ -15210,8 +15210,8 @@ __metadata: version: 5.6.3 resolution: "typescript@patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40" bin: - tsc: ./bin/tsc - tsserver: ./bin/tsserver + tsc: bin/tsc + tsserver: bin/tsserver checksum: 10c0/7c9d2e07c81226d60435939618c91ec2ff0b75fbfa106eec3430f0fcf93a584bc6c73176676f532d78c3594fe28a54b36eb40b3d75593071a7ec91301533ace7 languageName: node linkType: hard @@ -15393,7 +15393,7 @@ __metadata: version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: - uuid: ./dist/bin/uuid + uuid: dist/bin/uuid checksum: 10c0/bcbb807a917d374a49f475fae2e87fdca7da5e5530820ef53f65ba1d12131bd81a92ecf259cc7ce317cbe0f289e7d79fdfebcef9bfa3087c8c8a2fa304c9be54 languageName: node linkType: hard @@ -15596,7 +15596,7 @@ __metadata: dependencies: isexe: "npm:^3.1.1" bin: - node-which: ./bin/which.js + node-which: bin/which.js checksum: 10c0/449fa5c44ed120ccecfe18c433296a4978a7583bf2391c50abce13f76878d2476defde04d0f79db8165bdf432853c1f8389d0485ca6e8ebce3bbcded513d5e6a languageName: node linkType: hard @@ -15742,7 +15742,7 @@ __metadata: version: 2.8.2 resolution: "yaml@npm:2.8.2" bin: - yaml: ./bin.mjs + yaml: bin.mjs checksum: 10c0/703e4dc1e34b324aa66876d63618dcacb9ed49f7e7fe9b70f1e703645be8d640f68ab84f12b86df8ac960bac37acf5513e115de7c970940617ce0343c8c9cd96 languageName: node linkType: hard @@ -15834,7 +15834,7 @@ __metadata: commander: optional: true bin: - z-schema: ./bin/z-schema + z-schema: bin/z-schema checksum: 10c0/3242da6b2d8da3bc9a66876ef01a1d5f0d0ad7bd70b0e3e24f5dc6ef5f6213e6e660f14f3dceee9b000692a47b86b365c0ea43b5340153efcb2808ccbfb3fc6f languageName: node linkType: hard From 3b5551f1740566a6c5eaf96dc8cd969a4b078e56 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 25 Sep 2026 10:16:09 -0700 Subject: [PATCH 36/38] chore(macos): normalize shared fork diff tags Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/export-versions.mts | 1 + packages/react-native/ReactCommon/ReactCommon.podspec | 2 ++ .../react-native/ReactCommon/cxxreact/React-cxxreact.podspec | 2 +- .../react/nativemodule/samples/ReactCommon-Samples.podspec | 2 +- .../react/renderer/components/view/HostPlatformTouch.h | 2 ++ .../renderer/components/view/HostPlatformViewEventEmitter.h | 2 ++ .../react/renderer/components/view/HostPlatformViewProps.h | 2 ++ .../components/view/HostPlatformViewTraitsInitializer.h | 2 ++ .../ReactCommon/react/renderer/components/view/KeyEvent.h | 2 ++ .../ReactCommon/react/renderer/components/view/MouseEvent.h | 2 ++ .../renderer/components/view/HostPlatformViewEventEmitter.cpp | 4 ++-- .../react/renderer/components/view/HostPlatformViewProps.cpp | 4 ++-- .../react/renderer/graphics/React-graphics.podspec | 2 +- .../platform/ios/react/renderer/graphics/HostPlatformColor.mm | 2 +- .../react/runtime/platform/ios/React-RuntimeApple.podspec | 4 ++-- .../react-native/scripts/cocoapods/__tests__/utils-test.rb | 2 ++ .../scripts/ios-prebuild/__tests__/hermes-framework-test.js | 2 ++ .../scripts/ios-prebuild/__tests__/hermes-test.js | 2 ++ .../scripts/ios-prebuild/__tests__/hermes-version-test.js | 2 ++ .../react-native/scripts/ios-prebuild/hermes-framework.js | 2 ++ packages/react-native/scripts/ios-prebuild/hermes-version.js | 2 ++ .../rn-tester/NativeComponentExample/MyNativeView.podspec | 2 +- 22 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/scripts/export-versions.mts b/.github/scripts/export-versions.mts index b23bba76fa9d..27bd54804285 100644 --- a/.github/scripts/export-versions.mts +++ b/.github/scripts/export-versions.mts @@ -1,4 +1,5 @@ #!/usr/bin/env node +// [macOS] /** * Export react and react-native version information from packages/react-native/package.json. * diff --git a/packages/react-native/ReactCommon/ReactCommon.podspec b/packages/react-native/ReactCommon/ReactCommon.podspec index 8f4f85de40bf..ea00e7bb4476 100644 --- a/packages/react-native/ReactCommon/ReactCommon.podspec +++ b/packages/react-native/ReactCommon/ReactCommon.podspec @@ -63,10 +63,12 @@ Pod::Spec.new do |s| ss.subspec "core" do |sss| sss.source_files = podspec_sources("react/nativemodule/core/ReactCommon/**/*.{cpp,h}", "react/nativemodule/core/ReactCommon/**/*.h") + # [macOS sss.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ReactCommon\"" } add_dependency(sss, "React-debug", :version => version) add_dependency(sss, "React-featureflags", :version => version) add_dependency(sss, "React-utils", :version => version) + # macOS] end end end diff --git a/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec b/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec index 25232b1b2dc4..28a84374fdda 100644 --- a/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec +++ b/packages/react-native/ReactCommon/cxxreact/React-cxxreact.podspec @@ -41,7 +41,7 @@ Pod::Spec.new do |s| s.dependency "React-perflogger", version s.dependency "React-jsi", version s.dependency "React-logger", version - add_dependency(s, "React-debug", :version => version) + add_dependency(s, "React-debug", :version => version) # [macOS] s.dependency "React-timing", version s.resource_bundles = {'React-cxxreact_privacy' => 'PrivacyInfo.xcprivacy'} diff --git a/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec b/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec index 90ad49db4ef9..cd80dd7e7a58 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec +++ b/packages/react-native/ReactCommon/react/nativemodule/samples/ReactCommon-Samples.podspec @@ -37,7 +37,7 @@ Pod::Spec.new do |s| "USE_HEADERMAP" => "YES", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), "GCC_WARN_PEDANTIC" => "YES" } - s.frameworks = "CoreGraphics" + s.frameworks = "CoreGraphics" # [macOS] # [macOS Restrict UIKit to iOS and visionOS s.ios.framework = "UIKit" s.visionos.framework = "UIKit" diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h index 7962db434d0a..dacb710314b8 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformTouch.h @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +// [macOS] + #pragma once #if defined(__APPLE__) diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h index 6640bde021fa..83ac40eeffae 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewEventEmitter.h @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +// [macOS] + #pragma once #if defined(__APPLE__) diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h index 87c02fb1c5e8..9dca34cea540 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewProps.h @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +// [macOS] + #pragma once #if defined(__APPLE__) diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h index 2caf27c65d2b..321959c54c11 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/HostPlatformViewTraitsInitializer.h @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +// [macOS] + #pragma once #if defined(__APPLE__) diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h b/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h index ca285e3fa594..4514fc55c453 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/KeyEvent.h @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +// [macOS] + #pragma once #include "platform/macos/react/renderer/components/view/KeyEvent.h" diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h b/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h index 23515fefccd6..cffdc4bec6bd 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/MouseEvent.h @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +// [macOS] + #pragma once #include "platform/macos/react/renderer/components/view/MouseEvent.h" diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp index ff7a6d42c936..8e0d0d725fd6 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewEventEmitter.cpp @@ -9,7 +9,7 @@ #include #endif -#if defined(__APPLE__) && TARGET_OS_OSX // [macOS] +#if defined(__APPLE__) && TARGET_OS_OSX // [macOS #include #include @@ -216,4 +216,4 @@ void HostPlatformViewEventEmitter::onDrop(const DragEvent& dragEvent) const { } // namespace facebook::react -#endif // defined(__APPLE__) && TARGET_OS_OSX +#endif // macOS] diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp index 3e45d7288808..b0d1ac80423c 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/macos/react/renderer/components/view/HostPlatformViewProps.cpp @@ -9,7 +9,7 @@ #include #endif -#if defined(__APPLE__) && TARGET_OS_OSX // [macOS] +#if defined(__APPLE__) && TARGET_OS_OSX // [macOS #include "HostPlatformViewProps.h" @@ -167,4 +167,4 @@ void HostPlatformViewProps::setProp( } // namespace facebook::react -#endif // defined(__APPLE__) && TARGET_OS_OSX +#endif // macOS] diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec b/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec index e7db55edd6ee..320b86ec963d 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec +++ b/packages/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec @@ -32,7 +32,7 @@ Pod::Spec.new do |s| s.source = source s.source_files = podspec_sources(source_files, ["*.h", "platform/ios/**/*.h"]) s.header_dir = "react/renderer/graphics" - s.frameworks = "CoreGraphics" + s.frameworks = "CoreGraphics" # [macOS] # [macOS Restrict UIKit to iOS and visionOS s.ios.framework = "UIKit" s.visionos.framework = "UIKit" diff --git a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm index 5166765154a7..5f4512707749 100644 --- a/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm +++ b/packages/react-native/ReactCommon/react/renderer/graphics/platform/ios/react/renderer/graphics/HostPlatformColor.mm @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -#import +#import // [macOS] #import #import // [macOS] diff --git a/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec b/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec index a4ef1f14e443..9013ce9dfcff 100644 --- a/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec +++ b/packages/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec @@ -49,12 +49,12 @@ Pod::Spec.new do |s| s.dependency "React-Core/Default" s.dependency "React-CoreModules" s.dependency "React-NativeModulesApple" - add_dependency(s, "React-RCTFabric", :framework_name => "RCTFabric") + add_dependency(s, "React-RCTFabric", :framework_name => "RCTFabric") # [macOS] s.dependency "React-RuntimeCore" s.dependency "React-Mapbuffer" s.dependency "React-jserrorhandler" s.dependency "React-jsinspector" - add_dependency(s, "React-featureflags") + add_dependency(s, "React-featureflags") # [macOS] add_dependency(s, "React-jsitooling", :framework_name => "JSITooling") add_dependency(s, "React-RCTFBReactNativeSpec") add_dependency(s, "React-utils", :additional_framework_paths => ["react/utils/platform/ios"]) diff --git a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb index 1b50febdebde..b687e4ab92d1 100644 --- a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb +++ b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb @@ -769,6 +769,7 @@ def test_creatHeaderSearchPathForFrameworks_whenMultiplePlatformsAndExtraPath_cr # ===================== # # TEST - Add Dependency # # ===================== # + # [macOS data("normal" => [nil, [""]], "single platform" => [["iOS"], [""]], "three platforms" => [["iOS", "macOS", "visionOS"], ["-iOS", "-macOS", "-visionOS"]]) @@ -803,6 +804,7 @@ def test_addDependency_forDynamicPodDependencies_preservesVersionsAndTargetSetti }, spec.to_hash["pod_target_xcconfig"], pod_name) end end + # macOS] def test_addDependency_whenNoHeaderSearchPathAndNoVersion_addsThem spec = SpecMock.new diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js index 8d69be2fb6f7..d6832b5d80e4 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-framework-test.js @@ -8,6 +8,8 @@ * @format */ +// [macOS] + 'use strict'; jest.mock('child_process', () => ({execFileSync: jest.fn()})); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js index f4746f9e0bc9..643cdcc43995 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-test.js @@ -8,6 +8,8 @@ * @format */ +// [macOS] + 'use strict'; jest.mock('child_process', () => ({ diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js index a199b30fef2e..102b63c0c974 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js @@ -8,6 +8,8 @@ * @format */ +// [macOS] + 'use strict'; const {parseHermesMetadata, readHermesMetadata} = require('../hermes-version'); diff --git a/packages/react-native/scripts/ios-prebuild/hermes-framework.js b/packages/react-native/scripts/ios-prebuild/hermes-framework.js index 7889e5dd98bf..421d4c96b526 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes-framework.js +++ b/packages/react-native/scripts/ios-prebuild/hermes-framework.js @@ -8,6 +8,8 @@ * @format */ +// [macOS] + const {createLogger} = require('./utils'); const {execFileSync} = require('child_process'); const fs = require('fs'); diff --git a/packages/react-native/scripts/ios-prebuild/hermes-version.js b/packages/react-native/scripts/ios-prebuild/hermes-version.js index 01f6a8bf46ce..80b82a4a3b9a 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes-version.js +++ b/packages/react-native/scripts/ios-prebuild/hermes-version.js @@ -8,6 +8,8 @@ * @format */ +// [macOS] + 'use strict'; const fs = require('fs'); diff --git a/packages/rn-tester/NativeComponentExample/MyNativeView.podspec b/packages/rn-tester/NativeComponentExample/MyNativeView.podspec index 37af216df01a..98437e5ff630 100644 --- a/packages/rn-tester/NativeComponentExample/MyNativeView.podspec +++ b/packages/rn-tester/NativeComponentExample/MyNativeView.podspec @@ -26,5 +26,5 @@ Pod::Spec.new do |s| s.requires_arc = true install_modules_dependencies(s) - add_dependency(s, "ReactCodegen") + add_dependency(s, "ReactCodegen") # [macOS] end From b0b3e40947f29848deaace2506c05b901fb34746 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 25 Sep 2026 10:17:27 -0700 Subject: [PATCH 37/38] test(ci): validate release policy and Changesets config Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../__tests__/publishing-contract.test.mjs | 34 ++++++++++++++ .github/scripts/publishing-contract.mjs | 44 +++++++++++++++++++ .github/scripts/validate-changeset-config.mjs | 5 +++ .github/workflows/microsoft-pr.yml | 21 +++++++++ package.json | 1 + 5 files changed, 105 insertions(+) create mode 100644 .github/scripts/validate-changeset-config.mjs diff --git a/.github/scripts/__tests__/publishing-contract.test.mjs b/.github/scripts/__tests__/publishing-contract.test.mjs index 685d9e821cd5..03bac88df959 100644 --- a/.github/scripts/__tests__/publishing-contract.test.mjs +++ b/.github/scripts/__tests__/publishing-contract.test.mjs @@ -13,6 +13,7 @@ import { publishTag, readChangesetStatus, readWorkspaces, + validateChangesetConfig, validateRelease, validatePreparedVersionPR, canAdvanceTag, @@ -192,6 +193,39 @@ test('repository Changesets policy disables private versions and tags and fixes assert.deepEqual(releasePolicy.fixed, [[core, lists]]); }); +test('dedicated Changesets config validation follows the package graph and CI base', () => { + const stableWorkspaces = graph(); + assert.deepEqual(validateChangesetConfig({ + root: repositoryRoot, + workspaces: stableWorkspaces, + config: stablePolicy, + baseRef: branch, + }), {baseBranch: `origin/${branch}`, mode: 'stable'}); + + const mainWorkspaces = graph('1000.0.0'); + mainWorkspaces.find(pkg => pkg.name === lists).private = true; + assert.deepEqual(validateChangesetConfig({ + root: repositoryRoot, + workspaces: mainWorkspaces, + config: mainPolicy, + baseRef: 'main', + }), {baseBranch: 'origin/main', mode: 'development'}); + + for (const config of [ + {...stablePolicy, baseBranch: 'origin/main'}, + {...stablePolicy, ignore: [core]}, + {...stablePolicy, fixed: []}, + {...stablePolicy, privatePackages: {version: true, tag: false}}, + ]) { + assert.throws(() => validateChangesetConfig({ + root: repositoryRoot, + workspaces: stableWorkspaces, + config, + baseRef: branch, + })); + } +}); + test('repository Changesets policy follows the actual public and private workspace graph', async t => { const workspaces = readWorkspaces(repositoryRoot); const corePackage = workspaces.find(pkg => pkg.name === core); diff --git a/.github/scripts/publishing-contract.mjs b/.github/scripts/publishing-contract.mjs index b72da982723e..5fd5e2c11fa9 100644 --- a/.github/scripts/publishing-contract.mjs +++ b/.github/scripts/publishing-contract.mjs @@ -36,6 +36,50 @@ export function readWorkspaces(root = process.cwd(), run = execFileSync) { return readWorkspaceEntries(root, run).map(({pkg}) => pkg); } +export function validateChangesetConfig({ + root = process.cwd(), + config = JSON.parse(readFileSync(join(root, '.changeset/config.json'), 'utf8')), + workspaces = readWorkspaces(root), + baseRef = process.env.GITHUB_BASE_REF, +} = {}) { + const core = workspaces.find(pkg => pkg.name === 'react-native-macos'); + const lists = workspaces.find(pkg => pkg.name === '@react-native-macos/virtualized-lists'); + if (!core || !lists) { + throw new Error('Missing React Native macOS release workspaces'); + } + + const main = core.version === '1000.0.0'; + const parsed = !main && semver.parse(core.version); + if (!main && !parsed) { + throw new Error(`Invalid React Native macOS workspace version: ${core.version}`); + } + + const expectedBase = `origin/${baseRef ?? (main ? 'main' : `${parsed.major}.${parsed.minor}-stable`)}`; + const expectedIgnore = main ? ['react-native-macos', '@react-native/tester'] : []; + const expectedFixed = [['react-native-macos', '@react-native-macos/virtualized-lists']]; + const expectedPrivatePackages = {version: false, tag: false}; + const assertConfig = (name, actual, expected) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Invalid Changesets ${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } + }; + + assertConfig('baseBranch', config.baseBranch, expectedBase); + assertConfig('ignore', config.ignore, expectedIgnore); + assertConfig('fixed groups', config.fixed, expectedFixed); + assertConfig('linked groups', config.linked, []); + assertConfig('private package policy', config.privatePackages, expectedPrivatePackages); + assertConfig('workspace protocol policy', config.bumpVersionsWithWorkspaceProtocolOnly, true); + if (Boolean(lists.private) !== main) { + throw new Error(`@react-native-macos/virtualized-lists must be ${main ? 'private' : 'public'}`); + } + if (lists.version !== core.version) { + throw new Error(`@react-native-macos/virtualized-lists@${lists.version} does not match ${core.version}`); + } + + return {baseBranch: expectedBase, mode: main ? 'development' : 'stable'}; +} + // The init CLI has its own version and release process. Never include all public // workspaces: only these packages follow the React Native macOS release line. export function releasePackages(workspaces) { diff --git a/.github/scripts/validate-changeset-config.mjs b/.github/scripts/validate-changeset-config.mjs new file mode 100644 index 000000000000..55f4263a167a --- /dev/null +++ b/.github/scripts/validate-changeset-config.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import {validateChangesetConfig} from './publishing-contract.mjs'; + +const {baseBranch, mode} = validateChangesetConfig(); +console.log(`Changesets config is valid for ${mode} mode (${baseBranch}).`); diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index a3cfbed62d7a..7137217162fc 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -89,6 +89,26 @@ jobs: run: yarn install --immutable - name: Validate changesets run: yarn change:check + + changeset-config: + name: "Validate Changesets Config" + permissions: {} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 0 + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain + with: + node-version: '22' + - name: Install dependencies + run: yarn install --immutable + - name: Validate Changesets config + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: yarn changeset:config:check yarn-constraints: name: "Check Yarn Constraints" @@ -201,6 +221,7 @@ jobs: - lint-title - npm-publish-dry-run - check-changesets + - changeset-config - yarn-constraints - javascript-tests - build-rntester diff --git a/package.json b/package.json index e890208df87c..f7e8cbeab6fb 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "update-lock": "npx yarn-deduplicate", "change": "node .github/scripts/change.mts", "change:check": "node .github/scripts/change.mts --check", + "changeset:config:check": "node .github/scripts/validate-changeset-config.mjs", "changeset": "changeset", "changeset:version": "node .github/scripts/changeset-version-with-postbump.mts" }, From 2f231ade3c8282aa7c3bf76b87fd15e392513ff0 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 25 Sep 2026 10:18:05 -0700 Subject: [PATCH 38/38] chore(ci): restore Meta Actions ownership Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/setup-xcode/action.yml | 2 +- .github/workflows/autorebase.yml | 1 - .../{codeql-analysis.yml => microsoft-codeql-analysis.yml} | 2 +- .github/workflows/needs-attention.yml | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) rename .github/workflows/{codeql-analysis.yml => microsoft-codeql-analysis.yml} (98%) diff --git a/.github/actions/setup-xcode/action.yml b/.github/actions/setup-xcode/action.yml index 79ce3d47aa79..801c4750085a 100644 --- a/.github/actions/setup-xcode/action.yml +++ b/.github/actions/setup-xcode/action.yml @@ -4,7 +4,7 @@ inputs: xcode-version: description: 'The xcode version to use' required: false - default: '26.2' + default: '16.2.0' platform: description: 'The platform to use. Valid values are: ios, ios-simulator, macos, mac-catalyst, tvos, tvos-simulator, xros, xros-simulator' required: false diff --git a/.github/workflows/autorebase.yml b/.github/workflows/autorebase.yml index 1a3af07c31a0..447e7dc5ece5 100644 --- a/.github/workflows/autorebase.yml +++ b/.github/workflows/autorebase.yml @@ -25,4 +25,3 @@ jobs: uses: cirrus-actions/rebase@1.8 env: GITHUB_TOKEN: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }} - continue-on-error: true # [macOS] diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/microsoft-codeql-analysis.yml similarity index 98% rename from .github/workflows/codeql-analysis.yml rename to .github/workflows/microsoft-codeql-analysis.yml index b94dad7fccea..2b5370ea8e94 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/microsoft-codeql-analysis.yml @@ -9,7 +9,7 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: "Microsoft CodeQL" on: push: diff --git a/.github/workflows/needs-attention.yml b/.github/workflows/needs-attention.yml index b273b483aa54..4d7a0cf8f02f 100644 --- a/.github/workflows/needs-attention.yml +++ b/.github/workflows/needs-attention.yml @@ -26,4 +26,3 @@ jobs: id: needs-attention - name: Result run: echo '${{ steps.needs-attention.outputs.result }}' - continue-on-error: true # [macOS]