Skip to content

Commit 6d4c48b

Browse files
Saadnajmiclaude
andcommitted
feat(ci): recompose upstream Hermes xcframework with macOS slice
Instead of building Hermes from source (~90 min), download the upstream tarball from Maven, extract frameworks from the universal xcframework, add the standalone macOS framework, and recompose a new xcframework that includes all platforms. Replaces the previous checkUpstreamHermesHasMacSlice approach (which only checked if macOS was already in the universal) with downloadUpstreamHermesTarball + recompose (which actively adds it). Build-from-source is kept as a fallback when no upstream tarball is available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7c1a307 commit 6d4c48b

2 files changed

Lines changed: 94 additions & 97 deletions

File tree

.github/workflows/microsoft-resolve-hermes.yml

Lines changed: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
outputs:
1212
hermes-commit: ${{ steps.resolve.outputs.hermes-commit }}
1313
cache-hit: ${{ steps.cache.outputs.cache-hit }}
14-
upstream-has-mac-slice: ${{ steps.check-upstream.outputs.upstream-has-mac-slice }}
14+
recomposed: ${{ steps.recompose.outputs.recomposed }}
1515
steps:
1616
- uses: actions/checkout@v4
1717
with:
@@ -27,41 +27,87 @@ jobs:
2727
- name: Install npm dependencies
2828
run: yarn install
2929

