Skip to content

Commit 8fa8f22

Browse files
Saadnajmiclaude
andauthored
feat(ci): prebuild React xcframework for macOS (#2920)
## Summary Overhauls the macOS SPM prebuild CI pipeline, renaming it from "Build SwiftPM" to "Prebuild macOS Core" to align with upstream's `prebuild-ios-core.yml` convention. ### Hermes resolution (`microsoft-resolve-hermes.yml`) - Extract Hermes build into a separate reusable workflow (`Resolve Hermes`) - Download upstream Hermes tarball from Maven/Sonatype and recompose the xcframework to include the macOS slice — avoids ~90 min build-from-source - Check if macOS is already in the universal xcframework before recomposing (future-proofing for upstream Hermes PRs: [#1958](facebook/hermes#1958), [#1970](facebook/hermes#1970), [#1971](facebook/hermes#1971)) - Fall back to resolving the Hermes commit at the merge base with facebook/react-native → cache check → build from source ### Prebuild pipeline (`microsoft-prebuild-macos-core.yml`) - Expand build matrix to include `ios-simulator` and `visionos-simulator` slices - Add `compose-xcframework` job that assembles all slices into `React.xcframework` with dSYMs - Add content-hash caching for slice builds and composed xcframework (save on main and any stable branch) - Fix Hermes version marker mismatch (`HERMES_VERSION=prebuilt` bypasses version resolution entirely) - Use `microsoft-setup-toolchain` action consistently across all Hermes build jobs - Add macOS and visionOS to `extractDestinationFromPath` for dSYM symbol copying ### Script refactoring - Rename `macosVersionResolver.js` → `microsoft-hermes.js` - Move CI-specific logic (download, recompose, CLI dispatch) to `.github/scripts/resolve-hermes.mts` using the existing zx pattern - Keep `microsoft-hermes.js` as a pure library (version resolution, merge base commit lookup) - Use Node's `parseArgs` for CLI argument parsing - Fix `createRequire` interop for importing CommonJS modules from ESM `.mts` script ## Test plan - [ ] All 5 build jobs pass (ios, ios-simulator, macos, visionos, visionos-simulator) - [ ] `compose-xcframework` job succeeds - [ ] `ReactCoreDebug.xcframework.tar.gz` artifact is downloadable from GitHub Actions - [ ] Downloaded xcframework contains all expected platform slices - [ ] Cache hit skips build on subsequent runs with same source hash - [ ] Hermes resolve fast path downloads and recomposes upstream tarball (~1 min vs ~90 min) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2135a5c commit 8fa8f22

8 files changed

Lines changed: 543 additions & 85 deletions

File tree

.github/scripts/resolve-hermes.mts

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
#!/usr/bin/env node
2+
/**
3+
* CI entry point for resolving Hermes artifacts.
4+
*
5+
* Commands:
6+
* node resolve-hermes.mts download-hermes [Debug|Release]
7+
* node resolve-hermes.mts recompose-xcframework <tarball> <destroot>
8+
* node resolve-hermes.mts resolve-commit
9+
*
10+
* Each command writes results to $GITHUB_OUTPUT for use in GitHub Actions.
11+
*/
12+
import { createRequire } from 'node:module';
13+
import os from 'node:os';
14+
import { parseArgs } from 'node:util';
15+
import { $, echo, fs, path } from 'zx';
16+
17+
// Use createRequire to import CommonJS modules from ESM context
18+
const require = createRequire(import.meta.url);
19+
const {
20+
findMatchingHermesVersion,
21+
findVersionAtMergeBase,
22+
getLatestStableVersionFromNPM,
23+
hermesCommitAtMergeBase,
24+
} = require('../../packages/react-native/scripts/ios-prebuild/microsoft-hermes.js');
25+
const {
26+
computeNightlyTarballURL,
27+
} = require('../../packages/react-native/scripts/ios-prebuild/utils.js');
28+
29+
function setActionOutput(key: string, value: string) {
30+
const outputFile = process.env.GITHUB_OUTPUT;
31+
if (outputFile) {
32+
fs.appendFileSync(outputFile, `${key}=${value}\n`);
33+
}
34+
}
35+
36+
/**
37+
* Downloads the upstream Hermes tarball from Maven or Sonatype.
38+
*
39+
* Tries multiple version resolution strategies in order:
40+
* 1. Mapped version from peerDependencies (stable branches)
41+
* 2. Version at merge base with facebook/react-native (main branch)
42+
* 3. Latest stable version from npm (last resort)
43+
*
44+
* Returns {tarballPath, version} on success, or null if no tarball is available.
45+
*/
46+
async function downloadUpstreamHermesTarball(
47+
buildType: string = 'Debug',
48+
): Promise<{ tarballPath: string; version: string } | null> {
49+
const packageJsonPath = path.resolve(
50+
import.meta.dirname!, '..', '..', 'packages', 'react-native', 'package.json',
51+
);
52+
53+
// Build a list of candidate versions to try (in priority order)
54+
const candidates: string[] = [];
55+
56+
const mapped = findMatchingHermesVersion(packageJsonPath);
57+
if (mapped != null) {
58+
candidates.push(mapped);
59+
}
60+
61+
const mergeBaseVersion = findVersionAtMergeBase();
62+
if (mergeBaseVersion != null && !candidates.includes(mergeBaseVersion)) {
63+
candidates.push(mergeBaseVersion);
64+
}
65+
66+
try {
67+
const latestStable = await getLatestStableVersionFromNPM();
68+
if (!candidates.includes(latestStable)) {
69+
candidates.push(latestStable);
70+
}
71+
} catch {
72+
// npm lookup failed, continue with what we have
73+
}
74+
75+
if (candidates.length === 0) {
76+
echo('Could not determine any upstream version to download Hermes tarball');
77+
return null;
78+
}
79+
80+
const mavenRepoUrl = 'https://repo1.maven.org/maven2';
81+
const namespace = 'com/facebook/react';
82+
83+
for (const version of candidates) {
84+
const releaseUrl = `${mavenRepoUrl}/${namespace}/react-native-artifacts/${version}/react-native-artifacts-${version}-hermes-ios-${buildType.toLowerCase()}.tar.gz`;
85+
const nightlyUrl = await computeNightlyTarballURL(
86+
version,
87+
buildType,
88+
'react-native-artifacts',
89+
`hermes-ios-${buildType.toLowerCase()}.tar.gz`,
90+
);
91+
const urlsToTry = [releaseUrl];
92+
if (nightlyUrl) {
93+
urlsToTry.push(nightlyUrl);
94+
}
95+
96+
for (const tarballUrl of urlsToTry) {
97+
echo(`Trying upstream Hermes tarball (version: ${version}, ${buildType}) at ${tarballUrl}...`);
98+
99+
try {
100+
const response = await fetch(tarballUrl);
101+
if (!response.ok) {
102+
echo(`Tarball not available: ${response.status} ${response.statusText}`);
103+
continue;
104+
}
105+
106+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-'));
107+
const tarballPath = path.join(tmpDir, 'hermes-ios.tar.gz');
108+
const buffer = await response.arrayBuffer();
109+
fs.writeFileSync(tarballPath, Buffer.from(buffer));
110+
111+
echo(`Downloaded upstream Hermes tarball (${version}) to ${tarballPath}`);
112+
return { tarballPath, version };
113+
} catch (e: any) {
114+
echo(`Error downloading tarball for ${version}: ${e.message}`);
115+
continue;
116+
}
117+
}
118+
}
119+
120+
echo('No upstream Hermes tarball found for any candidate version — will build from source.');
121+
return null;
122+
}
123+
124+
/**
125+
* Extracts an upstream Hermes tarball and recomposes the xcframework to include
126+
* the macOS slice, if needed.
127+
*
128+
* Upstream tarballs ship a universal xcframework (iOS, simulator, catalyst,
129+
* tvOS, visionOS) plus a standalone macosx/hermes.framework. This function
130+
* merges the standalone macOS framework into the universal xcframework using
131+
* `xcodebuild -create-xcframework`.
132+
*
133+
* NOTE: Once upstream Hermes includes macOS in the universal xcframework
134+
* natively, this function will detect the existing macOS slice and skip
135+
* the recompose. At that point, this step can be removed entirely.
136+
* Tracking PRs:
137+
* - https://github.com/facebook/hermes/pull/1958
138+
* - https://github.com/facebook/hermes/pull/1970
139+
* - https://github.com/facebook/hermes/pull/1971
140+
*/
141+
async function recomposeHermesXcframework(
142+
tarballPath: string,
143+
destroot: string,
144+
): Promise<boolean> {
145+
// Extract tarball
146+
fs.mkdirSync(destroot, { recursive: true });
147+
await $`tar -xzf ${tarballPath} -C ${destroot} --strip-components=2`;
148+
149+
const frameworksDir = path.join(destroot, 'Library', 'Frameworks');
150+
const xcfwPath = path.join(frameworksDir, 'universal', 'hermes.xcframework');
151+
152+
echo('Upstream tarball contents:');
153+
await $`ls -la ${frameworksDir}`;
154+
155+
// Check if macOS is already in the universal xcframework — if so, no recompose needed
156+
const xcfwContents = fs.readdirSync(xcfwPath);
157+
const hasMacSlice = xcfwContents.some(
158+
(entry: string) => entry.startsWith('macos') && entry.includes('arm64'),
159+
);
160+
if (hasMacSlice) {
161+
echo('macOS slice already present in universal xcframework, skipping recompose');
162+
const standaloneMacDir = path.join(frameworksDir, 'macosx');
163+
if (fs.existsSync(standaloneMacDir)) {
164+
fs.removeSync(standaloneMacDir);
165+
}
166+
return true;
167+
}
168+
169+
// Check for standalone macOS framework
170+
const standaloneMacFw = path.join(frameworksDir, 'macosx', 'hermes.framework');
171+
if (!fs.existsSync(standaloneMacFw)) {
172+
echo('ERROR: Upstream tarball missing macosx/hermes.framework');
173+
return false;
174+
}
175+
176+
// Collect existing frameworks from inside the universal xcframework
177+
const frameworkArgs: string[] = [];
178+
for (const entry of xcfwContents) {
179+
const fwPath = path.join(xcfwPath, entry, 'hermes.framework');
180+
if (fs.existsSync(fwPath) && fs.statSync(fwPath).isDirectory()) {
181+
echo(`Found slice: ${fwPath}`);
182+
frameworkArgs.push('-framework', fwPath);
183+
}
184+
}
185+
186+
// Add the standalone macOS framework
187+
echo(`Found standalone macOS slice: ${standaloneMacFw}`);
188+
frameworkArgs.push('-framework', standaloneMacFw);
189+
190+
// Build new xcframework at a temp path (frameworks reference paths inside the old xcfw)
191+
const xcfwNew = path.join(frameworksDir, 'universal', 'hermes-new.xcframework');
192+
const sliceCount = frameworkArgs.filter(f => f !== '-framework').length;
193+
echo(`Creating new universal xcframework with ${sliceCount} slices...`);
194+
await $`xcodebuild -create-xcframework ${frameworkArgs} -output ${xcfwNew} -allow-internal-distribution`;
195+
196+
// Swap in the recomposed xcframework
197+
fs.removeSync(xcfwPath);
198+
fs.renameSync(xcfwNew, xcfwPath);
199+
200+
// Clean up standalone macOS dir (now included in universal)
201+
fs.removeSync(path.join(frameworksDir, 'macosx'));
202+
203+
echo('Recomposed xcframework:');
204+
await $`ls -la ${xcfwPath}/`;
205+
206+
return true;
207+
}
208+
209+
// --- CLI dispatch ---
210+
211+
const { positionals } = parseArgs({
212+
allowPositionals: true,
213+
strict: false,
214+
});
215+
216+
const [command, ...args] = positionals;
217+
218+
switch (command) {
219+
case 'download-hermes': {
220+
const buildType = args[0] || 'Debug';
221+
const result = await downloadUpstreamHermesTarball(buildType);
222+
if (result != null) {
223+
setActionOutput('tarball', result.tarballPath);
224+
setActionOutput('version', result.version);
225+
echo(`Downloaded upstream Hermes tarball for version ${result.version}`);
226+
} else {
227+
echo('No upstream tarball available');
228+
}
229+
break;
230+
}
231+
case 'recompose-xcframework': {
232+
const [tarball, destroot] = args;
233+
if (!tarball || !destroot) {
234+
echo('Usage: node resolve-hermes.mts recompose-xcframework <tarball> <destroot>');
235+
process.exit(1);
236+
}
237+
const recomposed = await recomposeHermesXcframework(tarball, destroot);
238+
setActionOutput('recomposed', String(recomposed));
239+
break;
240+
}
241+
case 'resolve-commit': {
242+
const { commit } = hermesCommitAtMergeBase();
243+
setActionOutput('hermes-commit', commit);
244+
echo(`Resolved Hermes commit: ${commit}`);
245+
break;
246+
}
247+
default:
248+
echo(`Unknown command: ${command ?? '(none)'}. Available: download-hermes, recompose-xcframework, resolve-commit`);
249+
process.exit(1);
250+
}

.github/workflows/microsoft-pr.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,10 @@ jobs:
138138
permissions: {}
139139
uses: ./.github/workflows/microsoft-build-rntester.yml
140140

141-
build-spm:
142-
name: "Build SPM"
141+
prebuild-macos-core:
142+
name: "Prebuild macOS Core"
143143
permissions: {}
144-
uses: ./.github/workflows/microsoft-build-spm.yml
144+
uses: ./.github/workflows/microsoft-prebuild-macos-core.yml
145145

146146
test-react-native-macos-init:
147147
name: "Test react-native-macos init"
@@ -169,7 +169,7 @@ jobs:
169169
- yarn-constraints
170170
- javascript-tests
171171
- build-rntester
172-
- build-spm
172+
- prebuild-macos-core
173173
- test-react-native-macos-init
174174
# - react-native-test-app-integration
175175
steps:

0 commit comments

Comments
 (0)