From 6af68ac33ad6f785282f1b793507c55bd2ae8137 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 15 Apr 2026 12:40:12 -0700 Subject: [PATCH 01/19] feat(ci): compose and cache prebuilt React xcframework from SPM build - Expand build-spm matrix to include ios-simulator and visionos-simulator - Upload slice artifacts and headers after each platform build - Add compose-xcframework job that assembles slices into React.xcframework Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-build-spm.yml | 83 ++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/.github/workflows/microsoft-build-spm.yml b/.github/workflows/microsoft-build-spm.yml index 255fba816c2..db27a4152ad 100644 --- a/.github/workflows/microsoft-build-spm.yml +++ b/.github/workflows/microsoft-build-spm.yml @@ -219,7 +219,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [ios, macos, visionos] + platform: [ios, ios-simulator, macos, visionos, visionos-simulator] steps: - uses: actions/checkout@v4 with: @@ -254,3 +254,84 @@ jobs: - name: Build SPM (${{ matrix.platform }}) working-directory: packages/react-native run: node scripts/ios-prebuild.js -b -f Debug -p ${{ matrix.platform }} + + - name: Upload headers + uses: actions/upload-artifact@v4 + with: + name: prebuild-macos-core-headers-Debug-${{ matrix.platform }} + path: packages/react-native/.build/headers + + - name: Upload slice artifacts + uses: actions/upload-artifact@v4 + with: + name: prebuild-macos-core-slice-Debug-${{ matrix.platform }} + path: packages/react-native/.build/output/spm/Debug/Build/Products + + compose-xcframework: + name: "Compose XCFramework (Debug)" + needs: [build-spm] + if: ${{ always() && !cancelled() && !failure() }} + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain + with: + node-version: '22' + platform: ios + + - name: Install npm dependencies + run: yarn install + + - name: Download slice artifacts + uses: actions/download-artifact@v4 + with: + pattern: prebuild-macos-core-slice-Debug-* + path: packages/react-native/.build/output/spm/Debug/Build/Products + merge-multiple: true + + - name: Download headers + uses: actions/download-artifact@v4 + with: + pattern: prebuild-macos-core-headers-Debug-* + path: packages/react-native/.build/headers + merge-multiple: true + + - name: Verify downloaded artifacts + run: | + echo "=== Products directory ===" + ls -R packages/react-native/.build/output/spm/Debug/Build/Products/ | head -40 + echo "=== Headers directory ===" + ls packages/react-native/.build/headers/ | head -20 + + - name: Create XCFramework + working-directory: packages/react-native + run: node scripts/ios-prebuild -c -f Debug + + - name: Compress XCFramework + run: | + cd packages/react-native/.build/output/xcframeworks/Debug + tar -cz -f ../ReactCoreDebug.xcframework.tar.gz React.xcframework + + - name: Compress dSYMs + run: | + cd packages/react-native/.build/output/xcframeworks/Debug/Symbols + tar -cz -f ../../ReactCoreDebug.framework.dSYM.tar.gz . + + - name: Upload XCFramework + uses: actions/upload-artifact@v4 + with: + name: ReactCoreDebug.xcframework.tar.gz + path: packages/react-native/.build/output/xcframeworks/ReactCoreDebug.xcframework.tar.gz + retention-days: 14 + + - name: Upload dSYMs + uses: actions/upload-artifact@v4 + with: + name: ReactCoreDebug.framework.dSYM.tar.gz + path: packages/react-native/.build/output/xcframeworks/ReactCoreDebug.framework.dSYM.tar.gz + retention-days: 14 From e64d7afd6a22c539746a12a99a1feadd1bf5d82c Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 15 Apr 2026 16:14:55 -0700 Subject: [PATCH 02/19] fix(ci): add macOS and visionOS to extractDestinationFromPath The upstream compose step only knew about iphoneos, iphonesimulator, and catalyst when copying dSYM symbols. Add xros, xrsimulator, and macosx so the xcframework compose succeeds with all platforms. Co-Authored-By: Claude Opus 4.6 --- .../scripts/ios-prebuild/xcframework.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index b57075acdf4..1e56f45ddb8 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -320,8 +320,24 @@ function extractDestinationFromPath(symbolPath /*: string */) /*: string */ { return 'catalyst'; } + // [macOS + // Check xrsimulator before xros since 'xrsimulator' contains 'xros' + if (symbolPath.includes('xrsimulator')) { + return 'xrsimulator'; + } + + if (symbolPath.includes('xros')) { + return 'xros'; + } + + // macOS SPM builds output to "Debug/" (no platform suffix) + if (symbolPath.includes('/Debug/') || symbolPath.includes('/Release/')) { + return 'macosx'; + } + // macOS] + throw new Error( - `Impossible to extract destination from ${symbolPath}. Valid destinations are iphoneos, iphonesimulator and catalyst.`, + `Impossible to extract destination from ${symbolPath}. Valid destinations are iphoneos, iphonesimulator, catalyst, xros, xrsimulator and macosx.`, ); } From 817c45a485d51093cda3c98550bd686920013a20 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 14:45:46 -0700 Subject: [PATCH 03/19] feat(ci): add content-hash caching for SPM slice and compose jobs - Cache slice builds keyed on source file hashes (Package.swift, ios-prebuild scripts, React/**, ReactCommon/**, Libraries/**) - Cache composed xcframework with same hash key - Skip toolchain setup, yarn install, and build steps on cache hit - Only save caches on main/0.81-stable to avoid cache explosion Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-build-spm.yml | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/microsoft-build-spm.yml b/.github/workflows/microsoft-build-spm.yml index db27a4152ad..18daf54fa42 100644 --- a/.github/workflows/microsoft-build-spm.yml +++ b/.github/workflows/microsoft-build-spm.yml @@ -226,35 +226,59 @@ jobs: filter: blob:none fetch-depth: 0 + - name: Restore slice cache + id: cache-slice + uses: actions/cache/restore@v4 + with: + key: v1-macos-core-${{ matrix.platform }}-Debug-${{ 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/**/*') }} + path: | + packages/react-native/.build/output/spm/Debug/Build/Products + packages/react-native/.build/headers + - name: Setup toolchain + if: steps.cache-slice.outputs.cache-hit != 'true' uses: ./.github/actions/microsoft-setup-toolchain with: node-version: '22' platform: ${{ matrix.platform }} - name: Install npm dependencies + if: steps.cache-slice.outputs.cache-hit != 'true' run: yarn install - name: Download Hermes artifacts + if: steps.cache-slice.outputs.cache-hit != 'true' uses: actions/download-artifact@v4 with: name: hermes-artifacts path: packages/react-native/.build/artifacts/hermes/destroot - name: Create Hermes version marker + if: steps.cache-slice.outputs.cache-hit != 'true' working-directory: packages/react-native run: | VERSION=$(node -p "require('./package.json').version") echo "${VERSION}-Debug" > .build/artifacts/hermes/version.txt - name: Setup SPM workspace (using prebuilt Hermes) + if: steps.cache-slice.outputs.cache-hit != 'true' working-directory: packages/react-native run: node scripts/ios-prebuild.js -s -f Debug - name: Build SPM (${{ matrix.platform }}) + if: steps.cache-slice.outputs.cache-hit != 'true' working-directory: packages/react-native run: node scripts/ios-prebuild.js -b -f Debug -p ${{ matrix.platform }} + - name: Save slice cache + if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/0.81-stable' }} + uses: actions/cache/save@v4 + with: + key: v1-macos-core-${{ matrix.platform }}-Debug-${{ 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/**/*') }} + path: | + packages/react-native/.build/output/spm/Debug/Build/Products + packages/react-native/.build/headers + - name: Upload headers uses: actions/upload-artifact@v4 with: @@ -278,16 +302,28 @@ jobs: with: filter: blob:none + - name: Restore compose cache + id: cache-xcframework + uses: actions/cache/restore@v4 + with: + key: v1-macos-core-xcframework-Debug-${{ 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/**/*') }} + path: | + packages/react-native/.build/output/xcframeworks/ReactCoreDebug.xcframework.tar.gz + packages/react-native/.build/output/xcframeworks/ReactCoreDebug.framework.dSYM.tar.gz + - name: Setup toolchain + if: steps.cache-xcframework.outputs.cache-hit != 'true' uses: ./.github/actions/microsoft-setup-toolchain with: node-version: '22' platform: ios - name: Install npm dependencies + if: steps.cache-xcframework.outputs.cache-hit != 'true' run: yarn install - name: Download slice artifacts + if: steps.cache-xcframework.outputs.cache-hit != 'true' uses: actions/download-artifact@v4 with: pattern: prebuild-macos-core-slice-Debug-* @@ -295,6 +331,7 @@ jobs: merge-multiple: true - name: Download headers + if: steps.cache-xcframework.outputs.cache-hit != 'true' uses: actions/download-artifact@v4 with: pattern: prebuild-macos-core-headers-Debug-* @@ -302,6 +339,7 @@ jobs: merge-multiple: true - name: Verify downloaded artifacts + if: steps.cache-xcframework.outputs.cache-hit != 'true' run: | echo "=== Products directory ===" ls -R packages/react-native/.build/output/spm/Debug/Build/Products/ | head -40 @@ -309,19 +347,31 @@ jobs: ls packages/react-native/.build/headers/ | head -20 - name: Create XCFramework + if: steps.cache-xcframework.outputs.cache-hit != 'true' working-directory: packages/react-native run: node scripts/ios-prebuild -c -f Debug - name: Compress XCFramework + if: steps.cache-xcframework.outputs.cache-hit != 'true' run: | cd packages/react-native/.build/output/xcframeworks/Debug tar -cz -f ../ReactCoreDebug.xcframework.tar.gz React.xcframework - name: Compress dSYMs + if: steps.cache-xcframework.outputs.cache-hit != 'true' run: | cd packages/react-native/.build/output/xcframeworks/Debug/Symbols tar -cz -f ../../ReactCoreDebug.framework.dSYM.tar.gz . + - name: Save compose cache + if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/0.81-stable' }} + uses: actions/cache/save@v4 + with: + key: v1-macos-core-xcframework-Debug-${{ 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/**/*') }} + path: | + packages/react-native/.build/output/xcframeworks/ReactCoreDebug.xcframework.tar.gz + packages/react-native/.build/output/xcframeworks/ReactCoreDebug.framework.dSYM.tar.gz + - name: Upload XCFramework uses: actions/upload-artifact@v4 with: From 95e3d7c0deae95dd932ea88f0dd696ac8c43a31f Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 15:00:26 -0700 Subject: [PATCH 04/19] feat(ci): skip Hermes build-from-source when upstream tarball has macOS slices When upstream Hermes publishes tarballs that already include macOS frameworks, the entire build-from-source pipeline (hermesc, 5 slices, assemble) can be skipped, saving ~90 minutes of CI time. The check downloads the upstream tarball via Maven/Sonatype, inspects it for a macosx/ slice, and if found uploads it directly as the hermes-artifacts artifact. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-build-spm.yml | 46 +++++- .../ios-prebuild/macosVersionResolver.js | 142 +++++++++++++++++- 2 files changed, 182 insertions(+), 6 deletions(-) diff --git a/.github/workflows/microsoft-build-spm.yml b/.github/workflows/microsoft-build-spm.yml index 18daf54fa42..c1c5927e45c 100644 --- a/.github/workflows/microsoft-build-spm.yml +++ b/.github/workflows/microsoft-build-spm.yml @@ -7,10 +7,11 @@ jobs: resolve-hermes: name: "Resolve Hermes" runs-on: macos-15 - timeout-minutes: 10 + timeout-minutes: 15 outputs: hermes-commit: ${{ steps.resolve.outputs.hermes-commit }} cache-hit: ${{ steps.cache.outputs.cache-hit }} + upstream-has-macos: ${{ steps.check-upstream.outputs.upstream-has-macos }} steps: - uses: actions/checkout@v4 with: @@ -30,7 +31,41 @@ jobs: - name: Install npm dependencies run: yarn install + - name: Check if upstream Hermes includes macOS slices + id: check-upstream + working-directory: packages/react-native + run: | + node -e " + const {checkUpstreamHermesHasMacOS} = require('./scripts/ios-prebuild/macosVersionResolver'); + checkUpstreamHermesHasMacOS('Debug').then(r => { + require('fs').writeFileSync('/tmp/hermes-check-result.json', JSON.stringify(r)); + }); + " + RESULT=$(cat /tmp/hermes-check-result.json) + HAS_MACOS=$(node -e "console.log(JSON.parse(process.argv[1]).hasMacOS)" "$RESULT") + echo "upstream-has-macos=$HAS_MACOS" >> "$GITHUB_OUTPUT" + echo "Upstream Hermes has macOS slices: $HAS_MACOS" + + if [ "$HAS_MACOS" = "true" ]; then + TARBALL=$(node -e "console.log(JSON.parse(process.argv[1]).tarballPath || '')" "$RESULT") + if [ -n "$TARBALL" ] && [ -f "$TARBALL" ]; then + mkdir -p ${{ github.workspace }}/hermes-destroot + tar -xzf "$TARBALL" -C ${{ github.workspace }}/hermes-destroot --strip-components=2 + echo "Extracted upstream Hermes to hermes-destroot" + ls -la ${{ github.workspace }}/hermes-destroot/ + fi + fi + + - name: Upload upstream Hermes as artifact + if: steps.check-upstream.outputs.upstream-has-macos == 'true' + uses: actions/upload-artifact@v4 + with: + name: hermes-artifacts + path: hermes-destroot + retention-days: 30 + - name: Resolve Hermes commit at merge base + if: steps.check-upstream.outputs.upstream-has-macos != 'true' id: resolve working-directory: packages/react-native run: | @@ -39,6 +74,7 @@ jobs: echo "Resolved Hermes commit: $COMMIT" - name: Restore Hermes cache + if: steps.check-upstream.outputs.upstream-has-macos != 'true' id: cache uses: actions/cache/restore@v4 with: @@ -46,7 +82,7 @@ jobs: path: hermes-destroot - name: Upload cached Hermes artifacts - if: steps.cache.outputs.cache-hit == 'true' + if: steps.check-upstream.outputs.upstream-has-macos != 'true' && steps.cache.outputs.cache-hit == 'true' uses: actions/upload-artifact@v4 with: name: hermes-artifacts @@ -55,7 +91,7 @@ jobs: build-hermesc: name: "Build hermesc" - if: ${{ needs.resolve-hermes.outputs.cache-hit != 'true' }} + if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} needs: resolve-hermes runs-on: macos-15 timeout-minutes: 30 @@ -93,7 +129,7 @@ jobs: build-hermes-slice: name: "Hermes ${{ matrix.slice }}" - if: ${{ needs.resolve-hermes.outputs.cache-hit != 'true' }} + if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} needs: [resolve-hermes, build-hermesc] runs-on: macos-15 timeout-minutes: 45 @@ -155,7 +191,7 @@ jobs: assemble-hermes: name: "Assemble Hermes xcframework" - if: ${{ needs.resolve-hermes.outputs.cache-hit != 'true' }} + if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} needs: [resolve-hermes, build-hermes-slice] runs-on: macos-15 timeout-minutes: 15 diff --git a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js index 70b85d3cebe..602d9fc6043 100644 --- a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js +++ b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js @@ -10,7 +10,7 @@ * @format */ -const {createLogger} = require('./utils'); +const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); const os = require('os'); @@ -184,6 +184,145 @@ async function getLatestStableVersionFromNPM() /*: Promise */ { return json.version; } +/** + * Checks whether the upstream Hermes tarball (from Maven) already contains + * macOS slices. If it does, we can skip building Hermes from source entirely. + * + * Tries multiple version resolution strategies in order: + * 1. Mapped version from peerDependencies (stable branches) + * 2. Version at merge base with facebook/react-native (main branch) + * 3. Latest stable version from npm (last resort) + * + * Returns {hasMacOS: boolean, tarballPath?: string, version?: string}. + * When hasMacOS is true, tarballPath points to the downloaded tarball and + * version is the upstream version string used for the lookup. + */ +async function checkUpstreamHermesHasMacOS( + buildType /*: string */ = 'Debug', +) /*: Promise<{| hasMacOS: boolean, tarballPath?: string, version?: string |}> */ { + const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); + + // Build a list of candidate versions to try (in priority order) + const candidates /*: string[] */ = []; + + const mapped = findMatchingHermesVersion(packageJsonPath); + if (mapped != null) { + candidates.push(mapped); + } + + const mergeBaseVersion = findVersionAtMergeBase(); + if (mergeBaseVersion != null && !candidates.includes(mergeBaseVersion)) { + candidates.push(mergeBaseVersion); + } + + try { + const latestStable = await getLatestStableVersionFromNPM(); + if (!candidates.includes(latestStable)) { + candidates.push(latestStable); + } + } catch (_) { + // npm lookup failed, continue with what we have + } + + if (candidates.length === 0) { + macosLog('Could not determine any upstream version to check Hermes tarball'); + return {hasMacOS: false}; + } + + const mavenRepoUrl = 'https://repo1.maven.org/maven2'; + const namespace = 'com/facebook/react'; + + for (const version of candidates) { + // Try both Maven release and nightly (Sonatype snapshot) URLs + const releaseUrl = `${mavenRepoUrl}/${namespace}/react-native-artifacts/${version}/react-native-artifacts-${version}-hermes-ios-${buildType.toLowerCase()}.tar.gz`; + const nightlyUrl = await computeNightlyTarballURL( + version, + buildType, + 'react-native-artifacts', + `hermes-ios-${buildType.toLowerCase()}.tar.gz`, + ); + const urlsToTry = [releaseUrl]; + if (nightlyUrl) { + urlsToTry.push(nightlyUrl); + } + + for (const tarballUrl of urlsToTry) { + macosLog( + `Checking upstream Hermes tarball (version: ${version}, ${buildType}) at ${tarballUrl}...`, + ); + + // Check if the tarball exists + try { + const headResponse = await fetch(tarballUrl, {method: 'HEAD'}); + if (headResponse.status !== 200) { + macosLog(`Tarball not found, trying next URL...`); + continue; + } + } catch (_) { + macosLog('Failed to reach server, trying next URL...'); + continue; + } + + // Download the tarball to a temp directory + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-check-')); + const tarballPath = path.join(tmpDir, 'hermes-ios.tar.gz'); + + try { + macosLog(`Downloading upstream tarball...`); + const response = await fetch(tarballUrl); + if (!response.ok) { + macosLog( + `Download failed: ${response.status} ${response.statusText}`, + ); + fs.rmSync(tmpDir, {recursive: true, force: true}); + continue; + } + + const buffer = await response.arrayBuffer(); + fs.writeFileSync(tarballPath, Buffer.from(buffer)); + + // List tarball contents and check for macosx slice + const listing = execSync(`tar -tzf "${tarballPath}" 2>/dev/null`, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + const hasMacOS = listing + .split('\n') + .some( + entry => entry.includes('/macosx/') || entry.includes('/macosx'), + ); + + if (hasMacOS) { + macosLog( + `Upstream Hermes tarball (${version}) contains macOS slices — build from source can be skipped!`, + ); + return {hasMacOS: true, tarballPath, version}; + } else { + macosLog( + `Upstream Hermes tarball (${version}) does NOT contain macOS slices.`, + ); + fs.rmSync(tmpDir, {recursive: true, force: true}); + // Don't try other versions — if the tarball exists but lacks macOS, + // older versions won't help since macOS was always included. + return {hasMacOS: false}; + } + } catch (e) { + macosLog(`Error checking tarball for ${version}: ${e.message}`); + try { + fs.rmSync(tmpDir, {recursive: true, force: true}); + } catch (_) {} + continue; + } + } + } + + macosLog( + 'No upstream Hermes tarball found for any candidate version — will build from source.', + ); + return {hasMacOS: false}; +} + function abort(message /*: string */) { macosLog(message, 'error'); throw new Error(message); @@ -194,4 +333,5 @@ module.exports = { hermesCommitAtMergeBase, findVersionAtMergeBase, getLatestStableVersionFromNPM, + checkUpstreamHermesHasMacOS, }; From 3ea3c9a3ac17bef10603d327c4a827993c652818 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:10:45 -0700 Subject: [PATCH 05/19] fix(ci): use mapped upstream version for Hermes version marker The version marker was using package.json version but prepareHermesArtifactsAsync resolves via peerDependencies. The mismatch caused the setup step to delete our prebuilt Hermes artifacts and re-download from Maven, which lacks macOS slices in the universal xcframework. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-build-spm.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/microsoft-build-spm.yml b/.github/workflows/microsoft-build-spm.yml index c1c5927e45c..d59c5ae6729 100644 --- a/.github/workflows/microsoft-build-spm.yml +++ b/.github/workflows/microsoft-build-spm.yml @@ -293,12 +293,13 @@ jobs: if: steps.cache-slice.outputs.cache-hit != 'true' working-directory: packages/react-native run: | - VERSION=$(node -p "require('./package.json').version") - echo "${VERSION}-Debug" > .build/artifacts/hermes/version.txt + echo "prebuilt-Debug" > .build/artifacts/hermes/version.txt - name: Setup SPM workspace (using prebuilt Hermes) if: steps.cache-slice.outputs.cache-hit != 'true' working-directory: packages/react-native + env: + HERMES_VERSION: prebuilt run: node scripts/ios-prebuild.js -s -f Debug - name: Build SPM (${{ matrix.platform }}) From 821930a6e56d4584e61c3e3da64369ce0b84d90b Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:36:44 -0700 Subject: [PATCH 06/19] ci: rename Build SwiftPM workflow to Prebuild macOS Core Aligns naming with upstream's convention (prebuild-ios-core.yml). SPM is an implementation detail; the workflow name should describe what it produces, not how. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-pr.yml | 8 ++++---- ...ild-spm.yml => microsoft-prebuild-macos-core.yml} | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) rename .github/workflows/{microsoft-build-spm.yml => microsoft-prebuild-macos-core.yml} (98%) diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index c9f4fc9e60e..dd38b720e2d 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -138,10 +138,10 @@ jobs: permissions: {} uses: ./.github/workflows/microsoft-build-rntester.yml - build-spm: - name: "Build SPM" + prebuild-macos-core: + name: "Prebuild macOS Core" permissions: {} - uses: ./.github/workflows/microsoft-build-spm.yml + uses: ./.github/workflows/microsoft-prebuild-macos-core.yml test-react-native-macos-init: name: "Test react-native-macos init" @@ -168,7 +168,7 @@ jobs: - yarn-constraints - javascript-tests - build-rntester - - build-spm + - prebuild-macos-core - test-react-native-macos-init # - react-native-test-app-integration steps: diff --git a/.github/workflows/microsoft-build-spm.yml b/.github/workflows/microsoft-prebuild-macos-core.yml similarity index 98% rename from .github/workflows/microsoft-build-spm.yml rename to .github/workflows/microsoft-prebuild-macos-core.yml index d59c5ae6729..380ece8484b 100644 --- a/.github/workflows/microsoft-build-spm.yml +++ b/.github/workflows/microsoft-prebuild-macos-core.yml @@ -1,4 +1,4 @@ -name: Build SwiftPM +name: Prebuild macOS Core on: workflow_call: @@ -245,8 +245,8 @@ jobs: path: hermes/destroot retention-days: 30 - build-spm: - name: "SPM ${{ matrix.platform }}" + build: + name: "Build ${{ matrix.platform }}" needs: [resolve-hermes, assemble-hermes] # Run when upstream jobs succeeded or were skipped (cache hit) if: ${{ always() && !cancelled() && !failure() }} @@ -295,14 +295,14 @@ jobs: run: | echo "prebuilt-Debug" > .build/artifacts/hermes/version.txt - - name: Setup SPM workspace (using prebuilt Hermes) + - name: Setup workspace (using prebuilt Hermes) if: steps.cache-slice.outputs.cache-hit != 'true' working-directory: packages/react-native env: HERMES_VERSION: prebuilt run: node scripts/ios-prebuild.js -s -f Debug - - name: Build SPM (${{ matrix.platform }}) + - name: Build (${{ matrix.platform }}) if: steps.cache-slice.outputs.cache-hit != 'true' working-directory: packages/react-native run: node scripts/ios-prebuild.js -b -f Debug -p ${{ matrix.platform }} @@ -330,7 +330,7 @@ jobs: compose-xcframework: name: "Compose XCFramework (Debug)" - needs: [build-spm] + needs: [build] if: ${{ always() && !cancelled() && !failure() }} runs-on: macos-26 timeout-minutes: 30 From 25cd3936182fda0373d0951c8dcdd4c0c42363bc Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:42:29 -0700 Subject: [PATCH 07/19] ci: use microsoft-setup-toolchain for Hermes build jobs Replace manual Xcode and Node.js setup in resolve-hermes, build-hermesc, and build-hermes-slice with the shared microsoft-setup-toolchain action for version consistency. Co-Authored-By: Claude Opus 4.6 --- .../microsoft-prebuild-macos-core.yml | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/.github/workflows/microsoft-prebuild-macos-core.yml b/.github/workflows/microsoft-prebuild-macos-core.yml index 380ece8484b..a140419f3d1 100644 --- a/.github/workflows/microsoft-prebuild-macos-core.yml +++ b/.github/workflows/microsoft-prebuild-macos-core.yml @@ -18,15 +18,11 @@ jobs: filter: blob:none fetch-depth: 0 - - name: Setup Xcode - run: sudo xcode-select --switch /Applications/Xcode_16.2.app - - - name: Set up Node.js - uses: actions/setup-node@v4.4.0 + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain with: node-version: '22' - cache: yarn - registry-url: https://registry.npmjs.org + platform: macos - name: Install npm dependencies run: yarn install @@ -100,8 +96,11 @@ jobs: with: filter: blob:none - - name: Setup Xcode - run: sudo xcode-select --switch /Applications/Xcode_16.2.app + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain + with: + platform: macos + cache-npm-dependencies: '' - name: Clone Hermes uses: actions/checkout@v4 @@ -137,21 +136,27 @@ jobs: fail-fast: false matrix: slice: [iphoneos, iphonesimulator, macosx, xros, xrsimulator] + include: + - slice: iphoneos + platform: ios + - slice: iphonesimulator + platform: ios + - slice: macosx + platform: macos + - slice: xros + platform: visionos + - slice: xrsimulator + platform: visionos steps: - uses: actions/checkout@v4 with: filter: blob:none - - name: Setup Xcode - run: sudo xcode-select --switch /Applications/Xcode_16.2.app - - - name: Download visionOS SDK - if: ${{ matrix.slice == 'xros' || matrix.slice == 'xrsimulator' }} - run: | - sudo xcodebuild -runFirstLaunch - sudo xcrun simctl list - sudo xcodebuild -downloadPlatform visionOS - sudo xcodebuild -runFirstLaunch + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain + with: + platform: ${{ matrix.platform }} + cache-npm-dependencies: '' - name: Clone Hermes uses: actions/checkout@v4 From 5b59e955e7f16839a3d5e11854fa0b63d447a61e Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:50:47 -0700 Subject: [PATCH 08/19] fix: use BuildFlavor type for checkUpstreamHermesHasMacOS parameter Flow was erroring because buildType was typed as string but computeNightlyTarballURL expects BuildFlavor ('Debug' | 'Release'). Co-Authored-By: Claude Opus 4.6 --- .../react-native/scripts/ios-prebuild/macosVersionResolver.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js index 602d9fc6043..fbef6c4f090 100644 --- a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js +++ b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js @@ -10,6 +10,8 @@ * @format */ +/*:: import type {BuildFlavor} from './types'; */ + const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); @@ -198,7 +200,7 @@ async function getLatestStableVersionFromNPM() /*: Promise */ { * version is the upstream version string used for the lookup. */ async function checkUpstreamHermesHasMacOS( - buildType /*: string */ = 'Debug', + buildType /*: BuildFlavor */ = 'Debug', ) /*: Promise<{| hasMacOS: boolean, tarballPath?: string, version?: string |}> */ { const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); From 2062b5842d82493103ecb5e12c18b3ada1d403e8 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:57:28 -0700 Subject: [PATCH 09/19] ci: extract Hermes build into separate reusable workflow Move resolve-hermes, build-hermesc, build-hermes-slice, and assemble-hermes into microsoft-build-hermes.yml. The prebuild workflow now calls it as a single dependency, cleanly separating Hermes compilation from the React Native prebuild pipeline. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-build-hermes.yml | 251 +++++++++++++++++ .../microsoft-prebuild-macos-core.yml | 252 +----------------- 2 files changed, 255 insertions(+), 248 deletions(-) create mode 100644 .github/workflows/microsoft-build-hermes.yml diff --git a/.github/workflows/microsoft-build-hermes.yml b/.github/workflows/microsoft-build-hermes.yml new file mode 100644 index 00000000000..2642f60a549 --- /dev/null +++ b/.github/workflows/microsoft-build-hermes.yml @@ -0,0 +1,251 @@ +name: Build Hermes + +on: + workflow_call: + +jobs: + resolve-hermes: + name: "Resolve Hermes" + runs-on: macos-15 + timeout-minutes: 15 + outputs: + hermes-commit: ${{ steps.resolve.outputs.hermes-commit }} + cache-hit: ${{ steps.cache.outputs.cache-hit }} + upstream-has-macos: ${{ steps.check-upstream.outputs.upstream-has-macos }} + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 0 + + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain + with: + node-version: '22' + platform: macos + + - name: Install npm dependencies + run: yarn install + + - name: Check if upstream Hermes includes macOS slices + id: check-upstream + working-directory: packages/react-native + run: | + node -e " + const {checkUpstreamHermesHasMacOS} = require('./scripts/ios-prebuild/macosVersionResolver'); + checkUpstreamHermesHasMacOS('Debug').then(r => { + require('fs').writeFileSync('/tmp/hermes-check-result.json', JSON.stringify(r)); + }); + " + RESULT=$(cat /tmp/hermes-check-result.json) + HAS_MACOS=$(node -e "console.log(JSON.parse(process.argv[1]).hasMacOS)" "$RESULT") + echo "upstream-has-macos=$HAS_MACOS" >> "$GITHUB_OUTPUT" + echo "Upstream Hermes has macOS slices: $HAS_MACOS" + + if [ "$HAS_MACOS" = "true" ]; then + TARBALL=$(node -e "console.log(JSON.parse(process.argv[1]).tarballPath || '')" "$RESULT") + if [ -n "$TARBALL" ] && [ -f "$TARBALL" ]; then + mkdir -p ${{ github.workspace }}/hermes-destroot + tar -xzf "$TARBALL" -C ${{ github.workspace }}/hermes-destroot --strip-components=2 + echo "Extracted upstream Hermes to hermes-destroot" + ls -la ${{ github.workspace }}/hermes-destroot/ + fi + fi + + - name: Upload upstream Hermes as artifact + if: steps.check-upstream.outputs.upstream-has-macos == 'true' + uses: actions/upload-artifact@v4 + with: + name: hermes-artifacts + path: hermes-destroot + retention-days: 30 + + - name: Resolve Hermes commit at merge base + if: steps.check-upstream.outputs.upstream-has-macos != 'true' + id: resolve + working-directory: packages/react-native + run: | + COMMIT=$(node -e "const {hermesCommitAtMergeBase} = require('./scripts/ios-prebuild/macosVersionResolver'); console.log(hermesCommitAtMergeBase().commit);" 2>&1 | grep -E '^[0-9a-f]{40}$') + echo "hermes-commit=$COMMIT" >> "$GITHUB_OUTPUT" + echo "Resolved Hermes commit: $COMMIT" + + - name: Restore Hermes cache + if: steps.check-upstream.outputs.upstream-has-macos != 'true' + id: cache + uses: actions/cache/restore@v4 + with: + key: hermes-v1-${{ steps.resolve.outputs.hermes-commit }}-Debug + path: hermes-destroot + + - name: Upload cached Hermes artifacts + if: steps.check-upstream.outputs.upstream-has-macos != 'true' && steps.cache.outputs.cache-hit == 'true' + uses: actions/upload-artifact@v4 + with: + name: hermes-artifacts + path: hermes-destroot + retention-days: 30 + + build-hermesc: + name: "Build hermesc" + if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} + needs: resolve-hermes + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain + with: + platform: macos + cache-npm-dependencies: '' + + - name: Clone Hermes + uses: actions/checkout@v4 + with: + repository: facebook/hermes + ref: ${{ needs.resolve-hermes.outputs.hermes-commit }} + path: hermes + + - name: Build hermesc + working-directory: hermes + env: + HERMES_PATH: ${{ github.workspace }}/hermes + JSI_PATH: ${{ github.workspace }}/hermes/API/jsi + MAC_DEPLOYMENT_TARGET: '14.0' + run: | + source $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh + build_host_hermesc + + - name: Upload hermesc artifact + uses: actions/upload-artifact@v4 + with: + name: hermesc + path: hermes/build_host_hermesc + retention-days: 30 + + build-hermes-slice: + name: "Hermes ${{ matrix.slice }}" + if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} + needs: [resolve-hermes, build-hermesc] + runs-on: macos-15 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + slice: [iphoneos, iphonesimulator, macosx, xros, xrsimulator] + include: + - slice: iphoneos + platform: ios + - slice: iphonesimulator + platform: ios + - slice: macosx + platform: macos + - slice: xros + platform: visionos + - slice: xrsimulator + platform: visionos + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain + with: + platform: ${{ matrix.platform }} + cache-npm-dependencies: '' + + - name: Clone Hermes + uses: actions/checkout@v4 + with: + repository: facebook/hermes + ref: ${{ needs.resolve-hermes.outputs.hermes-commit }} + path: hermes + + - name: Download hermesc + uses: actions/download-artifact@v4 + with: + name: hermesc + path: hermes/build_host_hermesc + + - name: Restore hermesc permissions + run: chmod +x ${{ github.workspace }}/hermes/build_host_hermesc/bin/hermesc + + - name: Build Hermes slice (${{ matrix.slice }}) + working-directory: hermes + env: + BUILD_TYPE: Debug + HERMES_PATH: ${{ github.workspace }}/hermes + JSI_PATH: ${{ github.workspace }}/hermes/API/jsi + IOS_DEPLOYMENT_TARGET: '15.1' + MAC_DEPLOYMENT_TARGET: '14.0' + XROS_DEPLOYMENT_TARGET: '1.0' + RELEASE_VERSION: '1000.0.0' + run: | + bash $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh "${{ matrix.slice }}" + + - name: Upload slice artifact + uses: actions/upload-artifact@v4 + with: + name: hermes-slice-${{ matrix.slice }} + path: hermes/destroot + retention-days: 30 + + assemble-hermes: + name: "Assemble Hermes xcframework" + if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} + needs: [resolve-hermes, build-hermes-slice] + runs-on: macos-15 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + + - name: Download all slice artifacts + uses: actions/download-artifact@v4 + with: + pattern: hermes-slice-* + path: /tmp/slices + + - name: Assemble destroot from slices + run: | + mkdir -p ${{ github.workspace }}/hermes/destroot/Library/Frameworks + for slice_dir in /tmp/slices/hermes-slice-*; do + slice_name=$(basename "$slice_dir" | sed 's/hermes-slice-//') + echo "Copying slice: $slice_name" + cp -R "$slice_dir/Library/Frameworks/$slice_name" ${{ github.workspace }}/hermes/destroot/Library/Frameworks/ + # Copy include and bin directories (identical across slices, only need one copy) + if [ -d "$slice_dir/include" ] && [ ! -d ${{ github.workspace }}/hermes/destroot/include ]; then + cp -R "$slice_dir/include" ${{ github.workspace }}/hermes/destroot/ + fi + if [ -d "$slice_dir/bin" ]; then + cp -R "$slice_dir/bin" ${{ github.workspace }}/hermes/destroot/ + fi + done + echo "Assembled destroot contents:" + ls -la ${{ github.workspace }}/hermes/destroot/Library/Frameworks/ + + - name: Create universal xcframework + working-directory: hermes + env: + HERMES_PATH: ${{ github.workspace }}/hermes + run: | + source $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh + create_universal_framework "iphoneos" "iphonesimulator" "macosx" "xros" "xrsimulator" + + - name: Save Hermes cache + uses: actions/cache/save@v4 + with: + key: hermes-v1-${{ needs.resolve-hermes.outputs.hermes-commit }}-Debug + path: hermes/destroot + + - name: Upload Hermes artifacts + uses: actions/upload-artifact@v4 + with: + name: hermes-artifacts + path: hermes/destroot + retention-days: 30 diff --git a/.github/workflows/microsoft-prebuild-macos-core.yml b/.github/workflows/microsoft-prebuild-macos-core.yml index a140419f3d1..1b4f226c1c1 100644 --- a/.github/workflows/microsoft-prebuild-macos-core.yml +++ b/.github/workflows/microsoft-prebuild-macos-core.yml @@ -4,257 +4,13 @@ on: workflow_call: jobs: - resolve-hermes: - name: "Resolve Hermes" - runs-on: macos-15 - timeout-minutes: 15 - outputs: - hermes-commit: ${{ steps.resolve.outputs.hermes-commit }} - cache-hit: ${{ steps.cache.outputs.cache-hit }} - upstream-has-macos: ${{ steps.check-upstream.outputs.upstream-has-macos }} - steps: - - uses: actions/checkout@v4 - with: - filter: blob:none - fetch-depth: 0 - - - name: Setup toolchain - uses: ./.github/actions/microsoft-setup-toolchain - with: - node-version: '22' - platform: macos - - - name: Install npm dependencies - run: yarn install - - - name: Check if upstream Hermes includes macOS slices - id: check-upstream - working-directory: packages/react-native - run: | - node -e " - const {checkUpstreamHermesHasMacOS} = require('./scripts/ios-prebuild/macosVersionResolver'); - checkUpstreamHermesHasMacOS('Debug').then(r => { - require('fs').writeFileSync('/tmp/hermes-check-result.json', JSON.stringify(r)); - }); - " - RESULT=$(cat /tmp/hermes-check-result.json) - HAS_MACOS=$(node -e "console.log(JSON.parse(process.argv[1]).hasMacOS)" "$RESULT") - echo "upstream-has-macos=$HAS_MACOS" >> "$GITHUB_OUTPUT" - echo "Upstream Hermes has macOS slices: $HAS_MACOS" - - if [ "$HAS_MACOS" = "true" ]; then - TARBALL=$(node -e "console.log(JSON.parse(process.argv[1]).tarballPath || '')" "$RESULT") - if [ -n "$TARBALL" ] && [ -f "$TARBALL" ]; then - mkdir -p ${{ github.workspace }}/hermes-destroot - tar -xzf "$TARBALL" -C ${{ github.workspace }}/hermes-destroot --strip-components=2 - echo "Extracted upstream Hermes to hermes-destroot" - ls -la ${{ github.workspace }}/hermes-destroot/ - fi - fi - - - name: Upload upstream Hermes as artifact - if: steps.check-upstream.outputs.upstream-has-macos == 'true' - uses: actions/upload-artifact@v4 - with: - name: hermes-artifacts - path: hermes-destroot - retention-days: 30 - - - name: Resolve Hermes commit at merge base - if: steps.check-upstream.outputs.upstream-has-macos != 'true' - id: resolve - working-directory: packages/react-native - run: | - COMMIT=$(node -e "const {hermesCommitAtMergeBase} = require('./scripts/ios-prebuild/macosVersionResolver'); console.log(hermesCommitAtMergeBase().commit);" 2>&1 | grep -E '^[0-9a-f]{40}$') - echo "hermes-commit=$COMMIT" >> "$GITHUB_OUTPUT" - echo "Resolved Hermes commit: $COMMIT" - - - name: Restore Hermes cache - if: steps.check-upstream.outputs.upstream-has-macos != 'true' - id: cache - uses: actions/cache/restore@v4 - with: - key: hermes-v1-${{ steps.resolve.outputs.hermes-commit }}-Debug - path: hermes-destroot - - - name: Upload cached Hermes artifacts - if: steps.check-upstream.outputs.upstream-has-macos != 'true' && steps.cache.outputs.cache-hit == 'true' - uses: actions/upload-artifact@v4 - with: - name: hermes-artifacts - path: hermes-destroot - retention-days: 30 - - build-hermesc: - name: "Build hermesc" - if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} - needs: resolve-hermes - runs-on: macos-15 - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - with: - filter: blob:none - - - name: Setup toolchain - uses: ./.github/actions/microsoft-setup-toolchain - with: - platform: macos - cache-npm-dependencies: '' - - - name: Clone Hermes - uses: actions/checkout@v4 - with: - repository: facebook/hermes - ref: ${{ needs.resolve-hermes.outputs.hermes-commit }} - path: hermes - - - name: Build hermesc - working-directory: hermes - env: - HERMES_PATH: ${{ github.workspace }}/hermes - JSI_PATH: ${{ github.workspace }}/hermes/API/jsi - MAC_DEPLOYMENT_TARGET: '14.0' - run: | - source $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh - build_host_hermesc - - - name: Upload hermesc artifact - uses: actions/upload-artifact@v4 - with: - name: hermesc - path: hermes/build_host_hermesc - retention-days: 30 - - build-hermes-slice: - name: "Hermes ${{ matrix.slice }}" - if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} - needs: [resolve-hermes, build-hermesc] - runs-on: macos-15 - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - slice: [iphoneos, iphonesimulator, macosx, xros, xrsimulator] - include: - - slice: iphoneos - platform: ios - - slice: iphonesimulator - platform: ios - - slice: macosx - platform: macos - - slice: xros - platform: visionos - - slice: xrsimulator - platform: visionos - steps: - - uses: actions/checkout@v4 - with: - filter: blob:none - - - name: Setup toolchain - uses: ./.github/actions/microsoft-setup-toolchain - with: - platform: ${{ matrix.platform }} - cache-npm-dependencies: '' - - - name: Clone Hermes - uses: actions/checkout@v4 - with: - repository: facebook/hermes - ref: ${{ needs.resolve-hermes.outputs.hermes-commit }} - path: hermes - - - name: Download hermesc - uses: actions/download-artifact@v4 - with: - name: hermesc - path: hermes/build_host_hermesc - - - name: Restore hermesc permissions - run: chmod +x ${{ github.workspace }}/hermes/build_host_hermesc/bin/hermesc - - - name: Build Hermes slice (${{ matrix.slice }}) - working-directory: hermes - env: - BUILD_TYPE: Debug - HERMES_PATH: ${{ github.workspace }}/hermes - JSI_PATH: ${{ github.workspace }}/hermes/API/jsi - IOS_DEPLOYMENT_TARGET: '15.1' - MAC_DEPLOYMENT_TARGET: '14.0' - XROS_DEPLOYMENT_TARGET: '1.0' - RELEASE_VERSION: '1000.0.0' - run: | - bash $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh "${{ matrix.slice }}" - - - name: Upload slice artifact - uses: actions/upload-artifact@v4 - with: - name: hermes-slice-${{ matrix.slice }} - path: hermes/destroot - retention-days: 30 - - assemble-hermes: - name: "Assemble Hermes xcframework" - if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} - needs: [resolve-hermes, build-hermes-slice] - runs-on: macos-15 - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - with: - filter: blob:none - - - name: Download all slice artifacts - uses: actions/download-artifact@v4 - with: - pattern: hermes-slice-* - path: /tmp/slices - - - name: Assemble destroot from slices - run: | - mkdir -p ${{ github.workspace }}/hermes/destroot/Library/Frameworks - for slice_dir in /tmp/slices/hermes-slice-*; do - slice_name=$(basename "$slice_dir" | sed 's/hermes-slice-//') - echo "Copying slice: $slice_name" - cp -R "$slice_dir/Library/Frameworks/$slice_name" ${{ github.workspace }}/hermes/destroot/Library/Frameworks/ - # Copy include and bin directories (identical across slices, only need one copy) - if [ -d "$slice_dir/include" ] && [ ! -d ${{ github.workspace }}/hermes/destroot/include ]; then - cp -R "$slice_dir/include" ${{ github.workspace }}/hermes/destroot/ - fi - if [ -d "$slice_dir/bin" ]; then - cp -R "$slice_dir/bin" ${{ github.workspace }}/hermes/destroot/ - fi - done - echo "Assembled destroot contents:" - ls -la ${{ github.workspace }}/hermes/destroot/Library/Frameworks/ - - - name: Create universal xcframework - working-directory: hermes - env: - HERMES_PATH: ${{ github.workspace }}/hermes - run: | - source $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh - create_universal_framework "iphoneos" "iphonesimulator" "macosx" "xros" "xrsimulator" - - - name: Save Hermes cache - uses: actions/cache/save@v4 - with: - key: hermes-v1-${{ needs.resolve-hermes.outputs.hermes-commit }}-Debug - path: hermes/destroot - - - name: Upload Hermes artifacts - uses: actions/upload-artifact@v4 - with: - name: hermes-artifacts - path: hermes/destroot - retention-days: 30 + build-hermes: + name: "Build Hermes" + uses: ./.github/workflows/microsoft-build-hermes.yml build: name: "Build ${{ matrix.platform }}" - needs: [resolve-hermes, assemble-hermes] - # Run when upstream jobs succeeded or were skipped (cache hit) - if: ${{ always() && !cancelled() && !failure() }} + needs: [build-hermes] runs-on: macos-26 timeout-minutes: 60 strategy: From 5500b3d4b1e671be505d2b44c62f7c51c85d608d Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:59:45 -0700 Subject: [PATCH 10/19] ci: rename Build Hermes workflow to Resolve Hermes The workflow's primary job is resolving which Hermes to use (cache hit, upstream, or build from source). Building is the fallback path, not the common case. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-prebuild-macos-core.yml | 8 ++++---- ...soft-build-hermes.yml => microsoft-resolve-hermes.yml} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename .github/workflows/{microsoft-build-hermes.yml => microsoft-resolve-hermes.yml} (99%) diff --git a/.github/workflows/microsoft-prebuild-macos-core.yml b/.github/workflows/microsoft-prebuild-macos-core.yml index 1b4f226c1c1..6ea5fa03bdb 100644 --- a/.github/workflows/microsoft-prebuild-macos-core.yml +++ b/.github/workflows/microsoft-prebuild-macos-core.yml @@ -4,13 +4,13 @@ on: workflow_call: jobs: - build-hermes: - name: "Build Hermes" - uses: ./.github/workflows/microsoft-build-hermes.yml + resolve-hermes: + name: "Resolve Hermes" + uses: ./.github/workflows/microsoft-resolve-hermes.yml build: name: "Build ${{ matrix.platform }}" - needs: [build-hermes] + needs: [resolve-hermes] runs-on: macos-26 timeout-minutes: 60 strategy: diff --git a/.github/workflows/microsoft-build-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml similarity index 99% rename from .github/workflows/microsoft-build-hermes.yml rename to .github/workflows/microsoft-resolve-hermes.yml index 2642f60a549..af00cec7e65 100644 --- a/.github/workflows/microsoft-build-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.yml @@ -1,4 +1,4 @@ -name: Build Hermes +name: Resolve Hermes on: workflow_call: From 7c1a307070c756da847a1f77d8bee4258738f065 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 17:12:50 -0700 Subject: [PATCH 11/19] fix: check xcframework Info.plist for mac slice, not just macosx/ dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream Hermes tarballs ship a standalone macosx/hermes.framework but don't include macOS in the universal xcframework yet. The previous check matched the standalone directory and would falsely report that upstream has macOS support. Now extracts the xcframework's Info.plist and checks for a macOS platform entry, which only matches when macOS is truly in the universal xcframework. Also renames hasMacOS → hasMacSlice and the function to checkUpstreamHermesHasMacSlice for clarity. Co-Authored-By: Claude Opus 4.6 --- .../workflows/microsoft-resolve-hermes.yml | 30 +++++----- .../ios-prebuild/macosVersionResolver.js | 57 +++++++++++-------- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index af00cec7e65..83e8ee74fbd 100644 --- a/.github/workflows/microsoft-resolve-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.yml @@ -11,7 +11,7 @@ jobs: outputs: hermes-commit: ${{ steps.resolve.outputs.hermes-commit }} cache-hit: ${{ steps.cache.outputs.cache-hit }} - upstream-has-macos: ${{ steps.check-upstream.outputs.upstream-has-macos }} + upstream-has-mac-slice: ${{ steps.check-upstream.outputs.upstream-has-mac-slice }} steps: - uses: actions/checkout@v4 with: @@ -27,22 +27,22 @@ jobs: - name: Install npm dependencies run: yarn install - - name: Check if upstream Hermes includes macOS slices + - name: Check if upstream Hermes xcframework includes mac slice id: check-upstream working-directory: packages/react-native run: | node -e " - const {checkUpstreamHermesHasMacOS} = require('./scripts/ios-prebuild/macosVersionResolver'); - checkUpstreamHermesHasMacOS('Debug').then(r => { + const {checkUpstreamHermesHasMacSlice} = require('./scripts/ios-prebuild/macosVersionResolver'); + checkUpstreamHermesHasMacSlice('Debug').then(r => { require('fs').writeFileSync('/tmp/hermes-check-result.json', JSON.stringify(r)); }); " RESULT=$(cat /tmp/hermes-check-result.json) - HAS_MACOS=$(node -e "console.log(JSON.parse(process.argv[1]).hasMacOS)" "$RESULT") - echo "upstream-has-macos=$HAS_MACOS" >> "$GITHUB_OUTPUT" - echo "Upstream Hermes has macOS slices: $HAS_MACOS" + HAS_MAC_SLICE=$(node -e "console.log(JSON.parse(process.argv[1]).hasMacSlice)" "$RESULT") + echo "upstream-has-mac-slice=$HAS_MAC_SLICE" >> "$GITHUB_OUTPUT" + echo "Upstream Hermes xcframework has mac slice: $HAS_MAC_SLICE" - if [ "$HAS_MACOS" = "true" ]; then + if [ "$HAS_MAC_SLICE" = "true" ]; then TARBALL=$(node -e "console.log(JSON.parse(process.argv[1]).tarballPath || '')" "$RESULT") if [ -n "$TARBALL" ] && [ -f "$TARBALL" ]; then mkdir -p ${{ github.workspace }}/hermes-destroot @@ -53,7 +53,7 @@ jobs: fi - name: Upload upstream Hermes as artifact - if: steps.check-upstream.outputs.upstream-has-macos == 'true' + if: steps.check-upstream.outputs.upstream-has-mac-slice == 'true' uses: actions/upload-artifact@v4 with: name: hermes-artifacts @@ -61,7 +61,7 @@ jobs: retention-days: 30 - name: Resolve Hermes commit at merge base - if: steps.check-upstream.outputs.upstream-has-macos != 'true' + if: steps.check-upstream.outputs.upstream-has-mac-slice != 'true' id: resolve working-directory: packages/react-native run: | @@ -70,7 +70,7 @@ jobs: echo "Resolved Hermes commit: $COMMIT" - name: Restore Hermes cache - if: steps.check-upstream.outputs.upstream-has-macos != 'true' + if: steps.check-upstream.outputs.upstream-has-mac-slice != 'true' id: cache uses: actions/cache/restore@v4 with: @@ -78,7 +78,7 @@ jobs: path: hermes-destroot - name: Upload cached Hermes artifacts - if: steps.check-upstream.outputs.upstream-has-macos != 'true' && steps.cache.outputs.cache-hit == 'true' + if: steps.check-upstream.outputs.upstream-has-mac-slice != 'true' && steps.cache.outputs.cache-hit == 'true' uses: actions/upload-artifact@v4 with: name: hermes-artifacts @@ -87,7 +87,7 @@ jobs: build-hermesc: name: "Build hermesc" - if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} + if: ${{ needs.resolve-hermes.outputs.upstream-has-mac-slice != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} needs: resolve-hermes runs-on: macos-15 timeout-minutes: 30 @@ -128,7 +128,7 @@ jobs: build-hermes-slice: name: "Hermes ${{ matrix.slice }}" - if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} + if: ${{ needs.resolve-hermes.outputs.upstream-has-mac-slice != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} needs: [resolve-hermes, build-hermesc] runs-on: macos-15 timeout-minutes: 45 @@ -196,7 +196,7 @@ jobs: assemble-hermes: name: "Assemble Hermes xcframework" - if: ${{ needs.resolve-hermes.outputs.upstream-has-macos != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} + if: ${{ needs.resolve-hermes.outputs.upstream-has-mac-slice != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} needs: [resolve-hermes, build-hermes-slice] runs-on: macos-15 timeout-minutes: 15 diff --git a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js index fbef6c4f090..8d7a2358b93 100644 --- a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js +++ b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js @@ -195,13 +195,18 @@ async function getLatestStableVersionFromNPM() /*: Promise */ { * 2. Version at merge base with facebook/react-native (main branch) * 3. Latest stable version from npm (last resort) * - * Returns {hasMacOS: boolean, tarballPath?: string, version?: string}. - * When hasMacOS is true, tarballPath points to the downloaded tarball and + * Returns {hasMacSlice: boolean, tarballPath?: string, version?: string}. + * When hasMacSlice is true, tarballPath points to the downloaded tarball and * version is the upstream version string used for the lookup. + * + * The check looks for a macOS platform entry inside the universal + * hermes.xcframework (via its Info.plist), not just for a standalone + * macosx/ directory. Upstream tarballs ship a standalone macosx/hermes.framework + * but don't include it in the universal xcframework yet. */ -async function checkUpstreamHermesHasMacOS( +async function checkUpstreamHermesHasMacSlice( buildType /*: BuildFlavor */ = 'Debug', -) /*: Promise<{| hasMacOS: boolean, tarballPath?: string, version?: string |}> */ { +) /*: Promise<{| hasMacSlice: boolean, tarballPath?: string, version?: string |}> */ { const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); // Build a list of candidate versions to try (in priority order) @@ -228,7 +233,7 @@ async function checkUpstreamHermesHasMacOS( if (candidates.length === 0) { macosLog('Could not determine any upstream version to check Hermes tarball'); - return {hasMacOS: false}; + return {hasMacSlice: false}; } const mavenRepoUrl = 'https://repo1.maven.org/maven2'; @@ -283,31 +288,35 @@ async function checkUpstreamHermesHasMacOS( const buffer = await response.arrayBuffer(); fs.writeFileSync(tarballPath, Buffer.from(buffer)); - // List tarball contents and check for macosx slice - const listing = execSync(`tar -tzf "${tarballPath}" 2>/dev/null`, { - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, - }); - - const hasMacOS = listing - .split('\n') - .some( - entry => entry.includes('/macosx/') || entry.includes('/macosx'), + // Extract the xcframework's Info.plist and check for a macOS + // platform entry. We can't just look for a macosx/ directory in + // the tarball — upstream ships a standalone macosx/hermes.framework + // but doesn't include macOS in the universal xcframework yet. + let hasMacSlice = false; + try { + const plist = execSync( + `tar -xzf "${tarballPath}" -O --wildcards '*/universal/hermes.xcframework/Info.plist' 2>/dev/null`, + {encoding: 'utf8', maxBuffer: 1024 * 1024}, ); + hasMacSlice = plist.includes('macos') || plist.includes('macOS'); + } catch (_) { + // Info.plist not found or extraction failed — no mac slice + macosLog('Could not extract xcframework Info.plist from tarball.'); + } - if (hasMacOS) { + if (hasMacSlice) { macosLog( - `Upstream Hermes tarball (${version}) contains macOS slices — build from source can be skipped!`, + `Upstream Hermes tarball (${version}) includes macOS in the universal xcframework — build from source can be skipped!`, ); - return {hasMacOS: true, tarballPath, version}; + return {hasMacSlice: true, tarballPath, version}; } else { macosLog( - `Upstream Hermes tarball (${version}) does NOT contain macOS slices.`, + `Upstream Hermes tarball (${version}) does NOT include macOS in the universal xcframework.`, ); fs.rmSync(tmpDir, {recursive: true, force: true}); - // Don't try other versions — if the tarball exists but lacks macOS, - // older versions won't help since macOS was always included. - return {hasMacOS: false}; + // Don't try other versions — if the tarball exists but lacks + // the mac slice, older versions won't have it either. + return {hasMacSlice: false}; } } catch (e) { macosLog(`Error checking tarball for ${version}: ${e.message}`); @@ -322,7 +331,7 @@ async function checkUpstreamHermesHasMacOS( macosLog( 'No upstream Hermes tarball found for any candidate version — will build from source.', ); - return {hasMacOS: false}; + return {hasMacSlice: false}; } function abort(message /*: string */) { @@ -335,5 +344,5 @@ module.exports = { hermesCommitAtMergeBase, findVersionAtMergeBase, getLatestStableVersionFromNPM, - checkUpstreamHermesHasMacOS, + checkUpstreamHermesHasMacSlice, }; From 6d4c48bfa46516eff3112cc134d4117b2a62e6bd Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 17:20:58 -0700 Subject: [PATCH 12/19] 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 --- .../workflows/microsoft-resolve-hermes.yml | 98 ++++++++++++++----- .../ios-prebuild/macosVersionResolver.js | 93 +++++------------- 2 files changed, 94 insertions(+), 97 deletions(-) diff --git a/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index 83e8ee74fbd..999bf964951 100644 --- a/.github/workflows/microsoft-resolve-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.yml @@ -11,7 +11,7 @@ jobs: outputs: hermes-commit: ${{ steps.resolve.outputs.hermes-commit }} cache-hit: ${{ steps.cache.outputs.cache-hit }} - upstream-has-mac-slice: ${{ steps.check-upstream.outputs.upstream-has-mac-slice }} + recomposed: ${{ steps.recompose.outputs.recomposed }} steps: - uses: actions/checkout@v4 with: @@ -27,41 +27,87 @@ jobs: - name: Install npm dependencies run: yarn install - - name: Check if upstream Hermes xcframework includes mac slice - id: check-upstream + - name: Download upstream Hermes tarball + id: download working-directory: packages/react-native run: | node -e " - const {checkUpstreamHermesHasMacSlice} = require('./scripts/ios-prebuild/macosVersionResolver'); - checkUpstreamHermesHasMacSlice('Debug').then(r => { - require('fs').writeFileSync('/tmp/hermes-check-result.json', JSON.stringify(r)); + const {downloadUpstreamHermesTarball} = require('./scripts/ios-prebuild/macosVersionResolver'); + downloadUpstreamHermesTarball('Debug').then(r => { + require('fs').writeFileSync('/tmp/hermes-download-result.json', JSON.stringify(r)); }); " - RESULT=$(cat /tmp/hermes-check-result.json) - HAS_MAC_SLICE=$(node -e "console.log(JSON.parse(process.argv[1]).hasMacSlice)" "$RESULT") - echo "upstream-has-mac-slice=$HAS_MAC_SLICE" >> "$GITHUB_OUTPUT" - echo "Upstream Hermes xcframework has mac slice: $HAS_MAC_SLICE" - - if [ "$HAS_MAC_SLICE" = "true" ]; then - TARBALL=$(node -e "console.log(JSON.parse(process.argv[1]).tarballPath || '')" "$RESULT") - if [ -n "$TARBALL" ] && [ -f "$TARBALL" ]; then - mkdir -p ${{ github.workspace }}/hermes-destroot - tar -xzf "$TARBALL" -C ${{ github.workspace }}/hermes-destroot --strip-components=2 - echo "Extracted upstream Hermes to hermes-destroot" - ls -la ${{ github.workspace }}/hermes-destroot/ + RESULT=$(cat /tmp/hermes-download-result.json) + if [ "$RESULT" != "null" ]; then + TARBALL=$(node -e "console.log(JSON.parse(process.argv[1]).tarballPath)" "$RESULT") + VERSION=$(node -e "console.log(JSON.parse(process.argv[1]).version)" "$RESULT") + echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Downloaded upstream Hermes tarball for version $VERSION" + else + echo "No upstream tarball available" + fi + + - name: Recompose xcframework with macOS slice + id: recompose + if: steps.download.outputs.tarball != '' + run: | + TARBALL="${{ steps.download.outputs.tarball }}" + + # Extract tarball + mkdir -p hermes-destroot + tar -xzf "$TARBALL" -C hermes-destroot --strip-components=2 + + echo "=== Upstream tarball contents ===" + ls -la hermes-destroot/Library/Frameworks/ + + # Collect existing frameworks from the universal xcframework + XCFW="hermes-destroot/Library/Frameworks/universal/hermes.xcframework" + FRAMEWORKS=() + for fw in "$XCFW"/*/hermes.framework; do + if [ -d "$fw" ]; then + echo "Found slice: $fw" + FRAMEWORKS+=(-framework "$fw") fi + done + + # Add standalone macOS framework + MAC_FW="hermes-destroot/Library/Frameworks/macosx/hermes.framework" + if [ -d "$MAC_FW" ]; then + echo "Found standalone macOS slice: $MAC_FW" + FRAMEWORKS+=(-framework "$MAC_FW") + else + echo "::error::Upstream tarball missing macosx/hermes.framework" + echo "recomposed=false" >> "$GITHUB_OUTPUT" + exit 0 fi - - name: Upload upstream Hermes as artifact - if: steps.check-upstream.outputs.upstream-has-mac-slice == 'true' + # Remove old xcframework and create new one with macOS included + rm -rf "$XCFW" + echo "Creating new universal xcframework with ${#FRAMEWORKS[@]} frameworks..." + xcodebuild -create-xcframework "${FRAMEWORKS[@]}" \ + -output "$XCFW" \ + -allow-internal-distribution + + # Clean up standalone macOS dir (now included in universal) + rm -rf hermes-destroot/Library/Frameworks/macosx + + echo "=== Recomposed xcframework ===" + ls -la "$XCFW"/ + + echo "recomposed=true" >> "$GITHUB_OUTPUT" + + - name: Upload recomposed Hermes artifacts + if: steps.recompose.outputs.recomposed == 'true' uses: actions/upload-artifact@v4 with: name: hermes-artifacts path: hermes-destroot retention-days: 30 + # Fallback: resolve Hermes commit for build-from-source - name: Resolve Hermes commit at merge base - if: steps.check-upstream.outputs.upstream-has-mac-slice != 'true' + if: steps.recompose.outputs.recomposed != 'true' id: resolve working-directory: packages/react-native run: | @@ -70,7 +116,7 @@ jobs: echo "Resolved Hermes commit: $COMMIT" - name: Restore Hermes cache - if: steps.check-upstream.outputs.upstream-has-mac-slice != 'true' + if: steps.recompose.outputs.recomposed != 'true' id: cache uses: actions/cache/restore@v4 with: @@ -78,7 +124,7 @@ jobs: path: hermes-destroot - name: Upload cached Hermes artifacts - if: steps.check-upstream.outputs.upstream-has-mac-slice != 'true' && steps.cache.outputs.cache-hit == 'true' + if: steps.recompose.outputs.recomposed != 'true' && steps.cache.outputs.cache-hit == 'true' uses: actions/upload-artifact@v4 with: name: hermes-artifacts @@ -87,7 +133,7 @@ jobs: build-hermesc: name: "Build hermesc" - if: ${{ needs.resolve-hermes.outputs.upstream-has-mac-slice != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} + if: ${{ needs.resolve-hermes.outputs.recomposed != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} needs: resolve-hermes runs-on: macos-15 timeout-minutes: 30 @@ -128,7 +174,7 @@ jobs: build-hermes-slice: name: "Hermes ${{ matrix.slice }}" - if: ${{ needs.resolve-hermes.outputs.upstream-has-mac-slice != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} + if: ${{ needs.resolve-hermes.outputs.recomposed != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} needs: [resolve-hermes, build-hermesc] runs-on: macos-15 timeout-minutes: 45 @@ -196,7 +242,7 @@ jobs: assemble-hermes: name: "Assemble Hermes xcframework" - if: ${{ needs.resolve-hermes.outputs.upstream-has-mac-slice != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} + if: ${{ needs.resolve-hermes.outputs.recomposed != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} needs: [resolve-hermes, build-hermes-slice] runs-on: macos-15 timeout-minutes: 15 diff --git a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js index 8d7a2358b93..7a86a752c28 100644 --- a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js +++ b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js @@ -187,26 +187,20 @@ async function getLatestStableVersionFromNPM() /*: Promise */ { } /** - * Checks whether the upstream Hermes tarball (from Maven) already contains - * macOS slices. If it does, we can skip building Hermes from source entirely. + * Downloads the upstream Hermes tarball from Maven or Sonatype. + * The caller is responsible for extracting and recomposing the + * xcframework (e.g. adding the macOS slice to the universal). * * Tries multiple version resolution strategies in order: * 1. Mapped version from peerDependencies (stable branches) * 2. Version at merge base with facebook/react-native (main branch) * 3. Latest stable version from npm (last resort) * - * Returns {hasMacSlice: boolean, tarballPath?: string, version?: string}. - * When hasMacSlice is true, tarballPath points to the downloaded tarball and - * version is the upstream version string used for the lookup. - * - * The check looks for a macOS platform entry inside the universal - * hermes.xcframework (via its Info.plist), not just for a standalone - * macosx/ directory. Upstream tarballs ship a standalone macosx/hermes.framework - * but don't include it in the universal xcframework yet. + * Returns {tarballPath, version} on success, or null if no tarball is available. */ -async function checkUpstreamHermesHasMacSlice( +async function downloadUpstreamHermesTarball( buildType /*: BuildFlavor */ = 'Debug', -) /*: Promise<{| hasMacSlice: boolean, tarballPath?: string, version?: string |}> */ { +) /*: Promise */ { const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); // Build a list of candidate versions to try (in priority order) @@ -232,8 +226,10 @@ async function checkUpstreamHermesHasMacSlice( } if (candidates.length === 0) { - macosLog('Could not determine any upstream version to check Hermes tarball'); - return {hasMacSlice: false}; + macosLog( + 'Could not determine any upstream version to download Hermes tarball', + ); + return null; } const mavenRepoUrl = 'https://repo1.maven.org/maven2'; @@ -255,74 +251,29 @@ async function checkUpstreamHermesHasMacSlice( for (const tarballUrl of urlsToTry) { macosLog( - `Checking upstream Hermes tarball (version: ${version}, ${buildType}) at ${tarballUrl}...`, + `Trying upstream Hermes tarball (version: ${version}, ${buildType}) at ${tarballUrl}...`, ); - // Check if the tarball exists - try { - const headResponse = await fetch(tarballUrl, {method: 'HEAD'}); - if (headResponse.status !== 200) { - macosLog(`Tarball not found, trying next URL...`); - continue; - } - } catch (_) { - macosLog('Failed to reach server, trying next URL...'); - continue; - } - - // Download the tarball to a temp directory - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-check-')); - const tarballPath = path.join(tmpDir, 'hermes-ios.tar.gz'); - try { - macosLog(`Downloading upstream tarball...`); - const response = await fetch(tarballUrl); + const response /*: Response */ = await fetch(tarballUrl); if (!response.ok) { macosLog( - `Download failed: ${response.status} ${response.statusText}`, + `Tarball not available: ${response.status} ${response.statusText}`, ); - fs.rmSync(tmpDir, {recursive: true, force: true}); continue; } + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-')); + const tarballPath = path.join(tmpDir, 'hermes-ios.tar.gz'); const buffer = await response.arrayBuffer(); fs.writeFileSync(tarballPath, Buffer.from(buffer)); - // Extract the xcframework's Info.plist and check for a macOS - // platform entry. We can't just look for a macosx/ directory in - // the tarball — upstream ships a standalone macosx/hermes.framework - // but doesn't include macOS in the universal xcframework yet. - let hasMacSlice = false; - try { - const plist = execSync( - `tar -xzf "${tarballPath}" -O --wildcards '*/universal/hermes.xcframework/Info.plist' 2>/dev/null`, - {encoding: 'utf8', maxBuffer: 1024 * 1024}, - ); - hasMacSlice = plist.includes('macos') || plist.includes('macOS'); - } catch (_) { - // Info.plist not found or extraction failed — no mac slice - macosLog('Could not extract xcframework Info.plist from tarball.'); - } - - if (hasMacSlice) { - macosLog( - `Upstream Hermes tarball (${version}) includes macOS in the universal xcframework — build from source can be skipped!`, - ); - return {hasMacSlice: true, tarballPath, version}; - } else { - macosLog( - `Upstream Hermes tarball (${version}) does NOT include macOS in the universal xcframework.`, - ); - fs.rmSync(tmpDir, {recursive: true, force: true}); - // Don't try other versions — if the tarball exists but lacks - // the mac slice, older versions won't have it either. - return {hasMacSlice: false}; - } + macosLog( + `Downloaded upstream Hermes tarball (${version}) to ${tarballPath}`, + ); + return {tarballPath, version}; } catch (e) { - macosLog(`Error checking tarball for ${version}: ${e.message}`); - try { - fs.rmSync(tmpDir, {recursive: true, force: true}); - } catch (_) {} + macosLog(`Error downloading tarball for ${version}: ${e.message}`); continue; } } @@ -331,7 +282,7 @@ async function checkUpstreamHermesHasMacSlice( macosLog( 'No upstream Hermes tarball found for any candidate version — will build from source.', ); - return {hasMacSlice: false}; + return null; } function abort(message /*: string */) { @@ -344,5 +295,5 @@ module.exports = { hermesCommitAtMergeBase, findVersionAtMergeBase, getLatestStableVersionFromNPM, - checkUpstreamHermesHasMacSlice, + downloadUpstreamHermesTarball, }; From 55a897360e63bbf984ac90f6c9aa3c5c5013a3c4 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:01:39 -0700 Subject: [PATCH 13/19] =?UTF-8?q?refactor:=20rename=20macosVersionResolver?= =?UTF-8?q?=20=E2=86=92=20microsoft-resolveHermes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure rename — no content changes. The microsoft- prefix makes it obvious this is a fork-specific file, consistent with our workflow naming convention (microsoft-*.yml). Co-Authored-By: Claude Opus 4.6 --- .../{macosVersionResolver.js => microsoft-resolveHermes.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react-native/scripts/ios-prebuild/{macosVersionResolver.js => microsoft-resolveHermes.js} (100%) diff --git a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js b/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js similarity index 100% rename from packages/react-native/scripts/ios-prebuild/macosVersionResolver.js rename to packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js From ad7f48208a460ce337460098943892ef4e0142b6 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:02:17 -0700 Subject: [PATCH 14/19] refactor: add CLI entry point, recompose function, and flow comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CLI interface to microsoft-resolveHermes.js with download-hermes, recompose-xcframework, and resolve-commit commands that write directly to $GITHUB_OUTPUT — replacing inline node -e / temp file / shell JSON parsing in the workflow. Move recompose xcframework logic from inline shell into JS function recomposeHermesXcframework() which: - Checks if macOS is already in the universal xcframework (no-op) - If not, merges the standalone macosx/hermes.framework into universal - Creates new xcframework at temp path before deleting old one - Once upstream Hermes ships macOS in universal, this becomes a no-op Add section comments documenting the resolve-hermes workflow strategy. Update all import references to new filename. Co-Authored-By: Claude Opus 4.6 --- .../workflows/microsoft-resolve-hermes.yml | 100 ++++------- .../scripts/ios-prebuild/hermes.js | 2 +- .../ios-prebuild/microsoft-resolveHermes.js | 156 +++++++++++++++++- .../ios-prebuild/reactNativeDependencies.js | 2 +- 4 files changed, 190 insertions(+), 70 deletions(-) diff --git a/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index 999bf964951..f143c3dbc3a 100644 --- a/.github/workflows/microsoft-resolve-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.yml @@ -1,9 +1,22 @@ +# Resolve Hermes — reusable workflow called by microsoft-prebuild-macos-core.yml +# +# Strategy (fast path first): +# 1. Download upstream Hermes tarball from Maven/Sonatype +# 2. If found → recompose xcframework (add macOS slice) → upload artifact → done +# 3. If not found → resolve Hermes commit at merge base → check cache → upload if cached +# +# Build-from-source fallback (only when recomposed != true AND cache-hit != true): +# build-hermesc → build 5 platform slices in parallel → assemble universal xcframework +# name: Resolve Hermes on: workflow_call: jobs: + # --------------------------------------------------------------------------- + # Fast path: download upstream tarball and recompose, or resolve commit + cache + # --------------------------------------------------------------------------- resolve-hermes: name: "Resolve Hermes" runs-on: macos-15 @@ -27,76 +40,27 @@ jobs: - name: Install npm dependencies run: yarn install + # Step 1: Try to download a prebuilt Hermes tarball from upstream Maven/Sonatype. + # Writes tarball= and version= to $GITHUB_OUTPUT if successful. - name: Download upstream Hermes tarball id: download working-directory: packages/react-native - run: | - node -e " - const {downloadUpstreamHermesTarball} = require('./scripts/ios-prebuild/macosVersionResolver'); - downloadUpstreamHermesTarball('Debug').then(r => { - require('fs').writeFileSync('/tmp/hermes-download-result.json', JSON.stringify(r)); - }); - " - RESULT=$(cat /tmp/hermes-download-result.json) - if [ "$RESULT" != "null" ]; then - TARBALL=$(node -e "console.log(JSON.parse(process.argv[1]).tarballPath)" "$RESULT") - VERSION=$(node -e "console.log(JSON.parse(process.argv[1]).version)" "$RESULT") - echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Downloaded upstream Hermes tarball for version $VERSION" - else - echo "No upstream tarball available" - fi + run: node scripts/ios-prebuild/microsoft-resolveHermes.js download-hermes Debug + # Step 2: If tarball found, recompose the xcframework to include the macOS slice + # (or skip if macOS is already present in the universal xcframework). + # Writes recomposed=true/false to $GITHUB_OUTPUT. - name: Recompose xcframework with macOS slice id: recompose if: steps.download.outputs.tarball != '' - run: | - TARBALL="${{ steps.download.outputs.tarball }}" - - # Extract tarball - mkdir -p hermes-destroot - tar -xzf "$TARBALL" -C hermes-destroot --strip-components=2 - - echo "=== Upstream tarball contents ===" - ls -la hermes-destroot/Library/Frameworks/ - - # Collect existing frameworks from the universal xcframework - XCFW="hermes-destroot/Library/Frameworks/universal/hermes.xcframework" - FRAMEWORKS=() - for fw in "$XCFW"/*/hermes.framework; do - if [ -d "$fw" ]; then - echo "Found slice: $fw" - FRAMEWORKS+=(-framework "$fw") - fi - done - - # Add standalone macOS framework - MAC_FW="hermes-destroot/Library/Frameworks/macosx/hermes.framework" - if [ -d "$MAC_FW" ]; then - echo "Found standalone macOS slice: $MAC_FW" - FRAMEWORKS+=(-framework "$MAC_FW") - else - echo "::error::Upstream tarball missing macosx/hermes.framework" - echo "recomposed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Remove old xcframework and create new one with macOS included - rm -rf "$XCFW" - echo "Creating new universal xcframework with ${#FRAMEWORKS[@]} frameworks..." - xcodebuild -create-xcframework "${FRAMEWORKS[@]}" \ - -output "$XCFW" \ - -allow-internal-distribution - - # Clean up standalone macOS dir (now included in universal) - rm -rf hermes-destroot/Library/Frameworks/macosx - - echo "=== Recomposed xcframework ===" - ls -la "$XCFW"/ - - echo "recomposed=true" >> "$GITHUB_OUTPUT" + working-directory: packages/react-native + run: >- + node scripts/ios-prebuild/microsoft-resolveHermes.js + recompose-xcframework + "${{ steps.download.outputs.tarball }}" + "${{ github.workspace }}/hermes-destroot" + # Upload recomposed artifacts — the prebuild-macos-core workflow downloads these - name: Upload recomposed Hermes artifacts if: steps.recompose.outputs.recomposed == 'true' uses: actions/upload-artifact@v4 @@ -105,15 +69,13 @@ jobs: path: hermes-destroot retention-days: 30 - # Fallback: resolve Hermes commit for build-from-source + # Step 3 (fallback): No upstream tarball — resolve the Hermes commit hash + # at the merge base with facebook/react-native and check the build cache. - name: Resolve Hermes commit at merge base if: steps.recompose.outputs.recomposed != 'true' id: resolve working-directory: packages/react-native - run: | - COMMIT=$(node -e "const {hermesCommitAtMergeBase} = require('./scripts/ios-prebuild/macosVersionResolver'); console.log(hermesCommitAtMergeBase().commit);" 2>&1 | grep -E '^[0-9a-f]{40}$') - echo "hermes-commit=$COMMIT" >> "$GITHUB_OUTPUT" - echo "Resolved Hermes commit: $COMMIT" + run: node scripts/ios-prebuild/microsoft-resolveHermes.js resolve-commit - name: Restore Hermes cache if: steps.recompose.outputs.recomposed != 'true' @@ -131,6 +93,10 @@ jobs: path: hermes-destroot retention-days: 30 + # --------------------------------------------------------------------------- + # Build-from-source fallback — only runs when no recomposed or cached artifact + # Pipeline: hermesc (host compiler) → 5 platform slices → assemble xcframework + # --------------------------------------------------------------------------- build-hermesc: name: "Build hermesc" if: ${{ needs.resolve-hermes.outputs.recomposed != 'true' && needs.resolve-hermes.outputs.cache-hit != 'true' }} diff --git a/packages/react-native/scripts/ios-prebuild/hermes.js b/packages/react-native/scripts/ios-prebuild/hermes.js index 06815d71422..497d864c226 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes.js +++ b/packages/react-native/scripts/ios-prebuild/hermes.js @@ -11,7 +11,7 @@ const { findMatchingHermesVersion, hermesCommitAtMergeBase, -} = require('./macosVersionResolver'); // [macOS] +} = require('./microsoft-resolveHermes'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); diff --git a/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js b/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js index 7a86a752c28..a197a72c64d 100644 --- a/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js +++ b/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js @@ -4,7 +4,15 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * [macOS] Handles version resolution for macOS fork branches. + * [macOS] Resolves Hermes artifacts for macOS fork branches. + * + * Handles downloading upstream Hermes tarballs, recomposing xcframeworks + * to include the macOS slice, and resolving Hermes commits for + * build-from-source fallback. Used as both a library and CLI: + * + * node microsoft-resolveHermes.js download-hermes [Debug|Release] + * node microsoft-resolveHermes.js recompose-xcframework + * node microsoft-resolveHermes.js resolve-commit * * @flow * @format @@ -285,15 +293,161 @@ async function downloadUpstreamHermesTarball( return null; } +/** + * Extracts an upstream Hermes tarball and recomposes the xcframework to include + * the macOS slice, if needed. + * + * Upstream tarballs ship a universal xcframework (iOS, simulator, catalyst, + * tvOS, visionOS) plus a standalone macosx/hermes.framework. This function + * merges the standalone macOS framework into the universal xcframework using + * `xcodebuild -create-xcframework`. + * + * NOTE: Once upstream Hermes includes macOS in the universal xcframework + * natively, this function will detect the existing macOS slice and skip + * the recompose. At that point, this step can be removed entirely. + * + * Returns true if the xcframework was recomposed (or already had macOS), + * false if the tarball is missing the macOS framework entirely. + */ +function recomposeHermesXcframework( + tarballPath /*: string */, + destroot /*: string */, +) /*: boolean */ { + // Extract tarball + fs.mkdirSync(destroot, {recursive: true}); + execSync(`tar -xzf "${tarballPath}" -C "${destroot}" --strip-components=2`, { + stdio: 'inherit', + }); + + const frameworksDir = path.join(destroot, 'Library', 'Frameworks'); + const xcfwPath = path.join(frameworksDir, 'universal', 'hermes.xcframework'); + + macosLog('Upstream tarball contents:'); + execSync(`ls -la "${frameworksDir}"`, {stdio: 'inherit'}); + + // Check if macOS is already in the universal xcframework — if so, no recompose needed + const xcfwContents = fs.readdirSync(xcfwPath); + const hasMacSlice = xcfwContents.some( + entry => entry.startsWith('macos') && entry.includes('arm64'), + ); + if (hasMacSlice) { + macosLog('macOS slice already present in universal xcframework, skipping recompose'); + // Clean up standalone macOS dir if it exists + const standaloneMacDir = path.join(frameworksDir, 'macosx'); + if (fs.existsSync(standaloneMacDir)) { + fs.rmSync(standaloneMacDir, {recursive: true, force: true}); + } + return true; + } + + // Check for standalone macOS framework + const standaloneMacFw = path.join(frameworksDir, 'macosx', 'hermes.framework'); + if (!fs.existsSync(standaloneMacFw)) { + macosLog('Upstream tarball missing macosx/hermes.framework', 'error'); + return false; + } + + // Collect existing frameworks from inside the universal xcframework + const frameworks /*: string[] */ = []; + for (const entry of xcfwContents) { + const fwPath = path.join(xcfwPath, entry, 'hermes.framework'); + if (fs.existsSync(fwPath) && fs.statSync(fwPath).isDirectory()) { + macosLog(`Found slice: ${fwPath}`); + frameworks.push('-framework', fwPath); + } + } + + // Add the standalone macOS framework + macosLog(`Found standalone macOS slice: ${standaloneMacFw}`); + frameworks.push('-framework', standaloneMacFw); + + // Build new xcframework at a temp path (frameworks reference paths inside the old xcfw) + const xcfwNew = path.join(frameworksDir, 'universal', 'hermes-new.xcframework'); + macosLog( + `Creating new universal xcframework with ${frameworks.filter(f => f !== '-framework').length} slices...`, + ); + execSync( + `xcodebuild -create-xcframework ${frameworks.map(f => `"${f}"`).join(' ')} -output "${xcfwNew}" -allow-internal-distribution`, + {stdio: 'inherit'}, + ); + + // Swap in the recomposed xcframework + fs.rmSync(xcfwPath, {recursive: true, force: true}); + fs.renameSync(xcfwNew, xcfwPath); + + // Clean up standalone macOS dir (now included in universal) + fs.rmSync(path.join(frameworksDir, 'macosx'), {recursive: true, force: true}); + + macosLog('Recomposed xcframework:'); + execSync(`ls -la "${xcfwPath}/"`, {stdio: 'inherit'}); + + return true; +} + function abort(message /*: string */) { macosLog(message, 'error'); throw new Error(message); } +/** + * Appends a key=value pair to the GitHub Actions output file ($GITHUB_OUTPUT). + * No-op if $GITHUB_OUTPUT is not set (e.g. running locally). + */ +function setActionOutput(key /*: string */, value /*: string */) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `${key}=${value}\n`); + } +} + +// CLI entry point — writes results to $GITHUB_OUTPUT for GitHub Actions. +// Usage: +// node microsoft-resolveHermes.js download-hermes [Debug|Release] +// node microsoft-resolveHermes.js recompose-xcframework +// node microsoft-resolveHermes.js resolve-commit +if (require.main === module) { + const [command, ...args] = process.argv.slice(2); + + if (command === 'download-hermes') { + const buildType = args[0] || 'Debug'; + downloadUpstreamHermesTarball(buildType).then(result => { + if (result != null) { + setActionOutput('tarball', result.tarballPath); + setActionOutput('version', result.version); + macosLog( + `Downloaded upstream Hermes tarball for version ${result.version}`, + ); + } else { + macosLog('No upstream tarball available'); + } + }); + } else if (command === 'recompose-xcframework') { + const [tarball, destroot] = args; + if (!tarball || !destroot) { + console.error( + 'Usage: node microsoft-resolveHermes.js recompose-xcframework ', + ); + process.exit(1); + } + const recomposed = recomposeHermesXcframework(tarball, destroot); + setActionOutput('recomposed', String(recomposed)); + } else if (command === 'resolve-commit') { + const {commit} = hermesCommitAtMergeBase(); + setActionOutput('hermes-commit', commit); + macosLog(`Resolved Hermes commit: ${commit}`); + } else { + console.error( + `Unknown command: ${command ?? '(none)'}. Available: download-hermes, recompose-xcframework, resolve-commit`, + ); + process.exit(1); + } +} + module.exports = { findMatchingHermesVersion, hermesCommitAtMergeBase, findVersionAtMergeBase, getLatestStableVersionFromNPM, downloadUpstreamHermesTarball, + recomposeHermesXcframework, }; diff --git a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js index 5f4dc9b50a3..3649925785f 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -14,7 +14,7 @@ const { findMatchingHermesVersion, findVersionAtMergeBase, getLatestStableVersionFromNPM, -} = require('./macosVersionResolver'); // [macOS] +} = require('./microsoft-resolveHermes'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); From 4e396cda44cb6ca6152a542e3a7e76b1df6cbf14 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:03:00 -0700 Subject: [PATCH 15/19] fix(ci): save cache on any stable branch, not just 0.81-stable Use pattern match (startsWith + endsWith '-stable') so the cache save step works for future stable branches (0.82-stable, 0.83-stable, etc.) without requiring workflow changes. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-prebuild-macos-core.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/microsoft-prebuild-macos-core.yml b/.github/workflows/microsoft-prebuild-macos-core.yml index 6ea5fa03bdb..878678e7efe 100644 --- a/.github/workflows/microsoft-prebuild-macos-core.yml +++ b/.github/workflows/microsoft-prebuild-macos-core.yml @@ -69,7 +69,7 @@ jobs: run: node scripts/ios-prebuild.js -b -f Debug -p ${{ matrix.platform }} - name: Save slice cache - if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/0.81-stable' }} + if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable') }} uses: actions/cache/save@v4 with: key: v1-macos-core-${{ matrix.platform }}-Debug-${{ 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/**/*') }} @@ -162,7 +162,7 @@ jobs: tar -cz -f ../../ReactCoreDebug.framework.dSYM.tar.gz . - name: Save compose cache - if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/0.81-stable' }} + if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable') }} uses: actions/cache/save@v4 with: key: v1-macos-core-xcframework-Debug-${{ 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/**/*') }} From a30b944773f6fe4729fe7d6fe0d096b4557a4a37 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:08:24 -0700 Subject: [PATCH 16/19] refactor: move CI scripts to .github/scripts/resolve-hermes.mts Move the CLI entry point, recomposeHermesXcframework, and setActionOutput out of the library into a dedicated CI script using the zx/.mts pattern established by the existing .github/scripts/ files. microsoft-resolveHermes.js is now a pure library (no CLI, no CI concerns). The CI script imports from it and handles $GITHUB_OUTPUT writing. Co-Authored-By: Claude Opus 4.6 --- .github/scripts/resolve-hermes.mts | 138 ++++++++++++++++ .../workflows/microsoft-resolve-hermes.yml | 9 +- .../ios-prebuild/microsoft-resolveHermes.js | 156 +----------------- 3 files changed, 144 insertions(+), 159 deletions(-) create mode 100644 .github/scripts/resolve-hermes.mts diff --git a/.github/scripts/resolve-hermes.mts b/.github/scripts/resolve-hermes.mts new file mode 100644 index 00000000000..ee072a58ac6 --- /dev/null +++ b/.github/scripts/resolve-hermes.mts @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * CI entry point for resolving Hermes artifacts. + * + * Commands: + * node resolve-hermes.mts download-hermes [Debug|Release] + * node resolve-hermes.mts recompose-xcframework + * node resolve-hermes.mts resolve-commit + * + * Each command writes results to $GITHUB_OUTPUT for use in GitHub Actions. + */ +import { $, echo, fs, path } from 'zx'; + +// Import library functions from the react-native package +const { + downloadUpstreamHermesTarball, + hermesCommitAtMergeBase, +} = require('../../packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js'); + +function setActionOutput(key: string, value: string) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `${key}=${value}\n`); + } +} + +/** + * Extracts an upstream Hermes tarball and recomposes the xcframework to include + * the macOS slice, if needed. + * + * Upstream tarballs ship a universal xcframework (iOS, simulator, catalyst, + * tvOS, visionOS) plus a standalone macosx/hermes.framework. This function + * merges the standalone macOS framework into the universal xcframework using + * `xcodebuild -create-xcframework`. + * + * NOTE: Once upstream Hermes includes macOS in the universal xcframework + * natively, this function will detect the existing macOS slice and skip + * the recompose. At that point, this step can be removed entirely. + */ +async function recomposeHermesXcframework( + tarballPath: string, + destroot: string, +): Promise { + // Extract tarball + fs.mkdirSync(destroot, { recursive: true }); + await $`tar -xzf ${tarballPath} -C ${destroot} --strip-components=2`; + + const frameworksDir = path.join(destroot, 'Library', 'Frameworks'); + const xcfwPath = path.join(frameworksDir, 'universal', 'hermes.xcframework'); + + echo('Upstream tarball contents:'); + await $`ls -la ${frameworksDir}`; + + // Check if macOS is already in the universal xcframework — if so, no recompose needed + const xcfwContents = fs.readdirSync(xcfwPath); + const hasMacSlice = xcfwContents.some( + (entry: string) => entry.startsWith('macos') && entry.includes('arm64'), + ); + if (hasMacSlice) { + echo('macOS slice already present in universal xcframework, skipping recompose'); + const standaloneMacDir = path.join(frameworksDir, 'macosx'); + if (fs.existsSync(standaloneMacDir)) { + fs.removeSync(standaloneMacDir); + } + return true; + } + + // Check for standalone macOS framework + const standaloneMacFw = path.join(frameworksDir, 'macosx', 'hermes.framework'); + if (!fs.existsSync(standaloneMacFw)) { + echo('ERROR: Upstream tarball missing macosx/hermes.framework'); + return false; + } + + // Collect existing frameworks from inside the universal xcframework + const frameworkArgs: string[] = []; + for (const entry of xcfwContents) { + const fwPath = path.join(xcfwPath, entry, 'hermes.framework'); + if (fs.existsSync(fwPath) && fs.statSync(fwPath).isDirectory()) { + echo(`Found slice: ${fwPath}`); + frameworkArgs.push('-framework', fwPath); + } + } + + // Add the standalone macOS framework + echo(`Found standalone macOS slice: ${standaloneMacFw}`); + frameworkArgs.push('-framework', standaloneMacFw); + + // Build new xcframework at a temp path (frameworks reference paths inside the old xcfw) + const xcfwNew = path.join(frameworksDir, 'universal', 'hermes-new.xcframework'); + const sliceCount = frameworkArgs.filter(f => f !== '-framework').length; + echo(`Creating new universal xcframework with ${sliceCount} slices...`); + await $`xcodebuild -create-xcframework ${frameworkArgs} -output ${xcfwNew} -allow-internal-distribution`; + + // Swap in the recomposed xcframework + fs.removeSync(xcfwPath); + fs.renameSync(xcfwNew, xcfwPath); + + // Clean up standalone macOS dir (now included in universal) + fs.removeSync(path.join(frameworksDir, 'macosx')); + + echo('Recomposed xcframework:'); + await $`ls -la ${xcfwPath}/`; + + return true; +} + +// --- CLI dispatch --- + +const command = process.argv[2]; +const args = process.argv.slice(3); + +if (command === 'download-hermes') { + const buildType = args[0] || 'Debug'; + const result = await downloadUpstreamHermesTarball(buildType); + if (result != null) { + setActionOutput('tarball', result.tarballPath); + setActionOutput('version', result.version); + echo(`Downloaded upstream Hermes tarball for version ${result.version}`); + } else { + echo('No upstream tarball available'); + } +} else if (command === 'recompose-xcframework') { + const [tarball, destroot] = args; + if (!tarball || !destroot) { + echo('Usage: node resolve-hermes.mts recompose-xcframework '); + process.exit(1); + } + const recomposed = await recomposeHermesXcframework(tarball, destroot); + setActionOutput('recomposed', String(recomposed)); +} else if (command === 'resolve-commit') { + const { commit } = hermesCommitAtMergeBase(); + setActionOutput('hermes-commit', commit); + echo(`Resolved Hermes commit: ${commit}`); +} else { + echo(`Unknown command: ${command ?? '(none)'}. Available: download-hermes, recompose-xcframework, resolve-commit`); + process.exit(1); +} diff --git a/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index f143c3dbc3a..78f590715dd 100644 --- a/.github/workflows/microsoft-resolve-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.yml @@ -44,8 +44,7 @@ jobs: # Writes tarball= and version= to $GITHUB_OUTPUT if successful. - name: Download upstream Hermes tarball id: download - working-directory: packages/react-native - run: node scripts/ios-prebuild/microsoft-resolveHermes.js download-hermes Debug + run: node .github/scripts/resolve-hermes.mts download-hermes Debug # Step 2: If tarball found, recompose the xcframework to include the macOS slice # (or skip if macOS is already present in the universal xcframework). @@ -53,9 +52,8 @@ jobs: - name: Recompose xcframework with macOS slice id: recompose if: steps.download.outputs.tarball != '' - working-directory: packages/react-native run: >- - node scripts/ios-prebuild/microsoft-resolveHermes.js + node .github/scripts/resolve-hermes.mts recompose-xcframework "${{ steps.download.outputs.tarball }}" "${{ github.workspace }}/hermes-destroot" @@ -74,8 +72,7 @@ jobs: - name: Resolve Hermes commit at merge base if: steps.recompose.outputs.recomposed != 'true' id: resolve - working-directory: packages/react-native - run: node scripts/ios-prebuild/microsoft-resolveHermes.js resolve-commit + run: node .github/scripts/resolve-hermes.mts resolve-commit - name: Restore Hermes cache if: steps.recompose.outputs.recomposed != 'true' diff --git a/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js b/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js index a197a72c64d..77c18e3e08e 100644 --- a/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js +++ b/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js @@ -6,13 +6,9 @@ * * [macOS] Resolves Hermes artifacts for macOS fork branches. * - * Handles downloading upstream Hermes tarballs, recomposing xcframeworks - * to include the macOS slice, and resolving Hermes commits for - * build-from-source fallback. Used as both a library and CLI: - * - * node microsoft-resolveHermes.js download-hermes [Debug|Release] - * node microsoft-resolveHermes.js recompose-xcframework - * node microsoft-resolveHermes.js resolve-commit + * Library functions for version resolution, downloading upstream Hermes + * tarballs, and resolving Hermes commits. The CI entry point that + * orchestrates these is at .github/scripts/resolve-hermes.mts. * * @flow * @format @@ -293,161 +289,15 @@ async function downloadUpstreamHermesTarball( return null; } -/** - * Extracts an upstream Hermes tarball and recomposes the xcframework to include - * the macOS slice, if needed. - * - * Upstream tarballs ship a universal xcframework (iOS, simulator, catalyst, - * tvOS, visionOS) plus a standalone macosx/hermes.framework. This function - * merges the standalone macOS framework into the universal xcframework using - * `xcodebuild -create-xcframework`. - * - * NOTE: Once upstream Hermes includes macOS in the universal xcframework - * natively, this function will detect the existing macOS slice and skip - * the recompose. At that point, this step can be removed entirely. - * - * Returns true if the xcframework was recomposed (or already had macOS), - * false if the tarball is missing the macOS framework entirely. - */ -function recomposeHermesXcframework( - tarballPath /*: string */, - destroot /*: string */, -) /*: boolean */ { - // Extract tarball - fs.mkdirSync(destroot, {recursive: true}); - execSync(`tar -xzf "${tarballPath}" -C "${destroot}" --strip-components=2`, { - stdio: 'inherit', - }); - - const frameworksDir = path.join(destroot, 'Library', 'Frameworks'); - const xcfwPath = path.join(frameworksDir, 'universal', 'hermes.xcframework'); - - macosLog('Upstream tarball contents:'); - execSync(`ls -la "${frameworksDir}"`, {stdio: 'inherit'}); - - // Check if macOS is already in the universal xcframework — if so, no recompose needed - const xcfwContents = fs.readdirSync(xcfwPath); - const hasMacSlice = xcfwContents.some( - entry => entry.startsWith('macos') && entry.includes('arm64'), - ); - if (hasMacSlice) { - macosLog('macOS slice already present in universal xcframework, skipping recompose'); - // Clean up standalone macOS dir if it exists - const standaloneMacDir = path.join(frameworksDir, 'macosx'); - if (fs.existsSync(standaloneMacDir)) { - fs.rmSync(standaloneMacDir, {recursive: true, force: true}); - } - return true; - } - - // Check for standalone macOS framework - const standaloneMacFw = path.join(frameworksDir, 'macosx', 'hermes.framework'); - if (!fs.existsSync(standaloneMacFw)) { - macosLog('Upstream tarball missing macosx/hermes.framework', 'error'); - return false; - } - - // Collect existing frameworks from inside the universal xcframework - const frameworks /*: string[] */ = []; - for (const entry of xcfwContents) { - const fwPath = path.join(xcfwPath, entry, 'hermes.framework'); - if (fs.existsSync(fwPath) && fs.statSync(fwPath).isDirectory()) { - macosLog(`Found slice: ${fwPath}`); - frameworks.push('-framework', fwPath); - } - } - - // Add the standalone macOS framework - macosLog(`Found standalone macOS slice: ${standaloneMacFw}`); - frameworks.push('-framework', standaloneMacFw); - - // Build new xcframework at a temp path (frameworks reference paths inside the old xcfw) - const xcfwNew = path.join(frameworksDir, 'universal', 'hermes-new.xcframework'); - macosLog( - `Creating new universal xcframework with ${frameworks.filter(f => f !== '-framework').length} slices...`, - ); - execSync( - `xcodebuild -create-xcframework ${frameworks.map(f => `"${f}"`).join(' ')} -output "${xcfwNew}" -allow-internal-distribution`, - {stdio: 'inherit'}, - ); - - // Swap in the recomposed xcframework - fs.rmSync(xcfwPath, {recursive: true, force: true}); - fs.renameSync(xcfwNew, xcfwPath); - - // Clean up standalone macOS dir (now included in universal) - fs.rmSync(path.join(frameworksDir, 'macosx'), {recursive: true, force: true}); - - macosLog('Recomposed xcframework:'); - execSync(`ls -la "${xcfwPath}/"`, {stdio: 'inherit'}); - - return true; -} - function abort(message /*: string */) { macosLog(message, 'error'); throw new Error(message); } -/** - * Appends a key=value pair to the GitHub Actions output file ($GITHUB_OUTPUT). - * No-op if $GITHUB_OUTPUT is not set (e.g. running locally). - */ -function setActionOutput(key /*: string */, value /*: string */) { - const outputFile = process.env.GITHUB_OUTPUT; - if (outputFile) { - fs.appendFileSync(outputFile, `${key}=${value}\n`); - } -} - -// CLI entry point — writes results to $GITHUB_OUTPUT for GitHub Actions. -// Usage: -// node microsoft-resolveHermes.js download-hermes [Debug|Release] -// node microsoft-resolveHermes.js recompose-xcframework -// node microsoft-resolveHermes.js resolve-commit -if (require.main === module) { - const [command, ...args] = process.argv.slice(2); - - if (command === 'download-hermes') { - const buildType = args[0] || 'Debug'; - downloadUpstreamHermesTarball(buildType).then(result => { - if (result != null) { - setActionOutput('tarball', result.tarballPath); - setActionOutput('version', result.version); - macosLog( - `Downloaded upstream Hermes tarball for version ${result.version}`, - ); - } else { - macosLog('No upstream tarball available'); - } - }); - } else if (command === 'recompose-xcframework') { - const [tarball, destroot] = args; - if (!tarball || !destroot) { - console.error( - 'Usage: node microsoft-resolveHermes.js recompose-xcframework ', - ); - process.exit(1); - } - const recomposed = recomposeHermesXcframework(tarball, destroot); - setActionOutput('recomposed', String(recomposed)); - } else if (command === 'resolve-commit') { - const {commit} = hermesCommitAtMergeBase(); - setActionOutput('hermes-commit', commit); - macosLog(`Resolved Hermes commit: ${commit}`); - } else { - console.error( - `Unknown command: ${command ?? '(none)'}. Available: download-hermes, recompose-xcframework, resolve-commit`, - ); - process.exit(1); - } -} - module.exports = { findMatchingHermesVersion, hermesCommitAtMergeBase, findVersionAtMergeBase, getLatestStableVersionFromNPM, downloadUpstreamHermesTarball, - recomposeHermesXcframework, }; From 432bdf0b78be1044d8924ad8c94392935119660c Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:13:22 -0700 Subject: [PATCH 17/19] refactor: move downloadUpstreamHermesTarball to CI script downloadUpstreamHermesTarball is only called from CI, not by any library consumer. Move it (and its computeNightlyTarballURL dependency) to .github/scripts/resolve-hermes.mts where it belongs. microsoft-resolveHermes.js is now a pure library with only the functions imported by hermes.js and reactNativeDependencies.js. Co-Authored-By: Claude Opus 4.6 --- .github/scripts/resolve-hermes.mts | 96 ++++++++++++++- .../ios-prebuild/microsoft-resolveHermes.js | 110 +----------------- 2 files changed, 99 insertions(+), 107 deletions(-) diff --git a/.github/scripts/resolve-hermes.mts b/.github/scripts/resolve-hermes.mts index ee072a58ac6..b6cf9199e2f 100644 --- a/.github/scripts/resolve-hermes.mts +++ b/.github/scripts/resolve-hermes.mts @@ -9,13 +9,19 @@ * * Each command writes results to $GITHUB_OUTPUT for use in GitHub Actions. */ +import os from 'node:os'; import { $, echo, fs, path } from 'zx'; // Import library functions from the react-native package const { - downloadUpstreamHermesTarball, + findMatchingHermesVersion, + findVersionAtMergeBase, + getLatestStableVersionFromNPM, hermesCommitAtMergeBase, } = require('../../packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js'); +const { + computeNightlyTarballURL, +} = require('../../packages/react-native/scripts/ios-prebuild/utils.js'); function setActionOutput(key: string, value: string) { const outputFile = process.env.GITHUB_OUTPUT; @@ -24,6 +30,94 @@ function setActionOutput(key: string, value: string) { } } +/** + * Downloads the upstream Hermes tarball from Maven or Sonatype. + * + * Tries multiple version resolution strategies in order: + * 1. Mapped version from peerDependencies (stable branches) + * 2. Version at merge base with facebook/react-native (main branch) + * 3. Latest stable version from npm (last resort) + * + * Returns {tarballPath, version} on success, or null if no tarball is available. + */ +async function downloadUpstreamHermesTarball( + buildType: string = 'Debug', +): Promise<{ tarballPath: string; version: string } | null> { + const packageJsonPath = path.resolve( + import.meta.dirname!, '..', '..', 'packages', 'react-native', 'package.json', + ); + + // Build a list of candidate versions to try (in priority order) + const candidates: string[] = []; + + const mapped = findMatchingHermesVersion(packageJsonPath); + if (mapped != null) { + candidates.push(mapped); + } + + const mergeBaseVersion = findVersionAtMergeBase(); + if (mergeBaseVersion != null && !candidates.includes(mergeBaseVersion)) { + candidates.push(mergeBaseVersion); + } + + try { + const latestStable = await getLatestStableVersionFromNPM(); + if (!candidates.includes(latestStable)) { + candidates.push(latestStable); + } + } catch { + // npm lookup failed, continue with what we have + } + + if (candidates.length === 0) { + echo('Could not determine any upstream version to download Hermes tarball'); + return null; + } + + const mavenRepoUrl = 'https://repo1.maven.org/maven2'; + const namespace = 'com/facebook/react'; + + for (const version of candidates) { + const releaseUrl = `${mavenRepoUrl}/${namespace}/react-native-artifacts/${version}/react-native-artifacts-${version}-hermes-ios-${buildType.toLowerCase()}.tar.gz`; + const nightlyUrl = await computeNightlyTarballURL( + version, + buildType, + 'react-native-artifacts', + `hermes-ios-${buildType.toLowerCase()}.tar.gz`, + ); + const urlsToTry = [releaseUrl]; + if (nightlyUrl) { + urlsToTry.push(nightlyUrl); + } + + for (const tarballUrl of urlsToTry) { + echo(`Trying upstream Hermes tarball (version: ${version}, ${buildType}) at ${tarballUrl}...`); + + try { + const response = await fetch(tarballUrl); + if (!response.ok) { + echo(`Tarball not available: ${response.status} ${response.statusText}`); + continue; + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-')); + const tarballPath = path.join(tmpDir, 'hermes-ios.tar.gz'); + const buffer = await response.arrayBuffer(); + fs.writeFileSync(tarballPath, Buffer.from(buffer)); + + echo(`Downloaded upstream Hermes tarball (${version}) to ${tarballPath}`); + return { tarballPath, version }; + } catch (e: any) { + echo(`Error downloading tarball for ${version}: ${e.message}`); + continue; + } + } + } + + echo('No upstream Hermes tarball found for any candidate version — will build from source.'); + return null; +} + /** * Extracts an upstream Hermes tarball and recomposes the xcframework to include * the macOS slice, if needed. diff --git a/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js b/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js index 77c18e3e08e..9bdeec72d8f 100644 --- a/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js +++ b/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js @@ -6,17 +6,15 @@ * * [macOS] Resolves Hermes artifacts for macOS fork branches. * - * Library functions for version resolution, downloading upstream Hermes - * tarballs, and resolving Hermes commits. The CI entry point that - * orchestrates these is at .github/scripts/resolve-hermes.mts. + * Library functions for version resolution and resolving Hermes commits. + * The CI entry point that orchestrates downloading, recomposing, and + * caching is at .github/scripts/resolve-hermes.mts. * * @flow * @format */ -/*:: import type {BuildFlavor} from './types'; */ - -const {computeNightlyTarballURL, createLogger} = require('./utils'); +const {createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); const os = require('os'); @@ -190,105 +188,6 @@ async function getLatestStableVersionFromNPM() /*: Promise */ { return json.version; } -/** - * Downloads the upstream Hermes tarball from Maven or Sonatype. - * The caller is responsible for extracting and recomposing the - * xcframework (e.g. adding the macOS slice to the universal). - * - * Tries multiple version resolution strategies in order: - * 1. Mapped version from peerDependencies (stable branches) - * 2. Version at merge base with facebook/react-native (main branch) - * 3. Latest stable version from npm (last resort) - * - * Returns {tarballPath, version} on success, or null if no tarball is available. - */ -async function downloadUpstreamHermesTarball( - buildType /*: BuildFlavor */ = 'Debug', -) /*: Promise */ { - const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); - - // Build a list of candidate versions to try (in priority order) - const candidates /*: string[] */ = []; - - const mapped = findMatchingHermesVersion(packageJsonPath); - if (mapped != null) { - candidates.push(mapped); - } - - const mergeBaseVersion = findVersionAtMergeBase(); - if (mergeBaseVersion != null && !candidates.includes(mergeBaseVersion)) { - candidates.push(mergeBaseVersion); - } - - try { - const latestStable = await getLatestStableVersionFromNPM(); - if (!candidates.includes(latestStable)) { - candidates.push(latestStable); - } - } catch (_) { - // npm lookup failed, continue with what we have - } - - if (candidates.length === 0) { - macosLog( - 'Could not determine any upstream version to download Hermes tarball', - ); - return null; - } - - const mavenRepoUrl = 'https://repo1.maven.org/maven2'; - const namespace = 'com/facebook/react'; - - for (const version of candidates) { - // Try both Maven release and nightly (Sonatype snapshot) URLs - const releaseUrl = `${mavenRepoUrl}/${namespace}/react-native-artifacts/${version}/react-native-artifacts-${version}-hermes-ios-${buildType.toLowerCase()}.tar.gz`; - const nightlyUrl = await computeNightlyTarballURL( - version, - buildType, - 'react-native-artifacts', - `hermes-ios-${buildType.toLowerCase()}.tar.gz`, - ); - const urlsToTry = [releaseUrl]; - if (nightlyUrl) { - urlsToTry.push(nightlyUrl); - } - - for (const tarballUrl of urlsToTry) { - macosLog( - `Trying upstream Hermes tarball (version: ${version}, ${buildType}) at ${tarballUrl}...`, - ); - - try { - const response /*: Response */ = await fetch(tarballUrl); - if (!response.ok) { - macosLog( - `Tarball not available: ${response.status} ${response.statusText}`, - ); - continue; - } - - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-')); - const tarballPath = path.join(tmpDir, 'hermes-ios.tar.gz'); - const buffer = await response.arrayBuffer(); - fs.writeFileSync(tarballPath, Buffer.from(buffer)); - - macosLog( - `Downloaded upstream Hermes tarball (${version}) to ${tarballPath}`, - ); - return {tarballPath, version}; - } catch (e) { - macosLog(`Error downloading tarball for ${version}: ${e.message}`); - continue; - } - } - } - - macosLog( - 'No upstream Hermes tarball found for any candidate version — will build from source.', - ); - return null; -} - function abort(message /*: string */) { macosLog(message, 'error'); throw new Error(message); @@ -299,5 +198,4 @@ module.exports = { hermesCommitAtMergeBase, findVersionAtMergeBase, getLatestStableVersionFromNPM, - downloadUpstreamHermesTarball, }; From b40ac212390860f7a6624da4ff662355ede0a861 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:17:19 -0700 Subject: [PATCH 18/19] refactor: rename to microsoft-hermes, use parseArgs, add PR links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rename microsoft-resolveHermes.js → microsoft-hermes.js for brevity 2. Use Node's parseArgs + switch/case instead of manual argv parsing, matching the pattern in change.mts 3. Add tracking PR links to the recompose JSDoc for when upstream Hermes includes macOS natively and the recompose can be removed Co-Authored-By: Claude Opus 4.6 --- .github/scripts/resolve-hermes.mts | 70 ++++++++++++------- .../scripts/ios-prebuild/hermes.js | 2 +- ...t-resolveHermes.js => microsoft-hermes.js} | 0 .../ios-prebuild/reactNativeDependencies.js | 2 +- 4 files changed, 45 insertions(+), 29 deletions(-) rename packages/react-native/scripts/ios-prebuild/{microsoft-resolveHermes.js => microsoft-hermes.js} (100%) diff --git a/.github/scripts/resolve-hermes.mts b/.github/scripts/resolve-hermes.mts index b6cf9199e2f..b07eb8d2ad2 100644 --- a/.github/scripts/resolve-hermes.mts +++ b/.github/scripts/resolve-hermes.mts @@ -10,6 +10,7 @@ * Each command writes results to $GITHUB_OUTPUT for use in GitHub Actions. */ import os from 'node:os'; +import { parseArgs } from 'node:util'; import { $, echo, fs, path } from 'zx'; // Import library functions from the react-native package @@ -18,7 +19,7 @@ const { findVersionAtMergeBase, getLatestStableVersionFromNPM, hermesCommitAtMergeBase, -} = require('../../packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js'); +} = require('../../packages/react-native/scripts/ios-prebuild/microsoft-hermes.js'); const { computeNightlyTarballURL, } = require('../../packages/react-native/scripts/ios-prebuild/utils.js'); @@ -130,6 +131,10 @@ async function downloadUpstreamHermesTarball( * NOTE: Once upstream Hermes includes macOS in the universal xcframework * natively, this function will detect the existing macOS slice and skip * the recompose. At that point, this step can be removed entirely. + * Tracking PRs: + * - https://github.com/facebook/hermes/pull/1958 + * - https://github.com/facebook/hermes/pull/1970 + * - https://github.com/facebook/hermes/pull/1971 */ async function recomposeHermesXcframework( tarballPath: string, @@ -201,32 +206,43 @@ async function recomposeHermesXcframework( // --- CLI dispatch --- -const command = process.argv[2]; -const args = process.argv.slice(3); - -if (command === 'download-hermes') { - const buildType = args[0] || 'Debug'; - const result = await downloadUpstreamHermesTarball(buildType); - if (result != null) { - setActionOutput('tarball', result.tarballPath); - setActionOutput('version', result.version); - echo(`Downloaded upstream Hermes tarball for version ${result.version}`); - } else { - echo('No upstream tarball available'); +const { positionals } = parseArgs({ + allowPositionals: true, + strict: false, +}); + +const [command, ...args] = positionals; + +switch (command) { + case 'download-hermes': { + const buildType = args[0] || 'Debug'; + const result = await downloadUpstreamHermesTarball(buildType); + if (result != null) { + setActionOutput('tarball', result.tarballPath); + setActionOutput('version', result.version); + echo(`Downloaded upstream Hermes tarball for version ${result.version}`); + } else { + echo('No upstream tarball available'); + } + break; } -} else if (command === 'recompose-xcframework') { - const [tarball, destroot] = args; - if (!tarball || !destroot) { - echo('Usage: node resolve-hermes.mts recompose-xcframework '); - process.exit(1); + case 'recompose-xcframework': { + const [tarball, destroot] = args; + if (!tarball || !destroot) { + echo('Usage: node resolve-hermes.mts recompose-xcframework '); + process.exit(1); + } + const recomposed = await recomposeHermesXcframework(tarball, destroot); + setActionOutput('recomposed', String(recomposed)); + break; } - const recomposed = await recomposeHermesXcframework(tarball, destroot); - setActionOutput('recomposed', String(recomposed)); -} else if (command === 'resolve-commit') { - const { commit } = hermesCommitAtMergeBase(); - setActionOutput('hermes-commit', commit); - echo(`Resolved Hermes commit: ${commit}`); -} else { - echo(`Unknown command: ${command ?? '(none)'}. Available: download-hermes, recompose-xcframework, resolve-commit`); - process.exit(1); + case 'resolve-commit': { + const { commit } = hermesCommitAtMergeBase(); + setActionOutput('hermes-commit', commit); + echo(`Resolved Hermes commit: ${commit}`); + break; + } + default: + echo(`Unknown command: ${command ?? '(none)'}. Available: download-hermes, recompose-xcframework, resolve-commit`); + process.exit(1); } diff --git a/packages/react-native/scripts/ios-prebuild/hermes.js b/packages/react-native/scripts/ios-prebuild/hermes.js index 497d864c226..eca19d2cec4 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes.js +++ b/packages/react-native/scripts/ios-prebuild/hermes.js @@ -11,7 +11,7 @@ const { findMatchingHermesVersion, hermesCommitAtMergeBase, -} = require('./microsoft-resolveHermes'); // [macOS] +} = require('./microsoft-hermes'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); diff --git a/packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js similarity index 100% rename from packages/react-native/scripts/ios-prebuild/microsoft-resolveHermes.js rename to packages/react-native/scripts/ios-prebuild/microsoft-hermes.js diff --git a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js index 3649925785f..d29182a0b87 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -14,7 +14,7 @@ const { findMatchingHermesVersion, findVersionAtMergeBase, getLatestStableVersionFromNPM, -} = require('./microsoft-resolveHermes'); // [macOS] +} = require('./microsoft-hermes'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); From 0a168fbb0a02eb0a0e6cbf6b1b36e7e0f6ebe19a Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:29:31 -0700 Subject: [PATCH 19/19] fix(ci): use createRequire to import CommonJS from ESM script Node v22 throws ERR_AMBIGUOUS_MODULE_SYNTAX when .mts files mix require() with top-level await. Use createRequire(import.meta.url) to properly import CommonJS modules from the ESM context. Co-Authored-By: Claude Opus 4.6 --- .github/scripts/resolve-hermes.mts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/resolve-hermes.mts b/.github/scripts/resolve-hermes.mts index b07eb8d2ad2..8d628957359 100644 --- a/.github/scripts/resolve-hermes.mts +++ b/.github/scripts/resolve-hermes.mts @@ -9,11 +9,13 @@ * * Each command writes results to $GITHUB_OUTPUT for use in GitHub Actions. */ +import { createRequire } from 'node:module'; import os from 'node:os'; import { parseArgs } from 'node:util'; import { $, echo, fs, path } from 'zx'; -// Import library functions from the react-native package +// Use createRequire to import CommonJS modules from ESM context +const require = createRequire(import.meta.url); const { findMatchingHermesVersion, findVersionAtMergeBase,