From 514f104ca6c2cc6ffbcdf9a5ec0e3686cd7b3d05 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Tue, 21 Jul 2026 04:45:09 -0700 Subject: [PATCH] fix(iOS): stamp the React Native version in the prebuild compose job (#57603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `prebuild-ios-core.yml` builds the prebuilt iOS core in two jobs: **`build-rn-slice`** compiles the React binary per platform slice, and **`compose-xcframework`** runs afterwards in its **own fresh checkout**, downloads the slice artifacts, and composes the shipped `React.xcframework` + `ReactNativeHeaders.xcframework` — re-deriving the shipped *headers* from that checkout. The "Set React Native version" step runs only in `build-rn-slice`. So the compiled **binary is stamped**, but the composed **headers are not**: the artifact ships `ReactNativeVersion.h` with the `1000.0.0` dev sentinel, internally inconsistent with its own binary (and with the npm package, which is stamped in its own publish job). This was latent for as long as the compose job has existed — it only started breaking consumers now because the header-facades / VFS-overlay-removal work changed *which file libraries actually read*. Previously header resolution (the VFS overlay, and the `Pods/Headers/Public` symlinks of the source pods) redirected reads to the **stamped npm copy**, so the sentinel bytes in the tarball were never consumed. With real materialized headers, the prebuilt copy now wins the search path, and any library gating on `REACT_NATIVE_VERSION_MAJOR/MINOR` compiles the wrong branch. That breaks `[ios] react-native-unistyles` in nightly-tests: its `#if REACT_NATIVE_VERSION_MINOR >= 81` sees `MINOR 0` and picks a removed pre-0.81 path (`shadowNodeFromValue`). **Fix — built-headers overlay (no re-stamp/revert).** The `build-rn-slice` job already stamps its checkout before building and uploads `.build/headers`, and the compose job already downloads it — it was just unused. `stageEntries` now takes an overlay dir and, **for `ReactNativeVersion.h` only**, prefers the built copy (stamped in the slice job) over the source sentinel. Header **layout** is still spec-derived from source (podspec inventory, collision detection, classification), and every **other** header still copies from source — so a stale build tree can never ship wrong header content, only the one build-generated version header is overlaid. No stamping of the compose checkout, no `git revert`, no cache-key desync. The compose cache key is bumped so pre-fix (unstamped) composed artifacts cached under the old key are not served. **Guard.** `headers-verify.js` gains `--require-stamped-version` (passed when a `version-type` is set) that hard-fails the compose verification if any composed `ReactNativeVersion.h` still contains the sentinel — turning any future regression into a red prebuild instead of silently broken community libraries. ## Changelog: [IOS] [FIXED] - Ship a version-stamped ReactNativeVersion.h in the prebuilt iOS core artifacts instead of the 1000.0.0 dev sentinel Pull Request resolved: https://github.com/react/react-native/pull/57603 Test Plan: - Local compose with a stamped `ReactNativeVersion.h` seeded into `.build/headers` and the source tree left at the `1000` sentinel: all composed `ReactNativeVersion.h` copies come out stamped (overlay won), while a deliberately **stale** `.build/headers/…/TraceRecordingState.h` is correctly ignored — the composed `TraceRecordingState.h`/`HostTracingProfile.h` come from source, and other headers + module maps/umbrellas are unaffected (structural gate passes). - Guard: composed layout with a sentinel header → `verifyVersionStamp` throws listing the offending files; stamped → `version stamp OK (N copies checked)`; no effect without the flag. - Verified against a fresh RN-nightly app + `react-native-unistyles@3.3.0` on Xcode 26.3 (prebuilt mode) and in the Expo prebuilt harness (26.3 + 26.6): stock prebuilt headers reproduce the `shadowNodeFromValue` error; the stamped prebuilt header resolves it. - End-to-end proof is the next nightly run: the composed tarball must carry a stamped header. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: javache Differential Revision: D113005220 Pulled By: cipolleschi fbshipit-source-id: 5fbba8bb4e84fd1458a2f4a0baa623cf613b6e3b --- .github/workflows/prebuild-ios-core.yml | 6 +- .../scripts/ios-prebuild/headers-compose.js | 26 +++++++- .../scripts/ios-prebuild/headers-verify.js | 60 ++++++++++++++++++- .../scripts/ios-prebuild/xcframework.js | 10 +++- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index 36b2e13aada..77148d99914 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -133,7 +133,7 @@ jobs: uses: actions/cache/restore@v5 with: path: packages/react-native/.build/output/xcframeworks - key: v3-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v4-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} - name: Setup node.js if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' uses: ./.github/actions/setup-node @@ -200,7 +200,7 @@ jobs: # privileged-consumer/Expo fixtures). Catches consumer-facing header # regressions here instead of in downstream builds. cd packages/react-native - node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}" + node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}" ${{ inputs.version-type != '' && '--require-stamped-version' || '' }} - name: Compress and Rename XCFramework if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | @@ -246,4 +246,4 @@ jobs: packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.xcframework.tar.gz packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.framework.dSYM.tar.gz packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.tar.gz - key: v3-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v4-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js index 60651c69dd2..05ec0de82b1 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-compose.js +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -114,6 +114,7 @@ function stageEntries( stage /*: string */, entries /*: Array */, rnRoot /*: string */, + overlayDir /*: ?string */ = null, ) /*: void */ { for (const e of entries) { const dest = path.join(stage, e.relPath); @@ -129,7 +130,24 @@ function stageEntries( `#import <${e.redirectTo}>\n`, ); } else { - fs.copyFileSync(path.join(rnRoot, e.source), dest); + // ReactNativeVersion.h is the one shipped header STAMPED at build time: + // the slice job runs set-rn-artifacts-version before building, while the + // source tree keeps the 1000.0.0 dev sentinel. Take just this file's + // content from the built header tree (`overlayDir`, i.e. `.build/headers`) + // when present, so the compose ships the real version without re-stamping + // its own checkout. Every OTHER header is authoritative in the source + // tree (only the layout is spec-derived), so it always copies from + // source — never from a build tree that could be stale relative to it. + const isStamped = path.basename(e.source) === 'ReactNativeVersion.h'; + const overlaySource = + isStamped && overlayDir != null + ? path.join(overlayDir, e.source) + : null; + const src = + overlaySource != null && fs.existsSync(overlaySource) + ? overlaySource + : path.join(rnRoot, e.source); + fs.copyFileSync(src, dest); } } } @@ -145,11 +163,12 @@ function emitReactFrameworkHeaders( xcfwPath /*: string */, plan /*: HeadersSpecPlan */, rnRoot /*: string */, + overlayDir /*: ?string */ = null, ) /*: void */ { const stage = fs.mkdtempSync( path.join(path.dirname(xcfwPath), '.react-stage-'), ); - stageEntries(stage, plan.react, rnRoot); + stageEntries(stage, plan.react, rnRoot, overlayDir); fs.writeFileSync( path.join(stage, 'React-umbrella.h'), renderUmbrellaHeader(plan.umbrella), @@ -239,10 +258,11 @@ function buildReactNativeHeadersXcframework( // namespace so `` resolves without per-library wiring. null // when unstaged — then `` stays unavailable. hermesHeaders /*: ?string */ = null, + overlayDir /*: ?string */ = null, ) /*: string */ { // ---- stage headers ---- const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); - stageEntries(stage, plan.reactNativeHeaders, rnRoot); + stageEntries(stage, plan.reactNativeHeaders, rnRoot, overlayDir); // Hermes public headers (separate source from the deps namespaces — they // come from the hermes-ios tarball, not ReactNativeDependencies). Vend only // the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js index 79bebc80034..30a9ce77895 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-verify.js +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -35,6 +35,7 @@ * Usage: * node scripts/ios-prebuild/headers-verify.js [--flavor Debug|Release] * [--artifacts ] [--skip-compile] [--update-baseline] + * [--require-stamped-version] */ const {computeInventory} = require('./headers-inventory'); @@ -410,6 +411,49 @@ function runCompileGates( } } +// --------------------------------------------------------------------------- +// Version stamp gate +// --------------------------------------------------------------------------- + +/** + * Release/nightly artifacts must not ship ReactNativeVersion.h with the + * 1000.0.0 dev sentinel: the compose step copies headers from the source + * tree, so a compose job that forgot to run set-rn-artifacts-version.js + * would silently publish a sentinel header, breaking every library that + * gates code on REACT_NATIVE_VERSION_MAJOR/MINOR. + */ +function verifyVersionStamp(artifactsDir /*: string */) /*: void */ { + const copies = []; + const walk = (dir /*: string */) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const name = String(entry.name); + const full = path.join(dir, name); + if (entry.isDirectory()) { + walk(full); + } else if (name === 'ReactNativeVersion.h') { + copies.push(full); + } + } + }; + walk(artifactsDir); + if (copies.length === 0) { + throw new Error( + `no ReactNativeVersion.h found under ${artifactsDir} — cannot verify the version stamp.`, + ); + } + const unstamped = copies.filter(f => + /REACT_NATIVE_VERSION_MAJOR\s+1000\b/.test(fs.readFileSync(f, 'utf8')), + ); + if (unstamped.length > 0) { + throw new Error( + `ReactNativeVersion.h still contains the 1000.0.0 dev sentinel — run ` + + `scripts/releases/set-rn-artifacts-version.js before composing:\n ` + + unstamped.join('\n '), + ); + } + log(`version stamp OK (${copies.length} copies checked).`); +} + // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- @@ -419,11 +463,13 @@ function parseArgs(argv /*: Array */) /*: { artifacts: ?string, skipCompile: boolean, updateBaseline: boolean, + requireStampedVersion: boolean, } */ { let flavor = 'Debug'; let artifacts /*: ?string */ = null; let skipCompile = false; let updateBaseline = false; + let requireStampedVersion = false; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--flavor') { flavor = argv[++i]; @@ -433,9 +479,17 @@ function parseArgs(argv /*: Array */) /*: { skipCompile = true; } else if (argv[i] === '--update-baseline') { updateBaseline = true; + } else if (argv[i] === '--require-stamped-version') { + requireStampedVersion = true; } } - return {flavor, artifacts, skipCompile, updateBaseline}; + return { + flavor, + artifacts, + skipCompile, + updateBaseline, + requireStampedVersion, + }; } function main(argv /*:: ?: Array */) /*: void */ { @@ -460,6 +514,10 @@ function main(argv /*:: ?: Array */) /*: void */ { `(node scripts/ios-prebuild -c -f ${args.flavor}).`, ); } + if (args.requireStampedVersion) { + verifyVersionStamp(artifactsDir); + } + const {reactSlice, rnhHeaders} = verifyStructural(plan, artifactsDir); if (args.skipCompile) { diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 700d460bcd2..92c0f85d987 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -87,7 +87,14 @@ function buildXCFrameworks( emitReactFrameworkHeaders, } = require('./headers-compose'); const plan = computeSpecPlan(rootFolder); - emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // Built header tree from the slice jobs (downloaded to `.build/headers`). + // When present, the compose sources header CONTENT from here (see + // stageEntries) so build-time generated/stamped headers — notably the + // version-stamped ReactNativeVersion.h — ship without re-stamping this + // checkout. Absent (e.g. a local compose with no prior build) → source tree. + const builtHeadersDir = path.join(buildFolder, 'headers'); + const overlayDir = fs.existsSync(builtHeadersDir) ? builtHeadersDir : null; + emitReactFrameworkHeaders(outputPath, plan, rootFolder, overlayDir); // ReactNativeHeaders is PURE-RN — the third-party deps namespaces ship in // the ReactNativeDependenciesHeaders sidecar built by the deps prebuild // (scripts/releases/ios-prebuild), so the core compose no longer needs the @@ -115,6 +122,7 @@ function buildXCFrameworks( rootFolder, true, // include the mac-catalyst slice in the real compose hermesHeaders, + overlayDir, ); if (identity) {