Skip to content

Commit 95df500

Browse files
chrfalchmeta-codesync[bot]
authored andcommitted
fix(iOS): stamp the React Native version in the prebuild compose job (#57603)
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: #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
1 parent 6306468 commit 95df500

4 files changed

Lines changed: 94 additions & 8 deletions

File tree

.github/workflows/prebuild-ios-core.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ jobs:
133133
uses: actions/cache/restore@v5
134134
with:
135135
path: packages/react-native/.build/output/xcframeworks
136-
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/**/*') }}
136+
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/**/*') }}
137137
- name: Setup node.js
138138
if: steps.restore-ios-xcframework.outputs.cache-hit != 'true'
139139
uses: ./.github/actions/setup-node
@@ -200,7 +200,7 @@ jobs:
200200
# privileged-consumer/Expo fixtures). Catches consumer-facing header
201201
# regressions here instead of in downstream builds.
202202
cd packages/react-native
203-
node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}"
203+
node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}" ${{ inputs.version-type != '' && '--require-stamped-version' || '' }}
204204
- name: Compress and Rename XCFramework
205205
if: steps.restore-ios-xcframework.outputs.cache-hit != 'true'
206206
run: |
@@ -246,4 +246,4 @@ jobs:
246246
packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.xcframework.tar.gz
247247
packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.framework.dSYM.tar.gz
248248
packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.tar.gz
249-
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/**/*') }}
249+
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/**/*') }}

