From fbdcf7adc6d2eda15e97967f86104b7498161509 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Mon, 20 Jul 2026 12:31:38 +0200 Subject: [PATCH 1/2] fix(iOS): stamp the React Native version in the composed prebuilt headers The prebuilt iOS core artifacts shipped ReactNativeVersion.h with the 1000.0.0 dev sentinel: the compose-xcframework job re-derives the shipped headers from its own fresh (unstamped) checkout, while only build-rn-slice runs set-rn-artifacts-version.js. Since the header-facades change, libraries resolve the prebuilt header instead of the stamped npm copy, so any library gating on REACT_NATIVE_VERSION_MAJOR/MINOR compiles the wrong branch (react-native-unistyles in nightly-tests). Rather than re-stamp (and then revert) the compose checkout, source just that one build-stamped header's CONTENT from the built header tree the compose job already downloads (.build/headers): 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, and every other header still copies from source, so a stale build tree can never ship wrong header content. Bump the compose cache key so pre-fix unstamped composed artifacts are not served. Co-Authored-By: Claude Fable 5 --- .github/workflows/prebuild-ios-core.yml | 4 +-- .../scripts/ios-prebuild/headers-compose.js | 26 ++++++++++++++++--- .../scripts/ios-prebuild/xcframework.js | 10 ++++++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index 36b2e13aada2..c5b3f65a57eb 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 @@ -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 60651c69dd29..05ec0de82b19 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/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 700d460bcd2e..92c0f85d987c 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) { From a1830a75866b990519bc8d6ee534bc1fafe1dc37 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Mon, 20 Jul 2026 12:31:56 +0200 Subject: [PATCH 2/2] fix(iOS): hard-fail the prebuild when composed headers ship the version sentinel Enforce the stamping above: headers-verify.js gains a --require-stamped-version gate that fails the compose verification when any ReactNativeVersion.h in the composed artifacts still contains the 1000.0.0 dev sentinel, and the workflow passes the flag whenever a version-type is set. Turns any future regression of the stamping into a red prebuild instead of silently broken community libraries. Co-Authored-By: Claude Fable 5 --- .github/workflows/prebuild-ios-core.yml | 2 +- .../scripts/ios-prebuild/headers-verify.js | 60 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index c5b3f65a57eb..77148d99914d 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -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: | diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js index 79bebc800348..30a9ce778954 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) {