Skip to content

Commit 6917845

Browse files
chrfalchclaude
andcommitted
feat(ios-prebuild): emit ReactNativeDependenciesHeaders.xcframework sidecar
The binary ReactNativeDependencies.xcframework is FRAMEWORK-type: it cannot carry a HeadersPath key, so its root Headers/ dir is invisible to SwiftPM binaryTargets (verified empirically 2026-07-04 - xcodebuild rejects -headers without -library, and SwiftPM hard-rejects HeadersPath on framework entries). This adds a headers-only LIBRARY-type sidecar, ReactNativeDependenciesHeaders.xcframework, that SwiftPM auto-serves with zero flags: stub static archives paired with per-slice Headers/ holding the seven third-party namespaces (folly, glog, boost, fmt, double-conversion, fast_float, SocketRocket). - headers-xcframework.js (new): dependency-light shared emitter - composeHeadersOnlyXcframework (the stub-archive recipe factored out of buildReactNativeHeadersXcframework), stubSlicesFromXcframework (slice parity derived from the binary artifact's Info.plist, so the sidecar resolves for every platform the binary does), and buildDepsHeadersXcframework with a set-equality gate that fails closed in both directions (declared-but-missing and undeclared namespaces). - compose-framework.js: the deps compose emits and signs the sidecar right after assembling the root Headers/ (slice-uniform content). - prebuild-ios-dependencies.yml: the sidecar ships INSIDE the deps tarball (self-contained for CocoaPods) and as a standalone tarball; cache key bumped and now also hashes compose-framework.js. - reactNativeDependencies.js: consumers extract the sidecar alongside the binary; pre-sidecar tarballs (pinned RN_DEP_VERSION) warn and continue - CocoaPods is unaffected, SwiftPM recomposes it locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 017ca51 commit 6917845

5 files changed

Lines changed: 361 additions & 3 deletions

File tree

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ jobs:
130130
with:
131131
path: |
132132
packages/react-native/third-party/
133-
key: v3-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js') }}
133+
key: v4-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.js') }}
134134
# If cache hit, we already have our binary. We don't need to do anything.
135135
- name: Yarn Install
136136
if: steps.restore-xcframework.outputs.cache-hit != 'true'
@@ -164,7 +164,13 @@ jobs:
164164
if: steps.restore-xcframework.outputs.cache-hit != 'true'
165165
run: |
166166
tar -cz -f packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz \
167-
packages/react-native/third-party/ReactNativeDependencies.xcframework
167+
packages/react-native/third-party/ReactNativeDependencies.xcframework \
168+
packages/react-native/third-party/ReactNativeDependenciesHeaders.xcframework
169+
- name: Compress Headers Sidecar XCFramework
170+
if: steps.restore-xcframework.outputs.cache-hit != 'true'
171+
run: |
172+
tar -cz -f packages/react-native/third-party/ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz \
173+
packages/react-native/third-party/ReactNativeDependenciesHeaders.xcframework
168174
- name: Show Symbol folder content
169175
if: steps.restore-xcframework.outputs.cache-hit != 'true'
170176
run: ls -lR packages/react-native/third-party/Symbols
@@ -179,6 +185,11 @@ jobs:
179185
with:
180186
name: ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz
181187
path: packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz
188+
- name: Upload Headers Sidecar XCFramework Artifact
189+
uses: actions/upload-artifact@v6
190+
with:
191+
name: ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz
192+
path: packages/react-native/third-party/ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz
182193
- name: Upload dSYM Artifact
183194
uses: actions/upload-artifact@v6
184195
with:
@@ -191,5 +202,6 @@ jobs:
191202
with:
192203
path: |
193204
packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz
205+
packages/react-native/third-party/ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz
194206
packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.framework.dSYM.tar.gz
195-
key: v3-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js') }}
207+
key: v4-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.js') }}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
'use strict';
12+
13+
const {buildDepsHeadersXcframework} = require('../headers-xcframework');
14+
const fs = require('fs');
15+
const os = require('os');
16+
const path = require('path');
17+
18+
describe('buildDepsHeadersXcframework set-equality gate', () => {
19+
let tmp /*: string */;
20+
beforeEach(() => {
21+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deps-headers-test-'));
22+
});
23+
afterEach(() => {
24+
fs.rmSync(tmp, {recursive: true, force: true});
25+
});
26+
27+
const mkHeaders = (namespaces /*: Array<string> */) => {
28+
const dir = path.join(tmp, 'Headers');
29+
fs.mkdirSync(dir, {recursive: true});
30+
for (const ns of namespaces) {
31+
fs.mkdirSync(path.join(dir, ns), {recursive: true});
32+
}
33+
return dir;
34+
};
35+
36+
// Both gates throw BEFORE any staging or xcodebuild invocation, so these
37+
// tests run without macOS tooling.
38+
test('fails closed when a declared namespace is missing from the artifact', () => {
39+
const headers = mkHeaders(['folly']);
40+
expect(() =>
41+
buildDepsHeadersXcframework(tmp, headers, ['folly', 'glog'], []),
42+
).toThrow(/missing from .*Headers: glog/);
43+
});
44+
45+
test('fails closed when the artifact ships an undeclared namespace', () => {
46+
const headers = mkHeaders(['folly', 'brand-new-dep']);
47+
expect(() =>
48+
buildDepsHeadersXcframework(tmp, headers, ['folly'], []),
49+
).toThrow(/undeclared in DEPS_NAMESPACES.*brand-new-dep/);
50+
});
51+
52+
test('ignores loose files at the Headers root (directories are the namespace set)', () => {
53+
const headers = mkHeaders(['folly']);
54+
fs.writeFileSync(path.join(headers, 'stray.h'), '');
55+
expect(() =>
56+
buildDepsHeadersXcframework(tmp, headers, ['folly', 'glog'], []),
57+
).toThrow(/missing from .*Headers: glog/); // throws for glog, not stray.h
58+
});
59+
});
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
* @format
9+
*/
10+
11+
/**
12+
* Headers-only xcframework emitter — the shared recipe behind
13+
* ReactNativeHeaders.xcframework (headers-compose.js) and the
14+
* ReactNativeDependenciesHeaders.xcframework sidecar (the deps prebuild's
15+
* compose-framework.js). A headers-only artifact is a LIBRARY-type
16+
* xcframework: stub static archives (nothing embeds in apps) paired with a
17+
* staged Headers dir. The per-slice Headers/ layout and the Info.plist
18+
* `HeadersPath` key — what makes SwiftPM auto-serve the headers with zero
19+
* flags — are produced by `xcodebuild -create-xcframework -library ...
20+
* -headers ...` itself and are never hand-written (framework-type entries
21+
* hard-reject `HeadersPath`; verified 2026-07-04).
22+
*
23+
* This module must stay dependency-light (fs/path/child_process only): the
24+
* deps prebuild under scripts/releases requires it across the package
25+
* boundary.
26+
*/
27+
28+
const {execSync} = require('child_process');
29+
const fs = require('fs');
30+
const path = require('path');
31+
32+
/*::
33+
export type StubSlice = {
34+
name: string, // human label
35+
sdk: string, // xcrun --sdk name
36+
targets: Array<string>, // clang -target triples (lipo'd when > 1)
37+
};
38+
*/
39+
40+
const DEFAULT_STUB_SLICES /*: Array<StubSlice> */ = [
41+
{name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']},
42+
{
43+
name: 'ios-simulator',
44+
sdk: 'iphonesimulator',
45+
targets: [
46+
'arm64-apple-ios15.0-simulator',
47+
'x86_64-apple-ios15.0-simulator',
48+
],
49+
},
50+
];
51+
52+
// Mac Catalyst slice — used by the real compose (the cached-artifact
53+
// repackage path skips it to stay fast; React.xcframework carries it).
54+
const CATALYST_STUB_SLICE /*: StubSlice */ = {
55+
name: 'mac-catalyst',
56+
sdk: 'macosx',
57+
targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'],
58+
};
59+
60+
// SupportedPlatform(+variant) from an xcframework Info.plist -> stub recipe.
61+
// The min OS version in the triple only shapes the stub object file; slice
62+
// identity (what create-xcframework groups by) comes from platform + variant
63+
// + archs.
64+
const PLATFORM_STUB_RECIPES /*: {
65+
[key: string]: {sdk: string, os: string, suffix: string},
66+
} */ = {
67+
ios: {sdk: 'iphoneos', os: 'ios15.0', suffix: ''},
68+
'ios-simulator': {
69+
sdk: 'iphonesimulator',
70+
os: 'ios15.0',
71+
suffix: '-simulator',
72+
},
73+
'ios-maccatalyst': {sdk: 'macosx', os: 'ios15.0', suffix: '-macabi'},
74+
macos: {sdk: 'macosx', os: 'macosx11.0', suffix: ''},
75+
tvos: {sdk: 'appletvos', os: 'tvos15.1', suffix: ''},
76+
'tvos-simulator': {
77+
sdk: 'appletvsimulator',
78+
os: 'tvos15.1',
79+
suffix: '-simulator',
80+
},
81+
xros: {sdk: 'xros', os: 'xros1.0', suffix: ''},
82+
'xros-simulator': {sdk: 'xrsimulator', os: 'xros1.0', suffix: '-simulator'},
83+
};
84+
85+
/**
86+
* Derives stub slices matching an existing (binary) xcframework's slice set,
87+
* so a headers-only sidecar resolves for every platform the binary does.
88+
*/
89+
function stubSlicesFromXcframework(
90+
xcfwPath /*: string */,
91+
) /*: Array<StubSlice> */ {
92+
const plist = JSON.parse(
93+
execSync(
94+
`plutil -convert json -o - "${path.join(xcfwPath, 'Info.plist')}"`,
95+
).toString(),
96+
);
97+
return plist.AvailableLibraries.map(lib => {
98+
const key =
99+
lib.SupportedPlatformVariant != null
100+
? `${lib.SupportedPlatform}-${lib.SupportedPlatformVariant}`
101+
: lib.SupportedPlatform;
102+
const recipe = PLATFORM_STUB_RECIPES[key];
103+
if (recipe == null) {
104+
throw new Error(
105+
`headers-xcframework: no stub recipe for slice '${key}' of ` +
106+
`${xcfwPath}. Add it to PLATFORM_STUB_RECIPES.`,
107+
);
108+
}
109+
return {
110+
name: key,
111+
sdk: recipe.sdk,
112+
targets: lib.SupportedArchitectures.map(
113+
a => `${a}-apple-${recipe.os}${recipe.suffix}`,
114+
),
115+
};
116+
});
117+
}
118+
119+
/**
120+
* Composes `<name>.xcframework` under `outDir` from an already-populated
121+
* Headers stage dir: one stub static archive per slice, then
122+
* `xcodebuild -create-xcframework` pairing every archive with the stage.
123+
* The caller owns (and cleans) the stage dir.
124+
*/
125+
function composeHeadersOnlyXcframework(
126+
outDir /*: string */,
127+
name /*: string */,
128+
stage /*: string */,
129+
slices /*: Array<StubSlice> */,
130+
) /*: string */ {
131+
const work = fs.mkdtempSync(path.join(outDir, '.stub-work-'));
132+
fs.writeFileSync(
133+
path.join(work, 'stub.c'),
134+
`// ${name} is headers-only; this stub satisfies xcframework tooling.\n` +
135+
`static int ${name}Stub __attribute__((unused)) = 0;\n`,
136+
);
137+
const libs = slices.map(slice => {
138+
const sdkPath = execSync(`xcrun --sdk ${slice.sdk} --show-sdk-path`)
139+
.toString()
140+
.trim();
141+
const thins = slice.targets.map((t, i) => {
142+
const obj = path.join(work, `stub-${slice.name}-${i}.o`);
143+
execSync(
144+
`xcrun clang -c -target ${t} -isysroot "${sdkPath}" "${path.join(work, 'stub.c')}" -o "${obj}"`,
145+
);
146+
const lib = path.join(work, `stub-${slice.name}-${i}.a`);
147+
execSync(`xcrun libtool -static -o "${lib}" "${obj}" 2>/dev/null`);
148+
return lib;
149+
});
150+
const outLib = path.join(work, `lib${name}-${slice.name}.a`);
151+
if (thins.length === 1) {
152+
fs.copyFileSync(thins[0], outLib);
153+
} else {
154+
execSync(
155+
`xcrun lipo -create ${thins.map(l => `"${l}"`).join(' ')} -output "${outLib}"`,
156+
);
157+
}
158+
return outLib;
159+
});
160+
161+
const outXcfw = path.join(outDir, `${name}.xcframework`);
162+
fs.rmSync(outXcfw, {recursive: true, force: true});
163+
execSync(
164+
`xcodebuild -create-xcframework ` +
165+
libs.map(l => `-library "${l}" -headers "${stage}"`).join(' ') +
166+
` -output "${outXcfw}"`,
167+
{stdio: 'pipe'},
168+
);
169+
fs.rmSync(work, {recursive: true, force: true});
170+
return outXcfw;
171+
}
172+
173+
const DEPS_HEADERS_XCFRAMEWORK_NAME = 'ReactNativeDependenciesHeaders';
174+
175+
/**
176+
* Builds ReactNativeDependenciesHeaders.xcframework: the headers-only sidecar
177+
* serving the third-party deps namespaces (folly/glog/boost/fmt/
178+
* double-conversion/fast_float/SocketRocket). The binary
179+
* ReactNativeDependencies.xcframework is FRAMEWORK-type, so its root Headers/
180+
* dir is invisible to SwiftPM — this LIBRARY-type sidecar is what makes the
181+
* deps headers auto-served, keeping ReactNativeHeaders pure-RN.
182+
*
183+
* Set-equality with `namespaces` (headers-spec.js DEPS_NAMESPACES) is
184+
* enforced fail-closed in BOTH directions: a declared namespace missing from
185+
* `depsHeaders` would ship a silently-broken sidecar; an undeclared dir means
186+
* a new third-party dep was added without a spec decision.
187+
*/
188+
function buildDepsHeadersXcframework(
189+
outDir /*: string */,
190+
depsHeaders /*: string */,
191+
namespaces /*: Array<string> */,
192+
slices /*: Array<StubSlice> */,
193+
) /*: string */ {
194+
const found = fs
195+
.readdirSync(depsHeaders, {withFileTypes: true})
196+
.filter(e => e.isDirectory())
197+
.map(e => String(e.name));
198+
const missing = namespaces.filter(ns => !found.includes(ns));
199+
const undeclared = found.filter(d => !namespaces.includes(d));
200+
if (missing.length > 0 || undeclared.length > 0) {
201+
throw new Error(
202+
`headers-xcframework: deps namespaces out of sync with the spec.\n` +
203+
(missing.length > 0
204+
? ` missing from ${depsHeaders}: ${missing.join(', ')}\n`
205+
: '') +
206+
(undeclared.length > 0
207+
? ` undeclared in DEPS_NAMESPACES (headers-spec.js): ${undeclared.join(', ')}\n`
208+
: '') +
209+
`Declare new deps deliberately — the sidecar and the spec must agree.`,
210+
);
211+
}
212+
213+
const stage = fs.mkdtempSync(path.join(outDir, '.deps-headers-stage-'));
214+
for (const ns of namespaces) {
215+
execSync(
216+
`/bin/cp -Rc "${path.join(depsHeaders, ns)}" "${path.join(stage, ns)}"`,
217+
);
218+
}
219+
const outXcfw = composeHeadersOnlyXcframework(
220+
outDir,
221+
DEPS_HEADERS_XCFRAMEWORK_NAME,
222+
stage,
223+
slices,
224+
);
225+
fs.rmSync(stage, {recursive: true, force: true});
226+
console.log(
227+
`headers-xcframework: ${DEPS_HEADERS_XCFRAMEWORK_NAME}.xcframework ` +
228+
`(${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` +
229+
`(namespaces: ${namespaces.join(', ')})`,
230+
);
231+
return outXcfw;
232+
}
233+
234+
module.exports = {
235+
CATALYST_STUB_SLICE,
236+
DEFAULT_STUB_SLICES,
237+
DEPS_HEADERS_XCFRAMEWORK_NAME,
238+
buildDepsHeadersXcframework,
239+
composeHeadersOnlyXcframework,
240+
stubSlicesFromXcframework,
241+
};

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,28 @@ async function prepareReactNativeDependenciesArtifactsAsync(
9292
stdio: 'inherit',
9393
});
9494

95+
// The headers-only ReactNativeDependenciesHeaders.xcframework sidecar ships
96+
// alongside the binary in the same tarball — it is what serves the deps
97+
// namespaces to SwiftPM (the binary is framework-type; its root Headers/ is
98+
// invisible to binaryTargets). Absent only in pre-sidecar tarballs (pinned
99+
// RN_DEP_VERSION): CocoaPods still works (the pod flattens the binary's
100+
// root Headers/), SwiftPM consumers regain it via ensureHeadersLayout.
101+
const headersSidecarSource = path.join(
102+
path.dirname(xcframeworkSource),
103+
'ReactNativeDependenciesHeaders.xcframework',
104+
);
105+
if (fs.existsSync(headersSidecarSource)) {
106+
execSync(`cp -R "${headersSidecarSource}" "${artifactsPath}"`, {
107+
stdio: 'inherit',
108+
});
109+
} else {
110+
dependencyLog(
111+
'ReactNativeDependenciesHeaders.xcframework not present in the tarball ' +
112+
'(pre-sidecar artifact) — continuing with the binary xcframework only.',
113+
'warning',
114+
);
115+
}
116+
95117
// Delete the tarball after extraction
96118
if (!process.env.HERMES_ENGINE_TARBALL_PATH) {
97119
fs.unlinkSync(localPath);

0 commit comments

Comments
 (0)