packages/react-native/scripts/ios-prebuild/headers-compose.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ function stageEntries(
114114
stage /*: string */,
115115
entries /*: Array<SpecEntry> */,
116116
rnRoot /*: string */,
117+
overlayDir /*: ?string */ = null,
117118
) /*: void */ {
118119
for (const e of entries) {
119120
const dest = path.join(stage, e.relPath);
@@ -129,7 +130,24 @@ function stageEntries(
129130
`#import <${e.redirectTo}>\n`,
130131
);
131132
} else {
132-
fs.copyFileSync(path.join(rnRoot, e.source), dest);
133+
// ReactNativeVersion.h is the one shipped header STAMPED at build time:
134+
// the slice job runs set-rn-artifacts-version before building, while the
135+
// source tree keeps the 1000.0.0 dev sentinel. Take just this file's
136+
// content from the built header tree (`overlayDir`, i.e. `.build/headers`)
137+
// when present, so the compose ships the real version without re-stamping
138+
// its own checkout. Every OTHER header is authoritative in the source
139+
// tree (only the layout is spec-derived), so it always copies from
140+
// source — never from a build tree that could be stale relative to it.
141+
const isStamped = path.basename(e.source) === 'ReactNativeVersion.h';
142+
const overlaySource =
143+
isStamped && overlayDir != null
144+
? path.join(overlayDir, e.source)
145+
: null;
146+
const src =
147+
overlaySource != null && fs.existsSync(overlaySource)
148+
? overlaySource
149+
: path.join(rnRoot, e.source);
150+
fs.copyFileSync(src, dest);
133151
}
134152
}
135153
}
@@ -145,11 +163,12 @@ function emitReactFrameworkHeaders(
145163
xcfwPath /*: string */,
146164
plan /*: HeadersSpecPlan */,
147165
rnRoot /*: string */,
166+
overlayDir /*: ?string */ = null,
148167
) /*: void */ {
149168
const stage = fs.mkdtempSync(
150169
path.join(path.dirname(xcfwPath), '.react-stage-'),
151170
);
152-
stageEntries(stage, plan.react, rnRoot);
171+
stageEntries(stage, plan.react, rnRoot, overlayDir);
153172
fs.writeFileSync(
154173
path.join(stage, 'React-umbrella.h'),
155174
renderUmbrellaHeader(plan.umbrella),
@@ -239,10 +258,11 @@ function buildReactNativeHeadersXcframework(
239258
// namespace so `<hermes/...>` resolves without per-library wiring. null
240259
// when unstaged — then `<hermes/...>` stays unavailable.
241260
hermesHeaders /*: ?string */ = null,
261+
overlayDir /*: ?string */ = null,
242262
) /*: string */ {
243263
// ---- stage headers ----
244264
const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-'));
245-
stageEntries(stage, plan.reactNativeHeaders, rnRoot);
265+
stageEntries(stage, plan.reactNativeHeaders, rnRoot, overlayDir);
246266
// Hermes public headers (separate source from the deps namespaces — they
247267
// come from the hermes-ios tarball, not ReactNativeDependencies). Vend only
248268
// the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying

packages/react-native/scripts/ios-prebuild/headers-verify.js

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
* Usage:
3636
* node scripts/ios-prebuild/headers-verify.js [--flavor Debug|Release]
3737
* [--artifacts <dir>] [--skip-compile] [--update-baseline]
38+
* [--require-stamped-version]
3839
*/
3940

4041
const {computeInventory} = require('./headers-inventory');
@@ -410,6 +411,49 @@ function runCompileGates(
410411
}
411412
}
412413

414+
// ---------------------------------------------------------------------------
415+
// Version stamp gate
416+
// ---------------------------------------------------------------------------
417+
418+
/**
419+
* Release/nightly artifacts must not ship ReactNativeVersion.h with the
420+
* 1000.0.0 dev sentinel: the compose step copies headers from the source
421+
* tree, so a compose job that forgot to run set-rn-artifacts-version.js
422+
* would silently publish a sentinel header, breaking every library that
423+
* gates code on REACT_NATIVE_VERSION_MAJOR/MINOR.
424+
*/
425+
function verifyVersionStamp(artifactsDir /*: string */) /*: void */ {
426+
const copies = [];
427+
const walk = (dir /*: string */) => {
428+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
429+
const name = String(entry.name);
430+
const full = path.join(dir, name);
431+
if (entry.isDirectory()) {
432+
walk(full);
433+
} else if (name === 'ReactNativeVersion.h') {
434+
copies.push(full);
435+
}
436+
}
437+
};
438+
walk(artifactsDir);
439+
if (copies.length === 0) {
440+
throw new Error(
441+
`no ReactNativeVersion.h found under ${artifactsDir} — cannot verify the version stamp.`,
442+
);
443+
}
444+
const unstamped = copies.filter(f =>
445+
/REACT_NATIVE_VERSION_MAJOR\s+1000\b/.test(fs.readFileSync(f, 'utf8')),
446+
);
447+
if (unstamped.length > 0) {
448+
throw new Error(
449+
`ReactNativeVersion.h still contains the 1000.0.0 dev sentinel — run ` +
450+
`scripts/releases/set-rn-artifacts-version.js before composing:\n ` +
451+
unstamped.join('\n '),
452+
);
453+
}
454+
log(`version stamp OK (${copies.length} copies checked).`);
455+
}
456+
413457
// ---------------------------------------------------------------------------
414458
// CLI
415459
// ---------------------------------------------------------------------------
@@ -419,11 +463,13 @@ function parseArgs(argv /*: Array<string> */) /*: {
419463
artifacts: ?string,
420464
skipCompile: boolean,
421465
updateBaseline: boolean,
466+
requireStampedVersion: boolean,
422467
} */ {
423468
let flavor = 'Debug';
424469
let artifacts /*: ?string */ = null;
425470
let skipCompile = false;
426471
let updateBaseline = false;
472+
let requireStampedVersion = false;
427473
for (let i = 0; i < argv.length; i++) {
428474
if (argv[i] === '--flavor') {
429475
flavor = argv[++i];
@@ -433,9 +479,17 @@ function parseArgs(argv /*: Array<string> */) /*: {
433479
skipCompile = true;
434480
} else if (argv[i] === '--update-baseline') {
435481
updateBaseline = true;
482+
} else if (argv[i] === '--require-stamped-version') {
483+
requireStampedVersion = true;
436484
}
437485
}
438-
return {flavor, artifacts, skipCompile, updateBaseline};
486+
return {
487+
flavor,
488+
artifacts,
489+
skipCompile,
490+
updateBaseline,
491+
requireStampedVersion,
492+
};
439493
}
440494

441495
function main(argv /*:: ?: Array<string> */) /*: void */ {
@@ -460,6 +514,10 @@ function main(argv /*:: ?: Array<string> */) /*: void */ {
460514
`(node scripts/ios-prebuild -c -f ${args.flavor}).`,
461515
);
462516
}
517+
if (args.requireStampedVersion) {
518+
verifyVersionStamp(artifactsDir);
519+
}
520+
463521
const {reactSlice, rnhHeaders} = verifyStructural(plan, artifactsDir);
464522

465523
if (args.skipCompile) {

packages/react-native/scripts/ios-prebuild/xcframework.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,14 @@ function buildXCFrameworks(
8787
emitReactFrameworkHeaders,
8888
} = require('./headers-compose');
8989
const plan = computeSpecPlan(rootFolder);
90-
emitReactFrameworkHeaders(outputPath, plan, rootFolder);
90+
// Built header tree from the slice jobs (downloaded to `.build/headers`).
91+
// When present, the compose sources header CONTENT from here (see
92+
// stageEntries) so build-time generated/stamped headers — notably the
93+
// version-stamped ReactNativeVersion.h — ship without re-stamping this
94+
// checkout. Absent (e.g. a local compose with no prior build) → source tree.
95+
const builtHeadersDir = path.join(buildFolder, 'headers');
96+
const overlayDir = fs.existsSync(builtHeadersDir) ? builtHeadersDir : null;
97+
emitReactFrameworkHeaders(outputPath, plan, rootFolder, overlayDir);
9198
// ReactNativeHeaders is PURE-RN — the third-party deps namespaces ship in
9299
// the ReactNativeDependenciesHeaders sidecar built by the deps prebuild
93100
// (scripts/releases/ios-prebuild), so the core compose no longer needs the
@@ -115,6 +122,7 @@ function buildXCFrameworks(
115122
rootFolder,
116123
true, // include the mac-catalyst slice in the real compose
117124
hermesHeaders,
125+
overlayDir,
118126
);
119127

120128
if (identity) {

0 commit comments

Comments
 (0)