From 578f7a09ded5996af4623e77d38e9a07f03d9c27 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Wed, 15 Apr 2026 12:40:05 -0700 Subject: [PATCH 01/13] feat(0.81, 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 - Add visionos-simulator platform to ios-prebuild CLI Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-build-spm.yml | 83 ++++++++++++++++++- .../react-native/scripts/ios-prebuild/cli.js | 2 + .../scripts/ios-prebuild/types.js | 10 ++- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/.github/workflows/microsoft-build-spm.yml b/.github/workflows/microsoft-build-spm.yml index ae9752c507f9..7d8058beff1b 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: @@ -255,3 +255,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 diff --git a/packages/react-native/scripts/ios-prebuild/cli.js b/packages/react-native/scripts/ios-prebuild/cli.js index c2f3586f46f5..e18e663df0a8 100644 --- a/packages/react-native/scripts/ios-prebuild/cli.js +++ b/packages/react-native/scripts/ios-prebuild/cli.js @@ -20,6 +20,7 @@ const platforms /*: $ReadOnlyArray */ = [ 'mac-catalyst', 'macos', // [macOS] 'visionos', // [macOS] + 'visionos-simulator', // [macOS] ]; // CI can't use commas in cache keys, so 'macOS,variant=Mac Catalyst' was creating troubles @@ -30,6 +31,7 @@ const platformToDestination /*: $ReadOnly<{|[Platform]: Destination|}> */ = { 'mac-catalyst': 'macOS,variant=Mac Catalyst', macos: 'macOS', // [macOS] visionos: 'visionOS', // [macOS] + 'visionos-simulator': 'visionOS Simulator', // [macOS] }; const cli = yargs diff --git a/packages/react-native/scripts/ios-prebuild/types.js b/packages/react-native/scripts/ios-prebuild/types.js index 87f516c5ba99..cdb554815a63 100644 --- a/packages/react-native/scripts/ios-prebuild/types.js +++ b/packages/react-native/scripts/ios-prebuild/types.js @@ -13,15 +13,17 @@ export type Platform = 'ios' | 'ios-simulator' | 'mac-catalyst' | - 'macos' | // [macOS] - 'visionos'; // [macOS] + 'macos' | // [macOS] + 'visionos' | // [macOS] + 'visionos-simulator'; // [macOS] export type Destination = 'iOS' | 'iOS Simulator' | 'macOS,variant=Mac Catalyst' | - 'macOS' | // [macOS] - 'visionOS'; // [macOS] + 'macOS' | // [macOS] + 'visionOS' | // [macOS] + 'visionOS Simulator'; // [macOS] export type BuildFlavor = 'Debug' | 'Release'; */ From 147328943a3c2efb02760577366ca2f2123b89ff Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 14:46:28 -0700 Subject: [PATCH 02/13] feat(0.81, 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 7d8058beff1b..fb56b5bd134e 100644 --- a/.github/workflows/microsoft-build-spm.yml +++ b/.github/workflows/microsoft-build-spm.yml @@ -226,36 +226,60 @@ 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: | 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 }}) + 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: @@ -279,16 +303,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-* @@ -296,6 +332,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-* @@ -303,6 +340,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 @@ -310,19 +348,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 c609b3066218dc0e4096953f21ceec56740ec4a1 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:37:20 -0700 Subject: [PATCH 03/13] 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 c20f2c7edfc0..157cd8b05fa2 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -132,10 +132,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" @@ -162,7 +162,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 fb56b5bd134e..013571d1b608 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: @@ -209,8 +209,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() }} @@ -259,14 +259,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 }} @@ -294,7 +294,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 35a24366864895a7a363bf4fcd25f06648980220 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:41:54 -0700 Subject: [PATCH 04/13] 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 013571d1b608..94f6ef079c65 100644 --- a/.github/workflows/microsoft-prebuild-macos-core.yml +++ b/.github/workflows/microsoft-prebuild-macos-core.yml @@ -17,15 +17,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 @@ -64,8 +60,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 @@ -101,21 +100,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 43687e36577b8d2442dae5b90d746053359b4ecf Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:56:10 -0700 Subject: [PATCH 05/13] 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 | 215 +++++++++++++++++ .../microsoft-prebuild-macos-core.yml | 216 +----------------- 2 files changed, 219 insertions(+), 212 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 000000000000..641bd136eaa1 --- /dev/null +++ b/.github/workflows/microsoft-build-hermes.yml @@ -0,0 +1,215 @@ +name: Build Hermes + +on: + workflow_call: + +jobs: + resolve-hermes: + name: "Resolve Hermes" + runs-on: macos-15 + timeout-minutes: 10 + outputs: + hermes-commit: ${{ steps.resolve.outputs.hermes-commit }} + cache-hit: ${{ steps.cache.outputs.cache-hit }} + 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: Resolve Hermes commit at merge base + 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 + 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.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.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.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.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 94f6ef079c65..1b4f226c1c11 100644 --- a/.github/workflows/microsoft-prebuild-macos-core.yml +++ b/.github/workflows/microsoft-prebuild-macos-core.yml @@ -4,221 +4,13 @@ on: workflow_call: jobs: - resolve-hermes: - name: "Resolve Hermes" - runs-on: macos-15 - timeout-minutes: 10 - outputs: - hermes-commit: ${{ steps.resolve.outputs.hermes-commit }} - cache-hit: ${{ steps.cache.outputs.cache-hit }} - 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: Resolve Hermes commit at merge base - 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 - 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.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.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.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.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 13081a97cf297883ee43bdb24dd7a8ca7f6376c6 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 16:59:21 -0700 Subject: [PATCH 06/13] 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 1b4f226c1c11..6ea5fa03bdb3 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 641bd136eaa1..8474efb8547a 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 3b5be70dad22687ce1265520d7d3c0ef59bf464d Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 17:19:15 -0700 Subject: [PATCH 07/13] 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. Build-from-source is kept as a fallback when no upstream tarball is available (e.g. Maven is down). Co-Authored-By: Claude Opus 4.6 --- .../workflows/microsoft-resolve-hermes.yml | 92 ++++++++++++++++++- .../ios-prebuild/macosVersionResolver.js | 47 ++++++++++ 2 files changed, 134 insertions(+), 5 deletions(-) diff --git a/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index 8474efb8547a..999bf964951d 100644 --- a/.github/workflows/microsoft-resolve-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.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 }} + recomposed: ${{ steps.recompose.outputs.recomposed }} steps: - uses: actions/checkout@v4 with: @@ -26,7 +27,87 @@ jobs: - name: Install npm dependencies run: yarn install + - 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 + + - 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" + + - 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.recompose.outputs.recomposed != 'true' id: resolve working-directory: packages/react-native run: | @@ -35,6 +116,7 @@ jobs: echo "Resolved Hermes commit: $COMMIT" - name: Restore Hermes cache + if: steps.recompose.outputs.recomposed != 'true' id: cache uses: actions/cache/restore@v4 with: @@ -42,7 +124,7 @@ jobs: path: hermes-destroot - name: Upload cached Hermes artifacts - if: 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 @@ -51,7 +133,7 @@ jobs: build-hermesc: name: "Build hermesc" - if: ${{ 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 @@ -92,7 +174,7 @@ jobs: build-hermes-slice: name: "Hermes ${{ matrix.slice }}" - if: ${{ 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 @@ -160,7 +242,7 @@ jobs: assemble-hermes: name: "Assemble Hermes xcframework" - if: ${{ 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 70b85d3cebeb..cc25d1a9ac1d 100644 --- a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js +++ b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js @@ -184,6 +184,52 @@ async function getLatestStableVersionFromNPM() /*: Promise */ { return json.version; } +/** + * Downloads the upstream Hermes tarball from Maven for the mapped + * upstream version. Returns the tarball path and version on success, + * or null if no tarball is available. + * + * The caller is responsible for extracting and recomposing the + * xcframework (e.g. adding the macOS slice to the universal). + */ +async function downloadUpstreamHermesTarball( + buildType /*: string */ = 'Debug', +) /*: Promise */ { + const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); + const version = findMatchingHermesVersion(packageJsonPath); + if (version == null) { + macosLog('No upstream version found, cannot download tarball'); + return null; + } + + const mavenUrl = `https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/${version}/react-native-artifacts-${version}-hermes-ios-${buildType.toLowerCase()}.tar.gz`; + + macosLog( + `Downloading upstream Hermes tarball (${version}) from ${mavenUrl}...`, + ); + + try { + const response /*: Response */ = await fetch(mavenUrl); + if (!response.ok) { + macosLog( + `Tarball not found: ${response.status} ${response.statusText}`, + ); + return null; + } + + 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 to ${tarballPath}`); + return {tarballPath, version}; + } catch (e) { + macosLog(`Error downloading tarball: ${e.message}`); + return null; + } +} + function abort(message /*: string */) { macosLog(message, 'error'); throw new Error(message); @@ -194,4 +240,5 @@ module.exports = { hermesCommitAtMergeBase, findVersionAtMergeBase, getLatestStableVersionFromNPM, + downloadUpstreamHermesTarball, }; From ef6425806ee4bea7834662e3e3fcaaaec2658b35 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 17:40:44 -0700 Subject: [PATCH 08/13] fix(ci): create recomposed xcframework at temp path before swap The source frameworks live inside the old xcframework, so we can't delete it before creating the new one. Build to a temp path first, then swap. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/microsoft-resolve-hermes.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index 999bf964951d..a7d1c8b789c7 100644 --- a/.github/workflows/microsoft-resolve-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.yml @@ -82,14 +82,17 @@ jobs: exit 0 fi - # Remove old xcframework and create new one with macOS included - rm -rf "$XCFW" + # Create new xcframework at a temp path (can't overwrite in-place + # because the source frameworks live inside the old xcframework) + XCFW_NEW="hermes-destroot/Library/Frameworks/universal/hermes-new.xcframework" echo "Creating new universal xcframework with ${#FRAMEWORKS[@]} frameworks..." xcodebuild -create-xcframework "${FRAMEWORKS[@]}" \ - -output "$XCFW" \ + -output "$XCFW_NEW" \ -allow-internal-distribution - # Clean up standalone macOS dir (now included in universal) + # Swap in the new xcframework and clean up + rm -rf "$XCFW" + mv "$XCFW_NEW" "$XCFW" rm -rf hermes-destroot/Library/Frameworks/macosx echo "=== Recomposed xcframework ===" From 3e63d3b2066722f7b15310913541ce82fef3e2fe Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:19:38 -0700 Subject: [PATCH 09/13] =?UTF-8?q?refactor:=20rename=20macosVersionResolver?= =?UTF-8?q?=20=E2=86=92=20microsoft-hermes?= 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. Co-Authored-By: Claude Opus 4.6 --- .../ios-prebuild/{macosVersionResolver.js => microsoft-hermes.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react-native/scripts/ios-prebuild/{macosVersionResolver.js => microsoft-hermes.js} (100%) diff --git a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js similarity index 100% rename from packages/react-native/scripts/ios-prebuild/macosVersionResolver.js rename to packages/react-native/scripts/ios-prebuild/microsoft-hermes.js From a4d3dc29c1ee97e892ae77e28693f5418317d4af Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:21:41 -0700 Subject: [PATCH 10/13] refactor: add CLI, recompose function, flow comments, and cache fix - Add CLI entry point to microsoft-hermes.js with download-hermes, recompose-xcframework, and resolve-commit commands - Move recompose xcframework logic from inline shell into JS function recomposeHermesXcframework() with macOS-already-present check - Add PR links for when upstream Hermes includes macOS natively - Replace inline node -e scripts in workflow with clean CLI calls - Add section comments documenting the resolve-hermes workflow strategy - Fix cache save condition to match any *-stable branch - Update import references to new filename Co-Authored-By: Claude Opus 4.6 --- .../microsoft-prebuild-macos-core.yml | 4 +- .../workflows/microsoft-resolve-hermes.yml | 103 ++++------- .../scripts/ios-prebuild/hermes.js | 2 +- .../scripts/ios-prebuild/microsoft-hermes.js | 169 +++++++++++++++++- .../ios-prebuild/reactNativeDependencies.js | 2 +- 5 files changed, 205 insertions(+), 75 deletions(-) diff --git a/.github/workflows/microsoft-prebuild-macos-core.yml b/.github/workflows/microsoft-prebuild-macos-core.yml index 6ea5fa03bdb3..878678e7efef 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/**/*') }} diff --git a/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index a7d1c8b789c7..688efcae412d 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 +# 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,79 +40,27 @@ jobs: - name: Install npm dependencies run: yarn install + # Step 1: Try to download a prebuilt Hermes tarball from upstream Maven. + # 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-hermes.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 - - # Create new xcframework at a temp path (can't overwrite in-place - # because the source frameworks live inside the old xcframework) - XCFW_NEW="hermes-destroot/Library/Frameworks/universal/hermes-new.xcframework" - echo "Creating new universal xcframework with ${#FRAMEWORKS[@]} frameworks..." - xcodebuild -create-xcframework "${FRAMEWORKS[@]}" \ - -output "$XCFW_NEW" \ - -allow-internal-distribution - - # Swap in the new xcframework and clean up - rm -rf "$XCFW" - mv "$XCFW_NEW" "$XCFW" - 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-hermes.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 @@ -108,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-hermes.js resolve-commit - name: Restore Hermes cache if: steps.recompose.outputs.recomposed != 'true' @@ -134,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 4b997cb4a903..92cc5b5eda61 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-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-hermes.js b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js index cc25d1a9ac1d..099f18bc0267 100644 --- a/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js +++ b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js @@ -4,7 +4,12 @@ * 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] Hermes version resolution and xcframework recomposition for + * macOS fork branches. Used as both a library and CLI: + * + * node microsoft-hermes.js download-hermes [Debug|Release] + * node microsoft-hermes.js recompose-xcframework + * node microsoft-hermes.js resolve-commit * * @flow * @format @@ -230,15 +235,177 @@ async function downloadUpstreamHermesTarball( } } +/** + * 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. + * Tracking PRs: + * - https://github.com/facebook/hermes/pull/1958 + * - https://github.com/facebook/hermes/pull/1970 + * - https://github.com/facebook/hermes/pull/1971 + * + * 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', + ); + 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. +if (require.main === module) { + const [command, ...args] = process.argv.slice(2); + + switch (command) { + case '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'); + } + }); + break; + } + case 'recompose-xcframework': { + const [tarball, destroot] = args; + if (!tarball || !destroot) { + console.error( + 'Usage: node microsoft-hermes.js recompose-xcframework ', + ); + process.exit(1); + } + const recomposed = recomposeHermesXcframework(tarball, destroot); + setActionOutput('recomposed', String(recomposed)); + break; + } + case 'resolve-commit': { + const {commit} = hermesCommitAtMergeBase(); + setActionOutput('hermes-commit', commit); + macosLog(`Resolved Hermes commit: ${commit}`); + break; + } + default: + 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 cc0bc9f8ff1b..b4efe4fdea13 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-hermes'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); From a28303ae7d6b84a6c479940e15c40e5bb965471b Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:47:52 -0700 Subject: [PATCH 11/13] fix: satisfy Flow unused-promise lint in CLI entry point Add `void` prefix to the Promise returned by downloadUpstreamHermesTarball().then() to suppress the Flow `unused-promise` error in the CLI dispatch block. Co-Authored-By: Claude Opus 4.6 --- packages/react-native/scripts/ios-prebuild/microsoft-hermes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js index 099f18bc0267..a7d0f4921036 100644 --- a/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js +++ b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js @@ -362,7 +362,7 @@ if (require.main === module) { switch (command) { case 'download-hermes': { const buildType = args[0] || 'Debug'; - downloadUpstreamHermesTarball(buildType).then(result => { + void downloadUpstreamHermesTarball(buildType).then(result => { if (result != null) { setActionOutput('tarball', result.tarballPath); setActionOutput('version', result.version); From 12d6ebedc93c63612627a0d65a337aa829ae2abe Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 18:59:21 -0700 Subject: [PATCH 12/13] =?UTF-8?q?refactor:=20align=20with=20main=20?= =?UTF-8?q?=E2=80=94=20move=20CI=20logic=20to=20.github/scripts/resolve-he?= =?UTF-8?q?rmes.mts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move downloadUpstreamHermesTarball, recomposeHermesXcframework, and CLI dispatch from microsoft-hermes.js into .github/scripts/resolve-hermes.mts (same as main branch). This makes microsoft-hermes.js a pure library on both branches, reducing divergence. Co-Authored-By: Claude Opus 4.6 --- .github/scripts/resolve-hermes.mts | 250 ++++++++++++++++++ .../workflows/microsoft-resolve-hermes.yml | 13 +- .../scripts/ios-prebuild/microsoft-hermes.js | 218 +-------------- 3 files changed, 259 insertions(+), 222 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 000000000000..8d6289573593 --- /dev/null +++ b/.github/scripts/resolve-hermes.mts @@ -0,0 +1,250 @@ +#!/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 { createRequire } from 'node:module'; +import os from 'node:os'; +import { parseArgs } from 'node:util'; +import { $, echo, fs, path } from 'zx'; + +// Use createRequire to import CommonJS modules from ESM context +const require = createRequire(import.meta.url); +const { + findMatchingHermesVersion, + findVersionAtMergeBase, + getLatestStableVersionFromNPM, + hermesCommitAtMergeBase, +} = require('../../packages/react-native/scripts/ios-prebuild/microsoft-hermes.js'); +const { + computeNightlyTarballURL, +} = require('../../packages/react-native/scripts/ios-prebuild/utils.js'); + +function setActionOutput(key: string, value: string) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `${key}=${value}\n`); + } +} + +/** + * 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. + * + * 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. + * 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, + 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 { 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; + } + 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; + } + 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/.github/workflows/microsoft-resolve-hermes.yml b/.github/workflows/microsoft-resolve-hermes.yml index 688efcae412d..78f590715ddc 100644 --- a/.github/workflows/microsoft-resolve-hermes.yml +++ b/.github/workflows/microsoft-resolve-hermes.yml @@ -1,7 +1,7 @@ # Resolve Hermes — reusable workflow called by microsoft-prebuild-macos-core.yml # # Strategy (fast path first): -# 1. Download upstream Hermes tarball from Maven +# 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 # @@ -40,12 +40,11 @@ jobs: - name: Install npm dependencies run: yarn install - # Step 1: Try to download a prebuilt Hermes tarball from upstream Maven. + # 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 scripts/ios-prebuild/microsoft-hermes.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-hermes.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-hermes.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-hermes.js b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js index a7d0f4921036..9bdeec72d8fc 100644 --- a/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js +++ b/packages/react-native/scripts/ios-prebuild/microsoft-hermes.js @@ -4,12 +4,11 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * [macOS] Hermes version resolution and xcframework recomposition for - * macOS fork branches. Used as both a library and CLI: + * [macOS] Resolves Hermes artifacts for macOS fork branches. * - * node microsoft-hermes.js download-hermes [Debug|Release] - * node microsoft-hermes.js recompose-xcframework - * node microsoft-hermes.js resolve-commit + * 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 @@ -189,223 +188,14 @@ async function getLatestStableVersionFromNPM() /*: Promise */ { return json.version; } -/** - * Downloads the upstream Hermes tarball from Maven for the mapped - * upstream version. Returns the tarball path and version on success, - * or null if no tarball is available. - * - * The caller is responsible for extracting and recomposing the - * xcframework (e.g. adding the macOS slice to the universal). - */ -async function downloadUpstreamHermesTarball( - buildType /*: string */ = 'Debug', -) /*: Promise */ { - const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); - const version = findMatchingHermesVersion(packageJsonPath); - if (version == null) { - macosLog('No upstream version found, cannot download tarball'); - return null; - } - - const mavenUrl = `https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/${version}/react-native-artifacts-${version}-hermes-ios-${buildType.toLowerCase()}.tar.gz`; - - macosLog( - `Downloading upstream Hermes tarball (${version}) from ${mavenUrl}...`, - ); - - try { - const response /*: Response */ = await fetch(mavenUrl); - if (!response.ok) { - macosLog( - `Tarball not found: ${response.status} ${response.statusText}`, - ); - return null; - } - - 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 to ${tarballPath}`); - return {tarballPath, version}; - } catch (e) { - macosLog(`Error downloading tarball: ${e.message}`); - 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. - * Tracking PRs: - * - https://github.com/facebook/hermes/pull/1958 - * - https://github.com/facebook/hermes/pull/1970 - * - https://github.com/facebook/hermes/pull/1971 - * - * 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', - ); - 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. -if (require.main === module) { - const [command, ...args] = process.argv.slice(2); - - switch (command) { - case 'download-hermes': { - const buildType = args[0] || 'Debug'; - void 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'); - } - }); - break; - } - case 'recompose-xcframework': { - const [tarball, destroot] = args; - if (!tarball || !destroot) { - console.error( - 'Usage: node microsoft-hermes.js recompose-xcframework ', - ); - process.exit(1); - } - const recomposed = recomposeHermesXcframework(tarball, destroot); - setActionOutput('recomposed', String(recomposed)); - break; - } - case 'resolve-commit': { - const {commit} = hermesCommitAtMergeBase(); - setActionOutput('hermes-commit', commit); - macosLog(`Resolved Hermes commit: ${commit}`); - break; - } - default: - 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 6db136df31fb82b0dbb5b6ef5217b54f74857ac5 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Fri, 17 Apr 2026 19:11:35 -0700 Subject: [PATCH 13/13] chore: add zx devDependency for .github/scripts/*.mts The resolve-hermes.mts CI script imports zx for shell execution and filesystem helpers. This was already present on main but missing from the 0.81 branch. Co-Authored-By: Claude Opus 4.6 --- package.json | 3 ++- yarn.lock | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index d57a41c95ab0..f262e9fe2a53 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,8 @@ "temp-dir": "^2.0.0", "tinybench": "^3.1.0", "typescript": "5.8.3", - "ws": "^6.2.3" + "ws": "^6.2.3", + "zx": "^8.2.4" }, "resolutions": { "@grpc/proto-loader": "^0.7.8", diff --git a/yarn.lock b/yarn.lock index 6df6b951b684..0fe1ab5a0f83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2963,6 +2963,7 @@ __metadata: tinybench: "npm:^3.1.0" typescript: "npm:5.8.3" ws: "npm:^6.2.3" + zx: "npm:^8.2.4" languageName: unknown linkType: soft @@ -14214,3 +14215,12 @@ __metadata: checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c languageName: node linkType: hard + +"zx@npm:^8.2.4": + version: 8.8.5 + resolution: "zx@npm:8.8.5" + bin: + zx: build/cli.js + checksum: 10c0/1273e4f72cfe35a59041aef5a56fd87318bc4e11947d101810b67e5c486ab30574042938728e8a15e085de985e762b8585fcdaab4cf87fd113153b63a5846611 + languageName: node + linkType: hard