30-
- name: Check if upstream Hermes xcframework includes mac slice
31-
id: check-upstream
30+
- name: Download upstream Hermes tarball
31+
id: download
3232
working-directory: packages/react-native
3333
run: |
3434
node -e "
35-
const {checkUpstreamHermesHasMacSlice} = require('./scripts/ios-prebuild/macosVersionResolver');
36-
checkUpstreamHermesHasMacSlice('Debug').then(r => {
37-
require('fs').writeFileSync('/tmp/hermes-check-result.json', JSON.stringify(r));
35+
const {downloadUpstreamHermesTarball} = require('./scripts/ios-prebuild/macosVersionResolver');
36+
downloadUpstreamHermesTarball('Debug').then(r => {
37+
require('fs').writeFileSync('/tmp/hermes-download-result.json', JSON.stringify(r));
3838
});
3939
"
40-
RESULT=$(cat /tmp/hermes-check-result.json)
41-
HAS_MAC_SLICE=$(node -e "console.log(JSON.parse(process.argv[1]).hasMacSlice)" "$RESULT")
42-
echo "upstream-has-mac-slice=$HAS_MAC_SLICE" >> "$GITHUB_OUTPUT"
43-
echo "Upstream Hermes xcframework has mac slice: $HAS_MAC_SLICE"
44-
45-
if [ "$HAS_MAC_SLICE" = "true" ]; then
46-
TARBALL=$(node -e "console.log(JSON.parse(process.argv[1]).tarballPath || '')" "$RESULT")
47-
if [ -n "$TARBALL" ] && [ -f "$TARBALL" ]; then
48-
mkdir -p ${{ github.workspace }}/hermes-destroot
49-
tar -xzf "$TARBALL" -C ${{ github.workspace }}/hermes-destroot --strip-components=2
50-
echo "Extracted upstream Hermes to hermes-destroot"
51-
ls -la ${{ github.workspace }}/hermes-destroot/
40+
RESULT=$(cat /tmp/hermes-download-result.json)
41+
if [ "$RESULT" != "null" ]; then
42+
TARBALL=$(node -e "console.log(JSON.parse(process.argv[1]).tarballPath)" "$RESULT")
43+
VERSION=$(node -e "console.log(JSON.parse(process.argv[1]).version)" "$RESULT")
44+
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
45+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
46+
echo "Downloaded upstream Hermes tarball for version $VERSION"
47+
else
48+
echo "No upstream tarball available"
49+
fi
50+
51+
- name: Recompose xcframework with macOS slice
52+
id: recompose
53+
if: steps.download.outputs.tarball != ''
54+
run: |
55+
TARBALL="${{ steps.download.outputs.tarball }}"
56+
57+
# Extract tarball
58+
mkdir -p hermes-destroot
59+
tar -xzf "$TARBALL" -C hermes-destroot --strip-components=2
60+
61+
echo "=== Upstream tarball contents ==="
62+
ls -la hermes-destroot/Library/Frameworks/
63+
64+
# Collect existing frameworks from the universal xcframework
65+
XCFW="hermes-destroot/Library/Frameworks/universal/hermes.xcframework"
66+
FRAMEWORKS=()
67+
for fw in "$XCFW"/*/hermes.framework; do
68+
if [ -d "$fw" ]; then
69+
echo "Found slice: $fw"
70+
FRAMEWORKS+=(-framework "$fw")
5271
fi
72+
done
73+
74+
# Add standalone macOS framework
75+
MAC_FW="hermes-destroot/Library/Frameworks/macosx/hermes.framework"
76+
if [ -d "$MAC_FW" ]; then
77+
echo "Found standalone macOS slice: $MAC_FW"
78+
FRAMEWORKS+=(-framework "$MAC_FW")
79+
else
80+
echo "::error::Upstream tarball missing macosx/hermes.framework"
81+
echo "recomposed=false" >> "$GITHUB_OUTPUT"
82+
exit 0
5383
fi
5484
55-
- name: Upload upstream Hermes as artifact
56-
if: steps.check-upstream.outputs.upstream-has-mac-slice == 'true'
85+
# Remove old xcframework and create new one with macOS included
86+
rm -rf "$XCFW"
87+
echo "Creating new universal xcframework with ${#FRAMEWORKS[@]} frameworks..."
88+
xcodebuild -create-xcframework "${FRAMEWORKS[@]}" \
89+
-output "$XCFW" \
90+
-allow-internal-distribution
91+
92+
# Clean up standalone macOS dir (now included in universal)
93+
rm -rf hermes-destroot/Library/Frameworks/macosx
94+
95+
echo "=== Recomposed xcframework ==="
96+
ls -la "$XCFW"/
97+
98+
echo "recomposed=true" >> "$GITHUB_OUTPUT"
99+
100+
- name: Upload recomposed Hermes artifacts
101+
if: steps.recompose.outputs.recomposed == 'true'
57102
uses: actions/upload-artifact@v4
58103
with:
59104
name: hermes-artifacts
60105
path: hermes-destroot
61106
retention-days: 30
62107

108+
# Fallback: resolve Hermes commit for build-from-source
63109
- name: Resolve Hermes commit at merge base
64-
if: steps.check-upstream.outputs.upstream-has-mac-slice != 'true'
110+
if: steps.recompose.outputs.recomposed != 'true'
65111
id: resolve
66112
working-directory: packages/react-native
67113
run: |
@@ -70,15 +116,15 @@ jobs:
70116
echo "Resolved Hermes commit: $COMMIT"
71117
72118
- name: Restore Hermes cache
73-
if: steps.check-upstream.outputs.upstream-has-mac-slice != 'true'
119+
if: steps.recompose.outputs.recomposed != 'true'
74120
id: cache
75121
uses: actions/cache/restore@v4
76122
with:
77123
key: hermes-v1-${{ steps.resolve.outputs.hermes-commit }}-Debug
78124
path: hermes-destroot
79125

80126
- name: Upload cached Hermes artifacts
81-
if: steps.check-upstream.outputs.upstream-has-mac-slice != 'true' && steps.cache.outputs.cache-hit == 'true'
127+
if: steps.recompose.outputs.recomposed != 'true' && steps.cache.outputs.cache-hit == 'true'
82128
uses: actions/upload-artifact@v4
83129
with:
84130
name: hermes-artifacts
@@ -87,7 +133,7 @@ jobs:
87133

88134
build-hermesc:
89135
name: "Build hermesc"
90-
if: ${{ needs.resolve-hermes.outputs.upstream-has-mac-slice != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }}
136+
if: ${{ needs.resolve-hermes.outputs.recomposed != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }}
91137
needs: resolve-hermes
92138
runs-on: macos-15
93139
timeout-minutes: 30
@@ -128,7 +174,7 @@ jobs:
128174

129175
build-hermes-slice:
130176
name: "Hermes ${{ matrix.slice }}"
131-
if: ${{ needs.resolve-hermes.outputs.upstream-has-mac-slice != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }}
177+
if: ${{ needs.resolve-hermes.outputs.recomposed != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }}
132178
needs: [resolve-hermes, build-hermesc]
133179
runs-on: macos-15
134180
timeout-minutes: 45
@@ -196,7 +242,7 @@ jobs:
196242

197243
assemble-hermes:
198244
name: "Assemble Hermes xcframework"
199-
if: ${{ needs.resolve-hermes.outputs.upstream-has-mac-slice != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }}
245+
if: ${{ needs.resolve-hermes.outputs.recomposed != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }}
200246
needs: [resolve-hermes, build-hermes-slice]
201247
runs-on: macos-15
202248
timeout-minutes: 15

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

Lines changed: 22 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -187,26 +187,20 @@ async function getLatestStableVersionFromNPM() /*: Promise<string> */ {
187187
}
188188

189189
/**
190-
* Checks whether the upstream Hermes tarball (from Maven) already contains
191-
* macOS slices. If it does, we can skip building Hermes from source entirely.
190+
* Downloads the upstream Hermes tarball from Maven or Sonatype.
191+
* The caller is responsible for extracting and recomposing the
192+
* xcframework (e.g. adding the macOS slice to the universal).
192193
*
193194
* Tries multiple version resolution strategies in order:
194195
* 1. Mapped version from peerDependencies (stable branches)
195196
* 2. Version at merge base with facebook/react-native (main branch)
196197
* 3. Latest stable version from npm (last resort)
197198
*
198-
* Returns {hasMacSlice: boolean, tarballPath?: string, version?: string}.
199-
* When hasMacSlice is true, tarballPath points to the downloaded tarball and
200-
* version is the upstream version string used for the lookup.
201-
*
202-
* The check looks for a macOS platform entry inside the universal
203-
* hermes.xcframework (via its Info.plist), not just for a standalone
204-
* macosx/ directory. Upstream tarballs ship a standalone macosx/hermes.framework
205-
* but don't include it in the universal xcframework yet.
199+
* Returns {tarballPath, version} on success, or null if no tarball is available.
206200
*/
207-
async function checkUpstreamHermesHasMacSlice(
201+
async function downloadUpstreamHermesTarball(
208202
buildType /*: BuildFlavor */ = 'Debug',
209-
) /*: Promise<{| hasMacSlice: boolean, tarballPath?: string, version?: string |}> */ {
203+
) /*: Promise<?{| tarballPath: string, version: string |}> */ {
210204
const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json');
211205

212206
// Build a list of candidate versions to try (in priority order)
@@ -232,8 +226,10 @@ async function checkUpstreamHermesHasMacSlice(
232226
}
233227

234228
if (candidates.length === 0) {
235-
macosLog('Could not determine any upstream version to check Hermes tarball');
236-
return {hasMacSlice: false};
229+
macosLog(
230+
'Could not determine any upstream version to download Hermes tarball',
231+
);
232+
return null;
237233
}
238234

239235
const mavenRepoUrl = 'https://repo1.maven.org/maven2';
@@ -255,74 +251,29 @@ async function checkUpstreamHermesHasMacSlice(
255251

256252
for (const tarballUrl of urlsToTry) {
257253
macosLog(
258-
`Checking upstream Hermes tarball (version: ${version}, ${buildType}) at ${tarballUrl}...`,
254+
`Trying upstream Hermes tarball (version: ${version}, ${buildType}) at ${tarballUrl}...`,
259255
);
260256

261-
// Check if the tarball exists
262-
try {
263-
const headResponse = await fetch(tarballUrl, {method: 'HEAD'});
264-
if (headResponse.status !== 200) {
265-
macosLog(`Tarball not found, trying next URL...`);
266-
continue;
267-
}
268-
} catch (_) {
269-
macosLog('Failed to reach server, trying next URL...');
270-
continue;
271-
}
272-
273-
// Download the tarball to a temp directory
274-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-check-'));
275-
const tarballPath = path.join(tmpDir, 'hermes-ios.tar.gz');
276-
277257
try {
278-
macosLog(`Downloading upstream tarball...`);
279-
const response = await fetch(tarballUrl);
258+
const response /*: Response */ = await fetch(tarballUrl);
280259
if (!response.ok) {
281260
macosLog(
282-
`Download failed: ${response.status} ${response.statusText}`,
261+
`Tarball not available: ${response.status} ${response.statusText}`,
283262
);
284-
fs.rmSync(tmpDir, {recursive: true, force: true});
285263
continue;
286264
}
287265

266+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-'));
267+
const tarballPath = path.join(tmpDir, 'hermes-ios.tar.gz');
288268
const buffer = await response.arrayBuffer();
289269
fs.writeFileSync(tarballPath, Buffer.from(buffer));
290270

291-
// Extract the xcframework's Info.plist and check for a macOS
292-
// platform entry. We can't just look for a macosx/ directory in
293-
// the tarball — upstream ships a standalone macosx/hermes.framework
294-
// but doesn't include macOS in the universal xcframework yet.
295-
let hasMacSlice = false;
296-
try {
297-
const plist = execSync(
298-
`tar -xzf "${tarballPath}" -O --wildcards '*/universal/hermes.xcframework/Info.plist' 2>/dev/null`,
299-
{encoding: 'utf8', maxBuffer: 1024 * 1024},
300-
);
301-
hasMacSlice = plist.includes('macos') || plist.includes('macOS');
302-
} catch (_) {
303-
// Info.plist not found or extraction failed — no mac slice
304-
macosLog('Could not extract xcframework Info.plist from tarball.');
305-
}
306-
307-
if (hasMacSlice) {
308-
macosLog(
309-
`Upstream Hermes tarball (${version}) includes macOS in the universal xcframework — build from source can be skipped!`,
310-
);
311-
return {hasMacSlice: true, tarballPath, version};
312-
} else {
313-
macosLog(
314-
`Upstream Hermes tarball (${version}) does NOT include macOS in the universal xcframework.`,
315-
);
316-
fs.rmSync(tmpDir, {recursive: true, force: true});
317-
// Don't try other versions — if the tarball exists but lacks
318-
// the mac slice, older versions won't have it either.
319-
return {hasMacSlice: false};
320-
}
271+
macosLog(
272+
`Downloaded upstream Hermes tarball (${version}) to ${tarballPath}`,
273+
);
274+
return {tarballPath, version};
321275
} catch (e) {
322-
macosLog(`Error checking tarball for ${version}: ${e.message}`);
323-
try {
324-
fs.rmSync(tmpDir, {recursive: true, force: true});
325-
} catch (_) {}
276+
macosLog(`Error downloading tarball for ${version}: ${e.message}`);
326277
continue;
327278
}
328279
}
@@ -331,7 +282,7 @@ async function checkUpstreamHermesHasMacSlice(
331282
macosLog(
332283
'No upstream Hermes tarball found for any candidate version — will build from source.',
333284
);
334-
return {hasMacSlice: false};
285+
return null;
335286
}
336287

337288
function abort(message /*: string */) {
@@ -344,5 +295,5 @@ module.exports = {
344295
hermesCommitAtMergeBase,
345296
findVersionAtMergeBase,
346297
getLatestStableVersionFromNPM,
347-
checkUpstreamHermesHasMacSlice,
298+
downloadUpstreamHermesTarball,
348299
};

0 commit comments

Comments
 (0)