From 387bc9953f5ce643de6b28311be512c5a6302bd4 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 24 Jul 2026 08:53:33 +0200 Subject: [PATCH 1/5] CI: add SwiftPM iOS e2e workflows (RNTester, HelloWorld, new app) Adds three standalone workflows that scaffold + convert an iOS app to SwiftPM using the prebuilt XCFrameworks and compile it: - test-ios-spm-rntester.yml (packages/rn-tester) - test-ios-spm-helloworld.yml (private/helloworld) - test-ios-spm-newapp.yml (fresh copy of helloworld, wired to a Verdaccio-published local monorepo build) None of these use @react-native-community/cli. Each workflow builds the prebuilt dependencies + core XCFrameworks via the existing reusable prebuild-ios-* workflows, primes both flavor slots, then runs the shared logic in two committed, locally-runnable scripts: - scripts/e2e/spm-prime-artifacts.js (lay CI tarballs + hermes into the --artifacts // layout) - scripts/e2e/spm-ios-e2e.js (scaffold -> spm add --deintegrate -> xcodebuild, per app) Triggered manually (workflow_dispatch) and nightly (schedule). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test-ios-spm-helloworld.yml | 111 ++++ .github/workflows/test-ios-spm-newapp.yml | 151 ++++++ .github/workflows/test-ios-spm-rntester.yml | 111 ++++ scripts/e2e/spm-ios-e2e.js | 512 ++++++++++++++++++ scripts/e2e/spm-prime-artifacts.js | 181 +++++++ 5 files changed, 1066 insertions(+) create mode 100644 .github/workflows/test-ios-spm-helloworld.yml create mode 100644 .github/workflows/test-ios-spm-newapp.yml create mode 100644 .github/workflows/test-ios-spm-rntester.yml create mode 100644 scripts/e2e/spm-ios-e2e.js create mode 100644 scripts/e2e/spm-prime-artifacts.js diff --git a/.github/workflows/test-ios-spm-helloworld.yml b/.github/workflows/test-ios-spm-helloworld.yml new file mode 100644 index 000000000000..153dab3afcae --- /dev/null +++ b/.github/workflows/test-ios-spm-helloworld.yml @@ -0,0 +1,111 @@ +name: Test iOS SwiftPM - Hello World + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + flavor: + description: 'Which build configuration(s) to test' + type: choice + options: + - both + - Debug + - Release + default: both + schedule: + # Nightly at 07:00 UTC (low-traffic hour). + - cron: '0 7 * * *' + +jobs: + prebuild_apple_dependencies: + uses: ./.github/workflows/prebuild-ios-dependencies.yml + secrets: inherit + + prebuild_react_native_core: + needs: [prebuild_apple_dependencies] + if: ${{ needs.prebuild_apple_dependencies.result == 'success' }} + uses: ./.github/workflows/prebuild-ios-core.yml + with: + use-hermes-prebuilt: ${{ !endsWith(github.ref_name, '-stable') }} + secrets: inherit + + set_flavors: + runs-on: ubuntu-latest + outputs: + flavors: ${{ steps.compute.outputs.flavors }} + steps: + - id: compute + env: + FLAVOR: ${{ github.event.inputs.flavor }} + run: | + case "$FLAVOR" in + Debug) echo 'flavors=["Debug"]' >> "$GITHUB_OUTPUT" ;; + Release) echo 'flavors=["Release"]' >> "$GITHUB_OUTPUT" ;; + *) echo 'flavors=["Debug","Release"]' >> "$GITHUB_OUTPUT" ;; + esac + + test: + needs: [prebuild_react_native_core, set_flavors] + if: ${{ needs.prebuild_react_native_core.result == 'success' }} + runs-on: macos-15-large + strategy: + fail-fast: false + matrix: + flavor: ${{ fromJSON(needs.set_flavors.outputs.flavors) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup xcode + uses: ./.github/actions/setup-xcode + - name: Setup node.js + uses: ./.github/actions/setup-node + - name: Run yarn install + uses: ./.github/actions/yarn-install + - name: Set Hermes prebuilt version + shell: bash + run: node ./scripts/releases/use-hermes-prebuilt.js + - name: Run yarn install again, with the correct hermes version + uses: ./.github/actions/yarn-install + - name: Ensure CocoaPods (needed by `spm add --deintegrate`) + shell: bash + run: pod --version || sudo gem install cocoapods --no-document + # `spm add --artifacts` validates BOTH the debug/ and release/ slots, so + # download both flavors regardless of which one this cell builds. + - name: Download ReactCore (Debug) + uses: actions/download-artifact@v7 + with: + name: ReactCoreDebug.xcframework.tar.gz + path: /tmp/rc-debug + - name: Download ReactCore (Release) + uses: actions/download-artifact@v7 + with: + name: ReactCoreRelease.xcframework.tar.gz + path: /tmp/rc-release + - name: Download ReactNativeDependencies (Debug) + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependenciesDebug.xcframework.tar.gz + path: /tmp/deps-debug + - name: Download ReactNativeDependencies (Release) + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependenciesRelease.xcframework.tar.gz + path: /tmp/deps-release + - name: Prime SPM artifacts (Debug + Release) + shell: bash + run: | + node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Debug \ + --core-tarball /tmp/rc-debug/ReactCoreDebug.xcframework.tar.gz \ + --deps-tarball /tmp/deps-debug/ReactNativeDependenciesDebug.xcframework.tar.gz + node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Release \ + --core-tarball /tmp/rc-release/ReactCoreRelease.xcframework.tar.gz \ + --deps-tarball /tmp/deps-release/ReactNativeDependenciesRelease.xcframework.tar.gz + - name: Convert to SwiftPM + build + shell: bash + run: | + node scripts/e2e/spm-ios-e2e.js \ + --app helloworld \ + --flavor "${{ matrix.flavor }}" \ + --artifacts /tmp/spm-artifacts diff --git a/.github/workflows/test-ios-spm-newapp.yml b/.github/workflows/test-ios-spm-newapp.yml new file mode 100644 index 000000000000..2f806af5b890 --- /dev/null +++ b/.github/workflows/test-ios-spm-newapp.yml @@ -0,0 +1,151 @@ +name: Test iOS SwiftPM - New App + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + flavor: + description: 'Which build configuration(s) to test' + type: choice + options: + - both + - Debug + - Release + default: both + schedule: + # Nightly at 08:00 UTC (low-traffic hour). + - cron: '0 8 * * *' + +jobs: + prebuild_apple_dependencies: + uses: ./.github/workflows/prebuild-ios-dependencies.yml + secrets: inherit + + prebuild_react_native_core: + needs: [prebuild_apple_dependencies] + if: ${{ needs.prebuild_apple_dependencies.result == 'success' }} + uses: ./.github/workflows/prebuild-ios-core.yml + with: + use-hermes-prebuilt: ${{ !endsWith(github.ref_name, '-stable') }} + secrets: inherit + + # Produces the `react-native-package` artifact (a local react-native .tgz) the + # fresh app installs. Mirrors test-all.yml's build_npm_package (dry-run upload). + build_npm_package: + runs-on: 8-core-ubuntu + container: + image: reactnativecommunity/react-native-android:latest + env: + TERM: 'dumb' + LC_ALL: C.UTF8 + GRADLE_OPTS: '-Dorg.gradle.daemon=false' + REACT_NATIVE_DOWNLOADS_DIR: /opt/react-native-downloads + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Build NPM Package + uses: ./.github/actions/build-npm-package + with: + release-type: dry-run + gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} + skip-apple-prebuilts: 'true' + + set_flavors: + runs-on: ubuntu-latest + outputs: + flavors: ${{ steps.compute.outputs.flavors }} + steps: + - id: compute + env: + FLAVOR: ${{ github.event.inputs.flavor }} + run: | + case "$FLAVOR" in + Debug) echo 'flavors=["Debug"]' >> "$GITHUB_OUTPUT" ;; + Release) echo 'flavors=["Release"]' >> "$GITHUB_OUTPUT" ;; + *) echo 'flavors=["Debug","Release"]' >> "$GITHUB_OUTPUT" ;; + esac + + test: + needs: [prebuild_react_native_core, build_npm_package, set_flavors] + if: ${{ needs.prebuild_react_native_core.result == 'success' && needs.build_npm_package.result == 'success' }} + runs-on: macos-15-large + strategy: + fail-fast: false + matrix: + flavor: ${{ fromJSON(needs.set_flavors.outputs.flavors) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup xcode + uses: ./.github/actions/setup-xcode + - name: Setup node.js + uses: ./.github/actions/setup-node + - name: Run yarn install + uses: ./.github/actions/yarn-install + - name: Set Hermes prebuilt version + shell: bash + run: node ./scripts/releases/use-hermes-prebuilt.js + - name: Run yarn install again, with the correct hermes version + uses: ./.github/actions/yarn-install + - name: Ensure CocoaPods (needed by `spm add --deintegrate`) + shell: bash + run: pod --version || sudo gem install cocoapods --no-document + - name: Configure git (Verdaccio publish + build) + shell: bash + run: | + git config --global user.email "react-native-bot@meta.com" + git config --global user.name "React Native Bot" + - name: Download React Native package + uses: actions/download-artifact@v7 + with: + name: react-native-package + path: /tmp/react-native-tmp + # `spm add --artifacts` validates BOTH the debug/ and release/ slots, so + # download both flavors regardless of which one this cell builds. + - name: Download ReactCore (Debug) + uses: actions/download-artifact@v7 + with: + name: ReactCoreDebug.xcframework.tar.gz + path: /tmp/rc-debug + - name: Download ReactCore (Release) + uses: actions/download-artifact@v7 + with: + name: ReactCoreRelease.xcframework.tar.gz + path: /tmp/rc-release + - name: Download ReactNativeDependencies (Debug) + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependenciesDebug.xcframework.tar.gz + path: /tmp/deps-debug + - name: Download ReactNativeDependencies (Release) + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependenciesRelease.xcframework.tar.gz + path: /tmp/deps-release + - name: Prime SPM artifacts (Debug + Release) + shell: bash + run: | + node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Debug \ + --core-tarball /tmp/rc-debug/ReactCoreDebug.xcframework.tar.gz \ + --deps-tarball /tmp/deps-debug/ReactNativeDependenciesDebug.xcframework.tar.gz + node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Release \ + --core-tarball /tmp/rc-release/ReactCoreRelease.xcframework.tar.gz \ + --deps-tarball /tmp/deps-release/ReactNativeDependenciesRelease.xcframework.tar.gz + - name: Convert to SwiftPM + build + shell: bash + run: | + mapfile -t RN_PKGS < <(find /tmp/react-native-tmp -type f -name "*.tgz") + if [[ ${#RN_PKGS[@]} -ne 1 ]]; then + echo "Expected exactly one react-native .tgz in /tmp/react-native-tmp, found ${#RN_PKGS[@]}:" >&2 + printf ' %s\n' "${RN_PKGS[@]}" >&2 + exit 1 + fi + REACT_NATIVE_PKG="${RN_PKGS[0]}" + echo "react-native tarball: $REACT_NATIVE_PKG" + node scripts/e2e/spm-ios-e2e.js \ + --app newapp \ + --flavor "${{ matrix.flavor }}" \ + --artifacts /tmp/spm-artifacts \ + --rn-tarball "$REACT_NATIVE_PKG" diff --git a/.github/workflows/test-ios-spm-rntester.yml b/.github/workflows/test-ios-spm-rntester.yml new file mode 100644 index 000000000000..9b97cea70c6f --- /dev/null +++ b/.github/workflows/test-ios-spm-rntester.yml @@ -0,0 +1,111 @@ +name: Test iOS SwiftPM - RN Tester + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + flavor: + description: 'Which build configuration(s) to test' + type: choice + options: + - both + - Debug + - Release + default: both + schedule: + # Nightly at 06:00 UTC (low-traffic hour). + - cron: '0 6 * * *' + +jobs: + prebuild_apple_dependencies: + uses: ./.github/workflows/prebuild-ios-dependencies.yml + secrets: inherit + + prebuild_react_native_core: + needs: [prebuild_apple_dependencies] + if: ${{ needs.prebuild_apple_dependencies.result == 'success' }} + uses: ./.github/workflows/prebuild-ios-core.yml + with: + use-hermes-prebuilt: ${{ !endsWith(github.ref_name, '-stable') }} + secrets: inherit + + set_flavors: + runs-on: ubuntu-latest + outputs: + flavors: ${{ steps.compute.outputs.flavors }} + steps: + - id: compute + env: + FLAVOR: ${{ github.event.inputs.flavor }} + run: | + case "$FLAVOR" in + Debug) echo 'flavors=["Debug"]' >> "$GITHUB_OUTPUT" ;; + Release) echo 'flavors=["Release"]' >> "$GITHUB_OUTPUT" ;; + *) echo 'flavors=["Debug","Release"]' >> "$GITHUB_OUTPUT" ;; + esac + + test: + needs: [prebuild_react_native_core, set_flavors] + if: ${{ needs.prebuild_react_native_core.result == 'success' }} + runs-on: macos-15-large + strategy: + fail-fast: false + matrix: + flavor: ${{ fromJSON(needs.set_flavors.outputs.flavors) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup xcode + uses: ./.github/actions/setup-xcode + - name: Setup node.js + uses: ./.github/actions/setup-node + - name: Run yarn install + uses: ./.github/actions/yarn-install + - name: Set Hermes prebuilt version + shell: bash + run: node ./scripts/releases/use-hermes-prebuilt.js + - name: Run yarn install again, with the correct hermes version + uses: ./.github/actions/yarn-install + - name: Ensure CocoaPods (needed by `spm add --deintegrate`) + shell: bash + run: pod --version || sudo gem install cocoapods --no-document + # `spm add --artifacts` validates BOTH the debug/ and release/ slots, so + # download both flavors regardless of which one this cell builds. + - name: Download ReactCore (Debug) + uses: actions/download-artifact@v7 + with: + name: ReactCoreDebug.xcframework.tar.gz + path: /tmp/rc-debug + - name: Download ReactCore (Release) + uses: actions/download-artifact@v7 + with: + name: ReactCoreRelease.xcframework.tar.gz + path: /tmp/rc-release + - name: Download ReactNativeDependencies (Debug) + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependenciesDebug.xcframework.tar.gz + path: /tmp/deps-debug + - name: Download ReactNativeDependencies (Release) + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependenciesRelease.xcframework.tar.gz + path: /tmp/deps-release + - name: Prime SPM artifacts (Debug + Release) + shell: bash + run: | + node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Debug \ + --core-tarball /tmp/rc-debug/ReactCoreDebug.xcframework.tar.gz \ + --deps-tarball /tmp/deps-debug/ReactNativeDependenciesDebug.xcframework.tar.gz + node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Release \ + --core-tarball /tmp/rc-release/ReactCoreRelease.xcframework.tar.gz \ + --deps-tarball /tmp/deps-release/ReactNativeDependenciesRelease.xcframework.tar.gz + - name: Convert to SwiftPM + build + shell: bash + run: | + node scripts/e2e/spm-ios-e2e.js \ + --app rntester \ + --flavor "${{ matrix.flavor }}" \ + --artifacts /tmp/spm-artifacts diff --git a/scripts/e2e/spm-ios-e2e.js b/scripts/e2e/spm-ios-e2e.js new file mode 100644 index 000000000000..8e1bfad5f110 --- /dev/null +++ b/scripts/e2e/spm-ios-e2e.js @@ -0,0 +1,512 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +/*:: import type {ProjectInfo} from '../shared/monorepoUtils'; */ + +/** + * spm-ios-e2e.js + * + * Converts a React Native iOS app to Swift Package Manager (SPM) mode using + * prebuilt XCFrameworks and compiles it — the per-app half of the SPM iOS CI + * gate. NONE of this uses @react-native-community/cli. + * + * Apps: + * rntester The in-repo packages/rn-tester app (flat layout). + * helloworld The in-repo private/helloworld app. + * newapp A fresh copy of private/helloworld in a temp dir, wired to a + * Verdaccio-published build of this monorepo (react-native from a + * local tarball via --rn-tarball, @react-native/* from the proxy). + * + * Flow (per app): + * 1. Resolve/prepare the app's ios dir. + * 2. `npx react-native spm scaffold` (tolerate non-zero) + * 3. `npx react-native spm add --deintegrate --artifacts --download skip` + * 4. Assert the .spm-injected.json marker + that the Podfile lost use_react_native!. + * 5. xcodebuild the requested configuration for the iOS simulator. For Release, + * point HERMES_CLI_PATH at the app's hermes-compiler hermesc so JS bundling + * to Hermes bytecode resolves (SPM has no hermes-engine pod to set it). + * 6. Light sanity check: the embedded React.framework flavor matches the build. + * + * `--artifacts ` must already contain complete debug/ and release/ slots + * (see spm-prime-artifacts.js). `spm add` validates BOTH regardless of the + * single configuration this run builds. + * + * Usage (from the repo root): + * node scripts/e2e/spm-ios-e2e.js --app helloworld --flavor Debug \ + * --artifacts /tmp/spm-artifacts + * node scripts/e2e/spm-ios-e2e.js --app newapp --flavor Release \ + * --artifacts /tmp/spm-artifacts --rn-tarball /tmp/rn/react-native-*.tgz + */ + +const {PRIVATE_DIR, REPO_ROOT} = require('../shared/consts'); +const {getPackages} = require('../shared/monorepoUtils'); +const { + VERDACCIO_SERVER_URL, + VERDACCIO_STORAGE_PATH, + setupVerdaccio, +} = require('./utils/verdaccio'); +const {execFileSync, execSync} = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const {parseArgs} = require('node:util'); + +const HELP = ` +Usage: node scripts/e2e/spm-ios-e2e.js [options] + +Converts a React Native iOS app to SwiftPM mode with prebuilt XCFrameworks and +compiles it. Does not use @react-native-community/cli. + +Options: + --app (required) App to convert + build. + --flavor (required) Build configuration. + --artifacts (required) Primed artifact root with + debug/ and release/ slots. + --rn-tarball (newapp only) The react-native .tgz to + install (e.g. the react-native-package + CI artifact). + --simulator [optional] Simulator name for the build + destination (default: a generic iOS + Simulator destination). + --help Show this help. +`; + +const config = { + options: { + app: {type: 'string'}, + flavor: {type: 'string'}, + artifacts: {type: 'string'}, + 'rn-tarball': {type: 'string'}, + simulator: {type: 'string'}, + help: {type: 'boolean', default: false}, + }, +}; + +/*:: +type AppMeta = { + // Directory the `spm` command runs in (holds the .xcodeproj). + iosDir: string, + // The .xcodeproj basename. + projectName: string, + // The scheme to build. + scheme: string, +}; +*/ + +function log(msg /*: string */) { + console.log(`\x1b[36m[spm-ios-e2e]\x1b[0m ${msg}`); +} +function step(msg /*: string */) { + console.log(`\n\x1b[35m==> ${msg}\x1b[0m`); +} + +function run( + cmd /*: string */, + args /*: Array */, + cwd /*: string */, + env /*:: ?: {[string]: string} */, +) /*: void */ { + console.log(`$ (cd ${cwd} && ${cmd} ${args.join(' ')})`); + execFileSync(cmd, args, { + cwd, + stdio: 'inherit', + env: env != null ? {...process.env, ...env} : process.env, + }); +} + +function resolveInRepoApp(app /*: 'rntester' | 'helloworld' */) /*: AppMeta */ { + if (app === 'rntester') { + return { + iosDir: path.join(REPO_ROOT, 'packages', 'rn-tester'), + projectName: 'RNTesterPods.xcodeproj', + scheme: 'RNTester', + }; + } + return { + iosDir: path.join(PRIVATE_DIR, 'helloworld', 'ios'), + projectName: 'HelloWorld.xcodeproj', + scheme: 'HelloWorld', + }; +} + +/** + * Copy private/helloworld into a fresh temp dir, skipping build/dependency + * output that must not (or need not) travel with the source. + */ +function copyHelloWorld(dest /*: string */) /*: void */ { + const src = path.join(PRIVATE_DIR, 'helloworld'); + const skipTop = new Set(['node_modules', 'android', 'vendor']); + fs.cpSync(src, dest, { + recursive: true, + filter: (from /*: string */) => { + const rel = path.relative(src, from); + if (rel === '') { + return true; + } + const segments = rel.split(path.sep); + if (segments.length === 1 && skipTop.has(segments[0])) { + return false; + } + // Drop gitignored iOS byproducts from any prior local build. + if ( + segments[0] === 'ios' && + (segments[1] === 'build' || segments[1] === 'Pods') + ) { + return false; + } + return true; + }, + }); +} + +/** + * Mirror init-project-e2e.js's _prepareHelloWorld against a standalone copy: + * repoint react-native at the local tarball and every in-repo @react-native/* + * dependency at the version published to the proxy (or a file: path for the + * unpublished `*` reference packages). + */ +function wireNewAppPackageJson( + appDir /*: string */, + version /*: string */, + rnTarball /*: string */, + localPackages /*: ProjectInfo */, +) /*: void */ { + const pkgPath = path.join(appDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + const updateDependencies = (deps /*: ?{[string]: string} */) => { + if (deps == null) { + return; + } + for (const key of Object.keys(deps)) { + if (!key.startsWith('@react-native/')) { + continue; + } + if (deps[key] === '*') { + const localPackage = localPackages[key]; + if (localPackage != null) { + deps[key] = `file:${path.relative(appDir, localPackage.path)}`; + } + } else { + deps[key] = version; + } + } + }; + updateDependencies(packageJson.dependencies); + updateDependencies(packageJson.devDependencies); + + packageJson.dependencies['react-native'] = `file:${rnTarball}`; + + fs.writeFileSync(pkgPath, JSON.stringify(packageJson, null, 2)); +} + +/** + * The helloworld Xcode "Bundle React Native code and images" phase reads + * .react-native.config, which ships with REACT_NATIVE_PATH / HELLOWORLD_PATH + * placeholders. For a standalone copy, substitute concrete paths so Release JS + * bundling resolves react-native from the app's own node_modules. + */ +function fixupNewAppConfig(appDir /*: string */) /*: void */ { + const cfg = path.join(appDir, '.react-native.config'); + if (!fs.existsSync(cfg)) { + return; + } + const rnPath = path.join(appDir, 'node_modules', 'react-native'); + const content = fs + .readFileSync(cfg, 'utf8') + .replace(/REACT_NATIVE_PATH/g, rnPath) + .replace(/HELLOWORLD_PATH/g, appDir); + fs.writeFileSync(cfg, content); +} + +async function prepareNewApp(rnTarball /*: ?string */) /*: Promise */ { + if (rnTarball == null) { + throw new Error('--app newapp requires --rn-tarball '); + } + const resolvedTarball = path.resolve(rnTarball); + if (!fs.existsSync(resolvedTarball)) { + throw new Error(`--rn-tarball not found: ${resolvedTarball}`); + } + + step('newapp: starting Verdaccio + publishing this monorepo'); + const verdaccioPid = setupVerdaccio(); + try { + execSync('node ./scripts/build/build.js', { + cwd: REPO_ROOT, + stdio: 'inherit', + }); + + const packages = await getPackages({ + includeReactNative: false, + includePrivate: false, + }); + // Packages are versioned in lockstep — any of them yields the version. + const version = packages[Object.keys(packages)[0]].packageJson.version; + + for (const {path: packagePath} of Object.values(packages)) { + execSync( + `npm publish --registry ${VERDACCIO_SERVER_URL} --access public --tag react-native-e2e`, + {cwd: packagePath, stdio: 'inherit'}, + ); + } + + step('newapp: copying private/helloworld to a temp dir'); + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-newapp-')); + copyHelloWorld(appDir); + + const localPackages = await getPackages({ + includeReactNative: false, + includePrivate: true, + }); + wireNewAppPackageJson(appDir, version, resolvedTarball, localPackages); + + step('newapp: npm install via the local proxy'); + execSync(`npm install --registry ${VERDACCIO_SERVER_URL}`, { + cwd: appDir, + stdio: 'inherit', + }); + + fixupNewAppConfig(appDir); + + return { + iosDir: path.join(appDir, 'ios'), + projectName: 'HelloWorld.xcodeproj', + scheme: 'HelloWorld', + }; + } finally { + try { + execSync(`kill ${verdaccioPid} || kill -9 ${verdaccioPid}`); + execSync('killall verdaccio'); + } catch { + console.warn('Failed to kill Verdaccio process'); + } + try { + execSync(`rm -rf ${VERDACCIO_STORAGE_PATH}`); + } catch {} + } +} + +/** + * Resolve the app's hermes-compiler host hermesc so react-native-xcode.sh can + * compile JS to Hermes bytecode under SwiftPM (no hermes-engine pod to set + * HERMES_CLI_PATH). Returns null when unresolved (Debug skips bundling anyway). + */ +function resolveHermesc(iosDir /*: string */) /*: ?string */ { + try { + const pkg = require.resolve('hermes-compiler/package.json', { + paths: [iosDir], + }); + const hermesc = path.join( + path.dirname(pkg), + 'hermesc', + 'osx-bin', + 'hermesc', + ); + return fs.existsSync(hermesc) ? hermesc : null; + } catch { + return null; + } +} + +/** + * Light, best-effort sanity check: the embedded React.framework flavor should + * match the build (Debug ships getDebugProps symbols, Release strips them). + * Skips quietly when the framework or `nm` is unavailable. + */ +function assertEmbeddedFlavor( + derivedData /*: string */, + flavor /*: string */, +) /*: void */ { + const productsDir = path.join( + derivedData, + 'Build', + 'Products', + `${flavor}-iphonesimulator`, + ); + if (!fs.existsSync(productsDir)) { + log('Skipping flavor check: no products dir.'); + return; + } + const app = fs.readdirSync(productsDir).find(name => name.endsWith('.app')); + if (app == null) { + log('Skipping flavor check: no .app found.'); + return; + } + const binary = path.join( + productsDir, + app, + 'Frameworks', + 'React.framework', + 'React', + ); + if (!fs.existsSync(binary)) { + log('Skipping flavor check: no embedded React.framework binary.'); + return; + } + let count = 0; + try { + const out = execFileSync('nm', [binary], {encoding: 'utf8'}); + count = (out.match(/getDebugProps/g) ?? []).length; + } catch { + log('Skipping flavor check: `nm` unavailable.'); + return; + } + const isDebug = flavor === 'Debug'; + if (isDebug && count === 0) { + throw new Error( + `Embedded React.framework has no getDebugProps symbols in a Debug build (expected >0).`, + ); + } + if (!isDebug && count !== 0) { + throw new Error( + `Embedded React.framework has getDebugProps symbols in a Release build (expected 0, got ${count}).`, + ); + } + log(`Embedded framework flavor matches ${flavor} (getDebugProps=${count}).`); +} + +async function main() /*: Promise */ { + const {values} = parseArgs(config); + + if (values.help) { + console.log(HELP); + return; + } + + const app = values.app; + if (app !== 'rntester' && app !== 'helloworld' && app !== 'newapp') { + throw new Error( + `--app must be one of rntester|helloworld|newapp, got "${String(app)}"`, + ); + } + const flavor = values.flavor; + if (flavor !== 'Debug' && flavor !== 'Release') { + throw new Error( + `--flavor must be Debug or Release, got "${String(flavor)}"`, + ); + } + if (values.artifacts == null) { + throw new Error('--artifacts is required'); + } + const artifacts = path.resolve(String(values.artifacts)); + for (const slot of ['debug', 'release']) { + if (!fs.existsSync(path.join(artifacts, slot))) { + throw new Error( + `--artifacts is missing the ${slot}/ slot: ${path.join(artifacts, slot)}`, + ); + } + } + + const meta = + app === 'newapp' + ? await prepareNewApp(values['rn-tarball']) + : resolveInRepoApp(app); + const {iosDir, projectName, scheme} = meta; + const pbxprojDir = path.join(iosDir, projectName); + + step(`Scaffold community Package.swift manifests (${app})`); + try { + run('npx', ['react-native', 'spm', 'scaffold'], iosDir); + } catch { + log('scaffold exited non-zero (tolerated).'); + } + + step(`Convert to SwiftPM: spm add --deintegrate (${app})`); + run( + 'npx', + [ + 'react-native', + 'spm', + 'add', + '--deintegrate', + '--artifacts', + artifacts, + '--download', + 'skip', + ], + iosDir, + ); + + const marker = path.join(pbxprojDir, '.spm-injected.json'); + if (!fs.existsSync(marker)) { + throw new Error(`spm add did not inject SPM (${marker} missing)`); + } + const podfile = path.join(iosDir, 'Podfile'); + if ( + fs.existsSync(podfile) && + /use_react_native!/.test(fs.readFileSync(podfile, 'utf8')) + ) { + throw new Error( + 'spm add --deintegrate did not strip use_react_native! from the Podfile', + ); + } + log('SPM injected in place; Podfile de-integrated.'); + + step(`Build ${scheme} (${flavor}, SwiftPM)`); + const derivedData = path.join(iosDir, 'build', 'spm-e2e-dd'); + fs.rmSync(derivedData, {recursive: true, force: true}); + + const destination = + values.simulator != null + ? `platform=iOS Simulator,name=${String(values.simulator)}` + : 'generic/platform=iOS Simulator'; + + const buildEnv /*: {[string]: string} */ = {}; + if (flavor === 'Release') { + const hermesc = resolveHermesc(iosDir); + if (hermesc == null) { + throw new Error( + 'Release build needs a hermes-compiler hermesc to bundle JS to Hermes ' + + 'bytecode (SwiftPM has no hermes-engine pod to set HERMES_CLI_PATH), but none was ' + + `resolved from ${iosDir}'s node_modules. Ensure hermes-compiler is installed. ` + + 'Failing early so this is not mistaken for an xcodebuild bundling error.', + ); + } + buildEnv.HERMES_CLI_PATH = hermesc; + log(`HERMES_CLI_PATH=${hermesc}`); + } + + run( + 'xcodebuild', + [ + '-project', + path.join(iosDir, projectName), + '-scheme', + scheme, + '-configuration', + flavor, + '-sdk', + 'iphonesimulator', + '-destination', + destination, + '-derivedDataPath', + derivedData, + 'build', + ], + iosDir, + Object.keys(buildEnv).length > 0 ? buildEnv : undefined, + ); + + step('Sanity check: embedded framework flavor'); + assertEmbeddedFlavor(derivedData, flavor); + + console.log(`\n\x1b[32m[spm-ios-e2e] PASS — ${app} (${flavor})\x1b[0m`); +} + +if (require.main === module) { + main().catch(err => { + console.error(`\x1b[31m[spm-ios-e2e] ${err.message}\x1b[0m`); + process.exit(1); + }); +} + +module.exports = {main}; diff --git a/scripts/e2e/spm-prime-artifacts.js b/scripts/e2e/spm-prime-artifacts.js new file mode 100644 index 000000000000..9190267f96cf --- /dev/null +++ b/scripts/e2e/spm-prime-artifacts.js @@ -0,0 +1,181 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +// download-spm-artifacts.js uses inline Flow type syntax; register the monorepo +// babel transform so plain `node` can require it from source. +require('../shared/babelRegister').registerForMonorepo(); + +/** + * spm-prime-artifacts.js + * + * Lays prebuilt React Native iOS xcframework tarballs into the two-flavor + * `--artifacts ` layout that `npx react-native spm add --artifacts + * --download skip` consumes offline: + * + * //React.xcframework + * //ReactNativeHeaders.xcframework + * //ReactNativeDependencies.xcframework + * //ReactNativeDependenciesHeaders.xcframework + * //hermes-engine.xcframework + * //hermes-headers/hermes + * //artifacts.json + * + * `` is the lower-cased flavor name (debug / release). + * + * This is a per-flavor operation (call it once per flavor into the same + * `--artifacts` root). `spm add` validates BOTH the debug/ and release/ slots + * regardless of which configuration is later built, so a full run primes both. + * + * The React core + ReactNativeHeaders xcframeworks come from the + * `ReactCore.xcframework.tar.gz` CI artifact (produced by + * prebuild-ios-core.yml), and ReactNativeDependencies + + * ReactNativeDependenciesHeaders from the + * `ReactNativeDependencies.xcframework.tar.gz` artifact (produced by + * prebuild-ios-dependencies.yml). Neither prebuild produces hermes-engine, so + * it is fetched from Maven (the `hermes-compiler` latest-v1 dist-tag by + * default; override with HERMES_VERSION). + * + * Rather than hand-rolling the tar/overlay dance, this delegates to + * download-spm-artifacts.js, whose `--core-tarball` / `--deps-tarball` + * local-tarball overrides extract exactly these companions, download + stage + * hermes (with its public headers), and write a valid artifacts.json — the + * same code path a real consumer's `spm download` runs. Passing the CI + * tarballs as-is is sufficient; the deps tarball nests its xcframeworks under + * `packages/react-native/third-party/`, which the extractor locates. + * + * Usage (from the repo root): + * node scripts/e2e/spm-prime-artifacts.js \ + * --artifacts /tmp/spm-artifacts --flavor Debug \ + * --core-tarball /tmp/rc/ReactCoreDebug.xcframework.tar.gz \ + * --deps-tarball /tmp/deps/ReactNativeDependenciesDebug.xcframework.tar.gz + */ + +const { + main: downloadArtifacts, + validateArtifactsCache, +} = require('../../packages/react-native/scripts/spm/download-spm-artifacts'); +const fs = require('node:fs'); +const path = require('node:path'); +const {parseArgs} = require('node:util'); + +const HELP = ` +Usage: node scripts/e2e/spm-prime-artifacts.js [options] + +Lays prebuilt xcframework tarballs into the --artifacts // layout +that \`npx react-native spm add --artifacts --download skip\` consumes. + +Options: + --artifacts (required) Output root. Writes //. + --flavor (required) Flavor to prime. + --core-tarball (required) ReactCore.xcframework.tar.gz + (React + ReactNativeHeaders). + --deps-tarball (required) ReactNativeDependencies.xcframework.tar.gz + (ReactNativeDependencies + …DependenciesHeaders). + --version [optional] React Native version label (for logs). + --help Show this help. + +Environment: + HERMES_VERSION Override the hermes-engine version to fetch + (default: the hermes-compiler latest-v1 dist-tag). +`; + +const config = { + options: { + artifacts: {type: 'string'}, + flavor: {type: 'string'}, + 'core-tarball': {type: 'string'}, + 'deps-tarball': {type: 'string'}, + version: {type: 'string'}, + help: {type: 'boolean', default: false}, + }, +}; + +async function main() /*: Promise */ { + const {values} = parseArgs(config); + + if (values.help) { + console.log(HELP); + return; + } + + const missing = [ + 'artifacts', + 'flavor', + 'core-tarball', + 'deps-tarball', + ].filter( + // $FlowFixMe[invalid-computed-prop] + k => values[k] == null, + ); + if (missing.length > 0) { + throw new Error( + `Missing required option(s): ${missing + .map(k => '--' + k) + .join(', ')}\n${HELP}`, + ); + } + + const flavorRaw = String(values.flavor); + const flavor = flavorRaw.toLowerCase(); + if (flavor !== 'debug' && flavor !== 'release') { + throw new Error(`--flavor must be Debug or Release, got "${flavorRaw}"`); + } + + const coreTarball = path.resolve(String(values['core-tarball'])); + const depsTarball = path.resolve(String(values['deps-tarball'])); + for (const [label, p] of [ + ['--core-tarball', coreTarball], + ['--deps-tarball', depsTarball], + ]) { + if (!fs.existsSync(p)) { + throw new Error(`${label} not found: ${p}`); + } + } + + const outputDir = path.join(path.resolve(String(values.artifacts)), flavor); + fs.mkdirSync(outputDir, {recursive: true}); + + const argv = [ + '--flavor', + flavor, + '--output', + outputDir, + '--core-tarball', + coreTarball, + '--deps-tarball', + depsTarball, + ]; + if (values.version != null) { + argv.push('--version', String(values.version)); + } + + console.log( + `[spm-prime-artifacts] Priming ${flavorRaw} slot at ${outputDir}\n` + + ` core: ${coreTarball}\n deps: ${depsTarball}`, + ); + await downloadArtifacts(argv); + + const error = validateArtifactsCache(outputDir); + if (error != null) { + throw new Error(`Primed ${flavor} slot is incomplete: ${error}`); + } + console.log(`[spm-prime-artifacts] ${flavorRaw} slot ready: ${outputDir}`); +} + +if (require.main === module) { + main().catch(err => { + console.error(`\x1b[31m[spm-prime-artifacts] ${err.message}\x1b[0m`); + process.exit(1); + }); +} + +module.exports = {main}; From da5340e460357ac5bf936eaf4c626d4a5b4da7e0 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 24 Jul 2026 09:01:22 +0200 Subject: [PATCH 2/5] CI: build only the slices a caller needs in the prebuild-ios workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `slices` input to prebuild-ios-dependencies.yml and prebuild-ios-core.yml, driving their build matrix from it. It defaults to the full platform set, so existing callers (test-all.yml, releases) are unchanged. The three SwiftPM iOS e2e workflows only ever compile for the iOS simulator, so they now request `["ios-simulator"]` from both prebuild workflows — cutting the dependencies matrix from 8 slices to 1 and the core matrix from 3 to 1 (per flavor), which is the dominant cost of those e2e runs. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/prebuild-ios-core.yml | 7 ++++++- .../workflows/prebuild-ios-dependencies.yml | 18 +++++++----------- .github/workflows/test-ios-spm-helloworld.yml | 4 ++++ .github/workflows/test-ios-spm-newapp.yml | 4 ++++ .github/workflows/test-ios-spm-rntester.yml | 4 ++++ 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index 77148d99914d..9eec57ce0a53 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -13,6 +13,11 @@ on: type: boolean required: false default: false + slices: + description: 'JSON array of Apple platform slices to build. Defaults to the full set; callers that only consume a subset (e.g. a simulator-only e2e build) can pass fewer to save time.' + type: string + required: false + default: '["ios", "ios-simulator", "mac-catalyst"]' jobs: build-rn-slice: @@ -21,7 +26,7 @@ jobs: fail-fast: false matrix: flavor: ['Debug', 'Release'] - slice: ['ios', 'ios-simulator', 'mac-catalyst'] + slice: ${{ fromJSON(inputs.slices) }} steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/prebuild-ios-dependencies.yml b/.github/workflows/prebuild-ios-dependencies.yml index b34fcf203eb7..e1e70d7d8d9a 100644 --- a/.github/workflows/prebuild-ios-dependencies.yml +++ b/.github/workflows/prebuild-ios-dependencies.yml @@ -2,6 +2,12 @@ name: Prebuild iOS Dependencies on: workflow_call: # this directive allow us to call this workflow from other workflows + inputs: + slices: + description: 'JSON array of Apple platform slices to build. Defaults to the full set; callers that only consume a subset (e.g. a simulator-only e2e build) can pass fewer to save time.' + type: string + required: false + default: '["ios", "ios-simulator", "macos", "mac-catalyst", "tvos", "tvos-simulator", "xros", "xros-simulator"]' jobs: prepare_workspace: @@ -51,17 +57,7 @@ jobs: fail-fast: false matrix: flavor: ['Debug', 'Release'] - slice: - [ - 'ios', - 'ios-simulator', - 'macos', - 'mac-catalyst', - 'tvos', - 'tvos-simulator', - 'xros', - 'xros-simulator', - ] + slice: ${{ fromJSON(inputs.slices) }} steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/test-ios-spm-helloworld.yml b/.github/workflows/test-ios-spm-helloworld.yml index 153dab3afcae..1ad505b40983 100644 --- a/.github/workflows/test-ios-spm-helloworld.yml +++ b/.github/workflows/test-ios-spm-helloworld.yml @@ -21,6 +21,9 @@ on: jobs: prebuild_apple_dependencies: uses: ./.github/workflows/prebuild-ios-dependencies.yml + with: + # e2e only compiles for the iOS simulator — no need for device/catalyst/tv/xr slices. + slices: '["ios-simulator"]' secrets: inherit prebuild_react_native_core: @@ -29,6 +32,7 @@ jobs: uses: ./.github/workflows/prebuild-ios-core.yml with: use-hermes-prebuilt: ${{ !endsWith(github.ref_name, '-stable') }} + slices: '["ios-simulator"]' secrets: inherit set_flavors: diff --git a/.github/workflows/test-ios-spm-newapp.yml b/.github/workflows/test-ios-spm-newapp.yml index 2f806af5b890..b9520d6f0fc8 100644 --- a/.github/workflows/test-ios-spm-newapp.yml +++ b/.github/workflows/test-ios-spm-newapp.yml @@ -21,6 +21,9 @@ on: jobs: prebuild_apple_dependencies: uses: ./.github/workflows/prebuild-ios-dependencies.yml + with: + # e2e only compiles for the iOS simulator — no need for device/catalyst/tv/xr slices. + slices: '["ios-simulator"]' secrets: inherit prebuild_react_native_core: @@ -29,6 +32,7 @@ jobs: uses: ./.github/workflows/prebuild-ios-core.yml with: use-hermes-prebuilt: ${{ !endsWith(github.ref_name, '-stable') }} + slices: '["ios-simulator"]' secrets: inherit # Produces the `react-native-package` artifact (a local react-native .tgz) the diff --git a/.github/workflows/test-ios-spm-rntester.yml b/.github/workflows/test-ios-spm-rntester.yml index 9b97cea70c6f..a9f3fa22f1bb 100644 --- a/.github/workflows/test-ios-spm-rntester.yml +++ b/.github/workflows/test-ios-spm-rntester.yml @@ -21,6 +21,9 @@ on: jobs: prebuild_apple_dependencies: uses: ./.github/workflows/prebuild-ios-dependencies.yml + with: + # e2e only compiles for the iOS simulator — no need for device/catalyst/tv/xr slices. + slices: '["ios-simulator"]' secrets: inherit prebuild_react_native_core: @@ -29,6 +32,7 @@ jobs: uses: ./.github/workflows/prebuild-ios-core.yml with: use-hermes-prebuilt: ${{ !endsWith(github.ref_name, '-stable') }} + slices: '["ios-simulator"]' secrets: inherit set_flavors: From 18322dac6a999d573ea4e60a4cb82b9fc24bed6c Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 24 Jul 2026 10:11:23 +0200 Subject: [PATCH 3/5] CI: fix Flow errors in the SwiftPM e2e scripts - spm-ios-e2e.js: build the execFileSync `env` from process.env's string entries only (process.env is Flow-typed to allow numbers, which the execFileSync `env` option rejects), and omit `env` entirely when there is no override so the child inherits process.env as-is. - Both scripts: suppress the parseArgs [incompatible-type] error with the standard "Natural Inference rollout" $FlowFixMe, matching set-version.js and init-project-e2e.js. Co-Authored-By: Claude Opus 4.8 --- scripts/e2e/spm-ios-e2e.js | 34 ++++++++++++++++++++++++------ scripts/e2e/spm-prime-artifacts.js | 6 +++++- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/scripts/e2e/spm-ios-e2e.js b/scripts/e2e/spm-ios-e2e.js index 8e1bfad5f110..d9081055c8f7 100644 --- a/scripts/e2e/spm-ios-e2e.js +++ b/scripts/e2e/spm-ios-e2e.js @@ -109,6 +109,19 @@ function step(msg /*: string */) { console.log(`\n\x1b[35m==> ${msg}\x1b[0m`); } +// process.env is typed to allow numbers, but execFileSync's `env` option wants +// string values only — copy across just the string entries. +function stringEnv() /*: {[string]: string} */ { + const out /*: {[string]: string} */ = {}; + for (const key of Object.keys(process.env)) { + const value = process.env[key]; + if (typeof value === 'string') { + out[key] = value; + } + } + return out; +} + function run( cmd /*: string */, args /*: Array */, @@ -116,11 +129,16 @@ function run( env /*:: ?: {[string]: string} */, ) /*: void */ { console.log(`$ (cd ${cwd} && ${cmd} ${args.join(' ')})`); - execFileSync(cmd, args, { - cwd, - stdio: 'inherit', - env: env != null ? {...process.env, ...env} : process.env, - }); + // With no override, omit `env` so execFileSync inherits process.env as-is. + if (env != null) { + execFileSync(cmd, args, { + cwd, + stdio: 'inherit', + env: {...stringEnv(), ...env}, + }); + } else { + execFileSync(cmd, args, {cwd, stdio: 'inherit'}); + } } function resolveInRepoApp(app /*: 'rntester' | 'helloworld' */) /*: AppMeta */ { @@ -375,7 +393,11 @@ function assertEmbeddedFlavor( } async function main() /*: Promise */ { - const {values} = parseArgs(config); + const { + values, + /* $FlowFixMe[incompatible-type] Natural Inference rollout. See + * https://fburl.com/workplace/6291gfvu */ + } = parseArgs(config); if (values.help) { console.log(HELP); diff --git a/scripts/e2e/spm-prime-artifacts.js b/scripts/e2e/spm-prime-artifacts.js index 9190267f96cf..3c281a739e07 100644 --- a/scripts/e2e/spm-prime-artifacts.js +++ b/scripts/e2e/spm-prime-artifacts.js @@ -100,7 +100,11 @@ const config = { }; async function main() /*: Promise */ { - const {values} = parseArgs(config); + const { + values, + /* $FlowFixMe[incompatible-type] Natural Inference rollout. See + * https://fburl.com/workplace/6291gfvu */ + } = parseArgs(config); if (values.help) { console.log(HELP); From 0df922c15a7faed51a03d5a9dbda45b8d3bc1ce9 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 24 Jul 2026 10:17:29 +0200 Subject: [PATCH 4/5] CI: fix remaining Flow error in spm-ios-e2e.js execFileSync env execFileSync's `env` option is invariantly typed {[string]: string | number | void}, so neither a plain process.env spread ({[string]: string | void}) nor a string-only object satisfies it. Build the env object into a correctly-typed indexer instead. Co-Authored-By: Claude Opus 4.8 --- scripts/e2e/spm-ios-e2e.js | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/scripts/e2e/spm-ios-e2e.js b/scripts/e2e/spm-ios-e2e.js index d9081055c8f7..86c55a534863 100644 --- a/scripts/e2e/spm-ios-e2e.js +++ b/scripts/e2e/spm-ios-e2e.js @@ -109,19 +109,6 @@ function step(msg /*: string */) { console.log(`\n\x1b[35m==> ${msg}\x1b[0m`); } -// process.env is typed to allow numbers, but execFileSync's `env` option wants -// string values only — copy across just the string entries. -function stringEnv() /*: {[string]: string} */ { - const out /*: {[string]: string} */ = {}; - for (const key of Object.keys(process.env)) { - const value = process.env[key]; - if (typeof value === 'string') { - out[key] = value; - } - } - return out; -} - function run( cmd /*: string */, args /*: Array */, @@ -129,16 +116,20 @@ function run( env /*:: ?: {[string]: string} */, ) /*: void */ { console.log(`$ (cd ${cwd} && ${cmd} ${args.join(' ')})`); - // With no override, omit `env` so execFileSync inherits process.env as-is. + // execFileSync's `env` option is invariantly typed as + // {[string]: string | number | void}. process.env is {[string]: string | void} + // and our overrides are strings, so a plain spread's narrower type is rejected; + // build the object into a correctly-typed indexer instead. + const childEnv /*: {[string]: string | number | void} */ = {}; + for (const key of Object.keys(process.env)) { + childEnv[key] = process.env[key]; + } if (env != null) { - execFileSync(cmd, args, { - cwd, - stdio: 'inherit', - env: {...stringEnv(), ...env}, - }); - } else { - execFileSync(cmd, args, {cwd, stdio: 'inherit'}); + for (const key of Object.keys(env)) { + childEnv[key] = env[key]; + } } + execFileSync(cmd, args, {cwd, stdio: 'inherit', env: childEnv}); } function resolveInRepoApp(app /*: 'rntester' | 'helloworld' */) /*: AppMeta */ { From dd19bfcf9d88698c89254e56c7321d58c01ef790 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Sat, 25 Jul 2026 13:53:20 +0200 Subject: [PATCH 5/5] CI: align the SwiftPM iOS workflows with the repo's CI conventions Addresses review feedback that the original approach did not follow the patterns already used in CI: - The three workflows no longer trigger prebuild-ios-dependencies / prebuild-ios-core themselves. They are workflow_call-only and consume the ReactCore / ReactNativeDependencies artifacts produced elsewhere in the run, the way e2e-ios-templateapp does. The `slices` input added to the two prebuild workflows is reverted. - They are invoked from test-all.yml instead of carrying their own cron. Note this means they run per-PR (gated on should_test_ios) and on pushes to main, not nightly -- test-all.yml has no schedule trigger. They start as continue-on-error so a brand-new lane cannot hard-block unrelated iOS PRs; remove that once it has proven green on main. - The new-app workflow reuses the react-native-package artifact instead of rebuilding the npm package. - Both helper scripts are deleted. Priming the artifact slots needs no new code: download-spm-artifacts.js already accepts tarball overrides via RN_CORE_TARBALL_PATH / RN_DEPS_TARBALL_PATH, so the workflows call `npx react-native spm download` directly, and the new-app case reuses scripts/e2e/init-project-e2e.js --useHelloWorld. Debug and Release run as parallel matrix cells. Each cell stages both flavor slots because `spm add` validates both (it stages both framework trees and selects one via a per-configuration build setting). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/prebuild-ios-core.yml | 7 +- .../workflows/prebuild-ios-dependencies.yml | 18 +- .github/workflows/test-all.yml | 34 ++ .github/workflows/test-ios-spm-helloworld.yml | 160 +++--- .github/workflows/test-ios-spm-newapp.yml | 207 +++---- .github/workflows/test-ios-spm-rntester.yml | 160 +++--- scripts/e2e/spm-ios-e2e.js | 525 ------------------ scripts/e2e/spm-prime-artifacts.js | 185 ------ 8 files changed, 349 insertions(+), 947 deletions(-) delete mode 100644 scripts/e2e/spm-ios-e2e.js delete mode 100644 scripts/e2e/spm-prime-artifacts.js diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index 9eec57ce0a53..77148d99914d 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -13,11 +13,6 @@ on: type: boolean required: false default: false - slices: - description: 'JSON array of Apple platform slices to build. Defaults to the full set; callers that only consume a subset (e.g. a simulator-only e2e build) can pass fewer to save time.' - type: string - required: false - default: '["ios", "ios-simulator", "mac-catalyst"]' jobs: build-rn-slice: @@ -26,7 +21,7 @@ jobs: fail-fast: false matrix: flavor: ['Debug', 'Release'] - slice: ${{ fromJSON(inputs.slices) }} + slice: ['ios', 'ios-simulator', 'mac-catalyst'] steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/prebuild-ios-dependencies.yml b/.github/workflows/prebuild-ios-dependencies.yml index e1e70d7d8d9a..b34fcf203eb7 100644 --- a/.github/workflows/prebuild-ios-dependencies.yml +++ b/.github/workflows/prebuild-ios-dependencies.yml @@ -2,12 +2,6 @@ name: Prebuild iOS Dependencies on: workflow_call: # this directive allow us to call this workflow from other workflows - inputs: - slices: - description: 'JSON array of Apple platform slices to build. Defaults to the full set; callers that only consume a subset (e.g. a simulator-only e2e build) can pass fewer to save time.' - type: string - required: false - default: '["ios", "ios-simulator", "macos", "mac-catalyst", "tvos", "tvos-simulator", "xros", "xros-simulator"]' jobs: prepare_workspace: @@ -57,7 +51,17 @@ jobs: fail-fast: false matrix: flavor: ['Debug', 'Release'] - slice: ${{ fromJSON(inputs.slices) }} + slice: + [ + 'ios', + 'ios-simulator', + 'macos', + 'mac-catalyst', + 'tvos', + 'tvos-simulator', + 'xros', + 'xros-simulator', + ] steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index b908d0f0061a..7269264395ea 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -203,6 +203,40 @@ jobs: fail-on-error: true secrets: inherit + test_ios_spm_rntester: + needs: + [ + prebuild_apple_dependencies, + prebuild_react_native_core, + check_code_changes, + ] + if: ${{ needs.prebuild_react_native_core.result == 'success' && needs.check_code_changes.outputs.should_test_ios == 'true' }} + uses: ./.github/workflows/test-ios-spm-rntester.yml + secrets: inherit + + test_ios_spm_helloworld: + needs: + [ + prebuild_apple_dependencies, + prebuild_react_native_core, + check_code_changes, + ] + if: ${{ needs.prebuild_react_native_core.result == 'success' && needs.check_code_changes.outputs.should_test_ios == 'true' }} + uses: ./.github/workflows/test-ios-spm-helloworld.yml + secrets: inherit + + test_ios_spm_newapp: + needs: + [ + build_npm_package, + prebuild_apple_dependencies, + prebuild_react_native_core, + check_code_changes, + ] + if: ${{ needs.prebuild_react_native_core.result == 'success' && needs.build_npm_package.result == 'success' && needs.check_code_changes.outputs.should_test_ios == 'true' }} + uses: ./.github/workflows/test-ios-spm-newapp.yml + secrets: inherit + test_e2e_android_templateapp: needs: [build_npm_package, build_android] if: ${{ always() && needs.build_android.result == 'success' && needs.build_npm_package.result == 'success' }} diff --git a/.github/workflows/test-ios-spm-helloworld.yml b/.github/workflows/test-ios-spm-helloworld.yml index 1ad505b40983..6dcad4a07fdb 100644 --- a/.github/workflows/test-ios-spm-helloworld.yml +++ b/.github/workflows/test-ios-spm-helloworld.yml @@ -4,60 +4,22 @@ permissions: contents: read on: - workflow_dispatch: - inputs: - flavor: - description: 'Which build configuration(s) to test' - type: choice - options: - - both - - Debug - - Release - default: both - schedule: - # Nightly at 07:00 UTC (low-traffic hour). - - cron: '0 7 * * *' + workflow_call: jobs: - prebuild_apple_dependencies: - uses: ./.github/workflows/prebuild-ios-dependencies.yml - with: - # e2e only compiles for the iOS simulator — no need for device/catalyst/tv/xr slices. - slices: '["ios-simulator"]' - secrets: inherit - - prebuild_react_native_core: - needs: [prebuild_apple_dependencies] - if: ${{ needs.prebuild_apple_dependencies.result == 'success' }} - uses: ./.github/workflows/prebuild-ios-core.yml - with: - use-hermes-prebuilt: ${{ !endsWith(github.ref_name, '-stable') }} - slices: '["ios-simulator"]' - secrets: inherit - - set_flavors: - runs-on: ubuntu-latest - outputs: - flavors: ${{ steps.compute.outputs.flavors }} - steps: - - id: compute - env: - FLAVOR: ${{ github.event.inputs.flavor }} - run: | - case "$FLAVOR" in - Debug) echo 'flavors=["Debug"]' >> "$GITHUB_OUTPUT" ;; - Release) echo 'flavors=["Release"]' >> "$GITHUB_OUTPUT" ;; - *) echo 'flavors=["Debug","Release"]' >> "$GITHUB_OUTPUT" ;; - esac - test: - needs: [prebuild_react_native_core, set_flavors] - if: ${{ needs.prebuild_react_native_core.result == 'success' }} runs-on: macos-15-large + # TODO: remove once this new lane has proven green on main. Keeps a + # brand-new SwiftPM build from hard-blocking unrelated iOS PRs. + continue-on-error: true strategy: fail-fast: false matrix: - flavor: ${{ fromJSON(needs.set_flavors.outputs.flavors) }} + flavor: [Debug, Release] + env: + APP_IOS_DIR: private/helloworld/ios + XCODE_PROJECT: HelloWorld.xcodeproj + XCODE_SCHEME: HelloWorld steps: - name: Checkout uses: actions/checkout@v6 @@ -72,11 +34,14 @@ jobs: run: node ./scripts/releases/use-hermes-prebuilt.js - name: Run yarn install again, with the correct hermes version uses: ./.github/actions/yarn-install - - name: Ensure CocoaPods (needed by `spm add --deintegrate`) + - name: Ensure CocoaPods (`spm add --deintegrate` shells out to `pod`) shell: bash run: pod --version || sudo gem install cocoapods --no-document - # `spm add --artifacts` validates BOTH the debug/ and release/ slots, so - # download both flavors regardless of which one this cell builds. + # Both flavors are needed by every matrix cell, not just the one it builds: + # `spm add` stages both flavor framework trees and lets a per-configuration + # build setting pick one at build time, so it validates the debug/ and + # release/ artifact slots together (and `--download skip` refuses an + # incomplete slot). Please don't "optimise" this down to one flavor. - name: Download ReactCore (Debug) uses: actions/download-artifact@v7 with: @@ -97,19 +62,90 @@ jobs: with: name: ReactNativeDependenciesRelease.xcframework.tar.gz path: /tmp/deps-release - - name: Prime SPM artifacts (Debug + Release) + # `spm download` primes both flavor slots per call and skips any slot that + # already validates, so prime in two passes: the first fills both slots + # with the Release bits, then debug/ is dropped and re-primed from the + # Debug tarballs while release/ validates and is left alone. hermes-engine + # is fetched from Maven by the command itself. + - name: Prime SwiftPM artifacts (Debug + Release) + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: | + RN_CORE_TARBALL_PATH=/tmp/rc-release/ReactCoreRelease.xcframework.tar.gz \ + RN_DEPS_TARBALL_PATH=/tmp/deps-release/ReactNativeDependenciesRelease.xcframework.tar.gz \ + npx react-native spm download --artifacts /tmp/spm-artifacts + rm -rf /tmp/spm-artifacts/debug + RN_CORE_TARBALL_PATH=/tmp/rc-debug/ReactCoreDebug.xcframework.tar.gz \ + RN_DEPS_TARBALL_PATH=/tmp/deps-debug/ReactNativeDependenciesDebug.xcframework.tar.gz \ + npx react-native spm download --artifacts /tmp/spm-artifacts + - name: Scaffold Package.swift manifests for community dependencies + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: npx react-native spm scaffold || true + - name: Convert the app to SwiftPM + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: npx react-native spm add --deintegrate --artifacts /tmp/spm-artifacts --download skip + - name: Assert the app is on SwiftPM + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: | + if [[ ! -f "$XCODE_PROJECT/.spm-injected.json" ]]; then + echo "::error::spm add did not inject SwiftPM: $XCODE_PROJECT/.spm-injected.json is missing" + exit 1 + fi + if [[ -f Podfile ]] && grep -q 'use_react_native!' Podfile; then + echo "::error::spm add --deintegrate left use_react_native! in the Podfile" + exit 1 + fi + echo "SwiftPM injected in place; Podfile de-integrated." + # Under SwiftPM there is no hermes-engine pod to set HERMES_CLI_PATH, so + # resolve hermesc from the app's node_modules for react-native-xcode.sh. + - name: Resolve HERMES_CLI_PATH + if: ${{ matrix.flavor == 'Release' }} + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: | + HERMESC=$(node -e "const p=require('path');console.log(p.join(p.dirname(require.resolve('hermes-compiler/package.json')),'hermesc/osx-bin/hermesc'))" 2>/dev/null || true) + if [[ -z "$HERMESC" || ! -x "$HERMESC" ]]; then + echo "::error::Could not resolve hermes-compiler's hermesc from $PWD. Release builds need it to compile JS to Hermes bytecode; failing here so it is not mistaken for an xcodebuild bundling error." + exit 1 + fi + echo "HERMES_CLI_PATH=$HERMESC" >> "$GITHUB_ENV" + echo "Using hermesc at $HERMESC" + - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) shell: bash + working-directory: ${{ env.APP_IOS_DIR }} run: | - node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Debug \ - --core-tarball /tmp/rc-debug/ReactCoreDebug.xcframework.tar.gz \ - --deps-tarball /tmp/deps-debug/ReactNativeDependenciesDebug.xcframework.tar.gz - node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Release \ - --core-tarball /tmp/rc-release/ReactCoreRelease.xcframework.tar.gz \ - --deps-tarball /tmp/deps-release/ReactNativeDependenciesRelease.xcframework.tar.gz - - name: Convert to SwiftPM + build + xcodebuild \ + -project "$XCODE_PROJECT" \ + -scheme "$XCODE_SCHEME" \ + -configuration "${{ matrix.flavor }}" \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build/spm-e2e-dd \ + build + - name: Check the embedded React.framework flavor shell: bash + working-directory: ${{ env.APP_IOS_DIR }} run: | - node scripts/e2e/spm-ios-e2e.js \ - --app helloworld \ - --flavor "${{ matrix.flavor }}" \ - --artifacts /tmp/spm-artifacts + PRODUCTS="build/spm-e2e-dd/Build/Products/${{ matrix.flavor }}-iphonesimulator" + if [[ ! -d "$PRODUCTS" ]]; then + echo "Skipping flavor check: no build products directory." + exit 0 + fi + BINARY=$(find "$PRODUCTS" -maxdepth 4 -path '*.app/Frameworks/React.framework/React' -type f 2>/dev/null | head -1 || true) + if [[ -z "$BINARY" ]] || ! command -v nm >/dev/null; then + echo "Skipping flavor check: no embedded React.framework binary or no nm." + exit 0 + fi + COUNT=$(nm "$BINARY" | grep -c getDebugProps || true) + echo "getDebugProps symbols in $BINARY: $COUNT" + if [[ "${{ matrix.flavor }}" == 'Debug' && "$COUNT" -eq 0 ]]; then + echo "::error::Debug build embeds a Release React.framework (expected getDebugProps symbols, found none)" + exit 1 + fi + if [[ "${{ matrix.flavor }}" == 'Release' && "$COUNT" -ne 0 ]]; then + echo "::error::Release build embeds a Debug React.framework ($COUNT getDebugProps symbols, expected none)" + exit 1 + fi diff --git a/.github/workflows/test-ios-spm-newapp.yml b/.github/workflows/test-ios-spm-newapp.yml index b9520d6f0fc8..960177f659c6 100644 --- a/.github/workflows/test-ios-spm-newapp.yml +++ b/.github/workflows/test-ios-spm-newapp.yml @@ -4,81 +4,22 @@ permissions: contents: read on: - workflow_dispatch: - inputs: - flavor: - description: 'Which build configuration(s) to test' - type: choice - options: - - both - - Debug - - Release - default: both - schedule: - # Nightly at 08:00 UTC (low-traffic hour). - - cron: '0 8 * * *' + workflow_call: jobs: - prebuild_apple_dependencies: - uses: ./.github/workflows/prebuild-ios-dependencies.yml - with: - # e2e only compiles for the iOS simulator — no need for device/catalyst/tv/xr slices. - slices: '["ios-simulator"]' - secrets: inherit - - prebuild_react_native_core: - needs: [prebuild_apple_dependencies] - if: ${{ needs.prebuild_apple_dependencies.result == 'success' }} - uses: ./.github/workflows/prebuild-ios-core.yml - with: - use-hermes-prebuilt: ${{ !endsWith(github.ref_name, '-stable') }} - slices: '["ios-simulator"]' - secrets: inherit - - # Produces the `react-native-package` artifact (a local react-native .tgz) the - # fresh app installs. Mirrors test-all.yml's build_npm_package (dry-run upload). - build_npm_package: - runs-on: 8-core-ubuntu - container: - image: reactnativecommunity/react-native-android:latest - env: - TERM: 'dumb' - LC_ALL: C.UTF8 - GRADLE_OPTS: '-Dorg.gradle.daemon=false' - REACT_NATIVE_DOWNLOADS_DIR: /opt/react-native-downloads - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Build NPM Package - uses: ./.github/actions/build-npm-package - with: - release-type: dry-run - gradle-cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} - skip-apple-prebuilts: 'true' - - set_flavors: - runs-on: ubuntu-latest - outputs: - flavors: ${{ steps.compute.outputs.flavors }} - steps: - - id: compute - env: - FLAVOR: ${{ github.event.inputs.flavor }} - run: | - case "$FLAVOR" in - Debug) echo 'flavors=["Debug"]' >> "$GITHUB_OUTPUT" ;; - Release) echo 'flavors=["Release"]' >> "$GITHUB_OUTPUT" ;; - *) echo 'flavors=["Debug","Release"]' >> "$GITHUB_OUTPUT" ;; - esac - test: - needs: [prebuild_react_native_core, build_npm_package, set_flavors] - if: ${{ needs.prebuild_react_native_core.result == 'success' && needs.build_npm_package.result == 'success' }} runs-on: macos-15-large + # TODO: remove once this new lane has proven green on main. Keeps a + # brand-new SwiftPM build from hard-blocking unrelated iOS PRs. + continue-on-error: true strategy: fail-fast: false matrix: - flavor: ${{ fromJSON(needs.set_flavors.outputs.flavors) }} + flavor: [Debug, Release] + env: + APP_IOS_DIR: private/helloworld/ios + XCODE_PROJECT: HelloWorld.xcodeproj + XCODE_SCHEME: HelloWorld steps: - name: Checkout uses: actions/checkout@v6 @@ -93,21 +34,14 @@ jobs: run: node ./scripts/releases/use-hermes-prebuilt.js - name: Run yarn install again, with the correct hermes version uses: ./.github/actions/yarn-install - - name: Ensure CocoaPods (needed by `spm add --deintegrate`) + - name: Ensure CocoaPods (`spm add --deintegrate` shells out to `pod`) shell: bash run: pod --version || sudo gem install cocoapods --no-document - - name: Configure git (Verdaccio publish + build) - shell: bash - run: | - git config --global user.email "react-native-bot@meta.com" - git config --global user.name "React Native Bot" - - name: Download React Native package - uses: actions/download-artifact@v7 - with: - name: react-native-package - path: /tmp/react-native-tmp - # `spm add --artifacts` validates BOTH the debug/ and release/ slots, so - # download both flavors regardless of which one this cell builds. + # Both flavors are needed by every matrix cell, not just the one it builds: + # `spm add` stages both flavor framework trees and lets a per-configuration + # build setting pick one at build time, so it validates the debug/ and + # release/ artifact slots together (and `--download skip` refuses an + # incomplete slot). Please don't "optimise" this down to one flavor. - name: Download ReactCore (Debug) uses: actions/download-artifact@v7 with: @@ -128,28 +62,101 @@ jobs: with: name: ReactNativeDependenciesRelease.xcframework.tar.gz path: /tmp/deps-release - - name: Prime SPM artifacts (Debug + Release) + - name: Download React Native package + uses: actions/download-artifact@v7 + with: + name: react-native-package + path: /tmp/react-native-tmp + # Reinstalls private/helloworld in place against the packaged react-native + # (published through the local proxy), so the app under test is what a user + # would get from npm rather than the workspace source. + - name: Prepare the HelloWorld application + shell: bash + run: node ./scripts/e2e/init-project-e2e.js --useHelloWorld --pathToLocalReactNative "/tmp/react-native-tmp/$(cat /tmp/react-native-tmp/react-native-package-version)" + # `spm download` primes both flavor slots per call and skips any slot that + # already validates, so prime in two passes: the first fills both slots + # with the Release bits, then debug/ is dropped and re-primed from the + # Debug tarballs while release/ validates and is left alone. hermes-engine + # is fetched from Maven by the command itself. + - name: Prime SwiftPM artifacts (Debug + Release) + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: | + RN_CORE_TARBALL_PATH=/tmp/rc-release/ReactCoreRelease.xcframework.tar.gz \ + RN_DEPS_TARBALL_PATH=/tmp/deps-release/ReactNativeDependenciesRelease.xcframework.tar.gz \ + npx react-native spm download --artifacts /tmp/spm-artifacts + rm -rf /tmp/spm-artifacts/debug + RN_CORE_TARBALL_PATH=/tmp/rc-debug/ReactCoreDebug.xcframework.tar.gz \ + RN_DEPS_TARBALL_PATH=/tmp/deps-debug/ReactNativeDependenciesDebug.xcframework.tar.gz \ + npx react-native spm download --artifacts /tmp/spm-artifacts + - name: Scaffold Package.swift manifests for community dependencies + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: npx react-native spm scaffold || true + - name: Convert the app to SwiftPM + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: npx react-native spm add --deintegrate --artifacts /tmp/spm-artifacts --download skip + - name: Assert the app is on SwiftPM + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: | + if [[ ! -f "$XCODE_PROJECT/.spm-injected.json" ]]; then + echo "::error::spm add did not inject SwiftPM: $XCODE_PROJECT/.spm-injected.json is missing" + exit 1 + fi + if [[ -f Podfile ]] && grep -q 'use_react_native!' Podfile; then + echo "::error::spm add --deintegrate left use_react_native! in the Podfile" + exit 1 + fi + echo "SwiftPM injected in place; Podfile de-integrated." + # Under SwiftPM there is no hermes-engine pod to set HERMES_CLI_PATH, so + # resolve hermesc from the app's node_modules for react-native-xcode.sh. + - name: Resolve HERMES_CLI_PATH + if: ${{ matrix.flavor == 'Release' }} + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: | + HERMESC=$(node -e "const p=require('path');console.log(p.join(p.dirname(require.resolve('hermes-compiler/package.json')),'hermesc/osx-bin/hermesc'))" 2>/dev/null || true) + if [[ -z "$HERMESC" || ! -x "$HERMESC" ]]; then + echo "::error::Could not resolve hermes-compiler's hermesc from $PWD. Release builds need it to compile JS to Hermes bytecode; failing here so it is not mistaken for an xcodebuild bundling error." + exit 1 + fi + echo "HERMES_CLI_PATH=$HERMESC" >> "$GITHUB_ENV" + echo "Using hermesc at $HERMESC" + - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) shell: bash + working-directory: ${{ env.APP_IOS_DIR }} run: | - node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Debug \ - --core-tarball /tmp/rc-debug/ReactCoreDebug.xcframework.tar.gz \ - --deps-tarball /tmp/deps-debug/ReactNativeDependenciesDebug.xcframework.tar.gz - node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Release \ - --core-tarball /tmp/rc-release/ReactCoreRelease.xcframework.tar.gz \ - --deps-tarball /tmp/deps-release/ReactNativeDependenciesRelease.xcframework.tar.gz - - name: Convert to SwiftPM + build + xcodebuild \ + -project "$XCODE_PROJECT" \ + -scheme "$XCODE_SCHEME" \ + -configuration "${{ matrix.flavor }}" \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build/spm-e2e-dd \ + build + - name: Check the embedded React.framework flavor shell: bash + working-directory: ${{ env.APP_IOS_DIR }} run: | - mapfile -t RN_PKGS < <(find /tmp/react-native-tmp -type f -name "*.tgz") - if [[ ${#RN_PKGS[@]} -ne 1 ]]; then - echo "Expected exactly one react-native .tgz in /tmp/react-native-tmp, found ${#RN_PKGS[@]}:" >&2 - printf ' %s\n' "${RN_PKGS[@]}" >&2 + PRODUCTS="build/spm-e2e-dd/Build/Products/${{ matrix.flavor }}-iphonesimulator" + if [[ ! -d "$PRODUCTS" ]]; then + echo "Skipping flavor check: no build products directory." + exit 0 + fi + BINARY=$(find "$PRODUCTS" -maxdepth 4 -path '*.app/Frameworks/React.framework/React' -type f 2>/dev/null | head -1 || true) + if [[ -z "$BINARY" ]] || ! command -v nm >/dev/null; then + echo "Skipping flavor check: no embedded React.framework binary or no nm." + exit 0 + fi + COUNT=$(nm "$BINARY" | grep -c getDebugProps || true) + echo "getDebugProps symbols in $BINARY: $COUNT" + if [[ "${{ matrix.flavor }}" == 'Debug' && "$COUNT" -eq 0 ]]; then + echo "::error::Debug build embeds a Release React.framework (expected getDebugProps symbols, found none)" + exit 1 + fi + if [[ "${{ matrix.flavor }}" == 'Release' && "$COUNT" -ne 0 ]]; then + echo "::error::Release build embeds a Debug React.framework ($COUNT getDebugProps symbols, expected none)" exit 1 fi - REACT_NATIVE_PKG="${RN_PKGS[0]}" - echo "react-native tarball: $REACT_NATIVE_PKG" - node scripts/e2e/spm-ios-e2e.js \ - --app newapp \ - --flavor "${{ matrix.flavor }}" \ - --artifacts /tmp/spm-artifacts \ - --rn-tarball "$REACT_NATIVE_PKG" diff --git a/.github/workflows/test-ios-spm-rntester.yml b/.github/workflows/test-ios-spm-rntester.yml index a9f3fa22f1bb..7d56883de7af 100644 --- a/.github/workflows/test-ios-spm-rntester.yml +++ b/.github/workflows/test-ios-spm-rntester.yml @@ -4,60 +4,22 @@ permissions: contents: read on: - workflow_dispatch: - inputs: - flavor: - description: 'Which build configuration(s) to test' - type: choice - options: - - both - - Debug - - Release - default: both - schedule: - # Nightly at 06:00 UTC (low-traffic hour). - - cron: '0 6 * * *' + workflow_call: jobs: - prebuild_apple_dependencies: - uses: ./.github/workflows/prebuild-ios-dependencies.yml - with: - # e2e only compiles for the iOS simulator — no need for device/catalyst/tv/xr slices. - slices: '["ios-simulator"]' - secrets: inherit - - prebuild_react_native_core: - needs: [prebuild_apple_dependencies] - if: ${{ needs.prebuild_apple_dependencies.result == 'success' }} - uses: ./.github/workflows/prebuild-ios-core.yml - with: - use-hermes-prebuilt: ${{ !endsWith(github.ref_name, '-stable') }} - slices: '["ios-simulator"]' - secrets: inherit - - set_flavors: - runs-on: ubuntu-latest - outputs: - flavors: ${{ steps.compute.outputs.flavors }} - steps: - - id: compute - env: - FLAVOR: ${{ github.event.inputs.flavor }} - run: | - case "$FLAVOR" in - Debug) echo 'flavors=["Debug"]' >> "$GITHUB_OUTPUT" ;; - Release) echo 'flavors=["Release"]' >> "$GITHUB_OUTPUT" ;; - *) echo 'flavors=["Debug","Release"]' >> "$GITHUB_OUTPUT" ;; - esac - test: - needs: [prebuild_react_native_core, set_flavors] - if: ${{ needs.prebuild_react_native_core.result == 'success' }} runs-on: macos-15-large + # TODO: remove once this new lane has proven green on main. Keeps a + # brand-new SwiftPM build from hard-blocking unrelated iOS PRs. + continue-on-error: true strategy: fail-fast: false matrix: - flavor: ${{ fromJSON(needs.set_flavors.outputs.flavors) }} + flavor: [Debug, Release] + env: + APP_IOS_DIR: packages/rn-tester + XCODE_PROJECT: RNTesterPods.xcodeproj + XCODE_SCHEME: RNTester steps: - name: Checkout uses: actions/checkout@v6 @@ -72,11 +34,14 @@ jobs: run: node ./scripts/releases/use-hermes-prebuilt.js - name: Run yarn install again, with the correct hermes version uses: ./.github/actions/yarn-install - - name: Ensure CocoaPods (needed by `spm add --deintegrate`) + - name: Ensure CocoaPods (`spm add --deintegrate` shells out to `pod`) shell: bash run: pod --version || sudo gem install cocoapods --no-document - # `spm add --artifacts` validates BOTH the debug/ and release/ slots, so - # download both flavors regardless of which one this cell builds. + # Both flavors are needed by every matrix cell, not just the one it builds: + # `spm add` stages both flavor framework trees and lets a per-configuration + # build setting pick one at build time, so it validates the debug/ and + # release/ artifact slots together (and `--download skip` refuses an + # incomplete slot). Please don't "optimise" this down to one flavor. - name: Download ReactCore (Debug) uses: actions/download-artifact@v7 with: @@ -97,19 +62,90 @@ jobs: with: name: ReactNativeDependenciesRelease.xcframework.tar.gz path: /tmp/deps-release - - name: Prime SPM artifacts (Debug + Release) + # `spm download` primes both flavor slots per call and skips any slot that + # already validates, so prime in two passes: the first fills both slots + # with the Release bits, then debug/ is dropped and re-primed from the + # Debug tarballs while release/ validates and is left alone. hermes-engine + # is fetched from Maven by the command itself. + - name: Prime SwiftPM artifacts (Debug + Release) + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: | + RN_CORE_TARBALL_PATH=/tmp/rc-release/ReactCoreRelease.xcframework.tar.gz \ + RN_DEPS_TARBALL_PATH=/tmp/deps-release/ReactNativeDependenciesRelease.xcframework.tar.gz \ + npx react-native spm download --artifacts /tmp/spm-artifacts + rm -rf /tmp/spm-artifacts/debug + RN_CORE_TARBALL_PATH=/tmp/rc-debug/ReactCoreDebug.xcframework.tar.gz \ + RN_DEPS_TARBALL_PATH=/tmp/deps-debug/ReactNativeDependenciesDebug.xcframework.tar.gz \ + npx react-native spm download --artifacts /tmp/spm-artifacts + - name: Scaffold Package.swift manifests for community dependencies + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: npx react-native spm scaffold || true + - name: Convert the app to SwiftPM + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: npx react-native spm add --deintegrate --artifacts /tmp/spm-artifacts --download skip + - name: Assert the app is on SwiftPM + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: | + if [[ ! -f "$XCODE_PROJECT/.spm-injected.json" ]]; then + echo "::error::spm add did not inject SwiftPM: $XCODE_PROJECT/.spm-injected.json is missing" + exit 1 + fi + if [[ -f Podfile ]] && grep -q 'use_react_native!' Podfile; then + echo "::error::spm add --deintegrate left use_react_native! in the Podfile" + exit 1 + fi + echo "SwiftPM injected in place; Podfile de-integrated." + # Under SwiftPM there is no hermes-engine pod to set HERMES_CLI_PATH, so + # resolve hermesc from the app's node_modules for react-native-xcode.sh. + - name: Resolve HERMES_CLI_PATH + if: ${{ matrix.flavor == 'Release' }} + shell: bash + working-directory: ${{ env.APP_IOS_DIR }} + run: | + HERMESC=$(node -e "const p=require('path');console.log(p.join(p.dirname(require.resolve('hermes-compiler/package.json')),'hermesc/osx-bin/hermesc'))" 2>/dev/null || true) + if [[ -z "$HERMESC" || ! -x "$HERMESC" ]]; then + echo "::error::Could not resolve hermes-compiler's hermesc from $PWD. Release builds need it to compile JS to Hermes bytecode; failing here so it is not mistaken for an xcodebuild bundling error." + exit 1 + fi + echo "HERMES_CLI_PATH=$HERMESC" >> "$GITHUB_ENV" + echo "Using hermesc at $HERMESC" + - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) shell: bash + working-directory: ${{ env.APP_IOS_DIR }} run: | - node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Debug \ - --core-tarball /tmp/rc-debug/ReactCoreDebug.xcframework.tar.gz \ - --deps-tarball /tmp/deps-debug/ReactNativeDependenciesDebug.xcframework.tar.gz - node scripts/e2e/spm-prime-artifacts.js --artifacts /tmp/spm-artifacts --flavor Release \ - --core-tarball /tmp/rc-release/ReactCoreRelease.xcframework.tar.gz \ - --deps-tarball /tmp/deps-release/ReactNativeDependenciesRelease.xcframework.tar.gz - - name: Convert to SwiftPM + build + xcodebuild \ + -project "$XCODE_PROJECT" \ + -scheme "$XCODE_SCHEME" \ + -configuration "${{ matrix.flavor }}" \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build/spm-e2e-dd \ + build + - name: Check the embedded React.framework flavor shell: bash + working-directory: ${{ env.APP_IOS_DIR }} run: | - node scripts/e2e/spm-ios-e2e.js \ - --app rntester \ - --flavor "${{ matrix.flavor }}" \ - --artifacts /tmp/spm-artifacts + PRODUCTS="build/spm-e2e-dd/Build/Products/${{ matrix.flavor }}-iphonesimulator" + if [[ ! -d "$PRODUCTS" ]]; then + echo "Skipping flavor check: no build products directory." + exit 0 + fi + BINARY=$(find "$PRODUCTS" -maxdepth 4 -path '*.app/Frameworks/React.framework/React' -type f 2>/dev/null | head -1 || true) + if [[ -z "$BINARY" ]] || ! command -v nm >/dev/null; then + echo "Skipping flavor check: no embedded React.framework binary or no nm." + exit 0 + fi + COUNT=$(nm "$BINARY" | grep -c getDebugProps || true) + echo "getDebugProps symbols in $BINARY: $COUNT" + if [[ "${{ matrix.flavor }}" == 'Debug' && "$COUNT" -eq 0 ]]; then + echo "::error::Debug build embeds a Release React.framework (expected getDebugProps symbols, found none)" + exit 1 + fi + if [[ "${{ matrix.flavor }}" == 'Release' && "$COUNT" -ne 0 ]]; then + echo "::error::Release build embeds a Debug React.framework ($COUNT getDebugProps symbols, expected none)" + exit 1 + fi diff --git a/scripts/e2e/spm-ios-e2e.js b/scripts/e2e/spm-ios-e2e.js deleted file mode 100644 index 86c55a534863..000000000000 --- a/scripts/e2e/spm-ios-e2e.js +++ /dev/null @@ -1,525 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - * @format - */ - -'use strict'; - -/*:: import type {ProjectInfo} from '../shared/monorepoUtils'; */ - -/** - * spm-ios-e2e.js - * - * Converts a React Native iOS app to Swift Package Manager (SPM) mode using - * prebuilt XCFrameworks and compiles it — the per-app half of the SPM iOS CI - * gate. NONE of this uses @react-native-community/cli. - * - * Apps: - * rntester The in-repo packages/rn-tester app (flat layout). - * helloworld The in-repo private/helloworld app. - * newapp A fresh copy of private/helloworld in a temp dir, wired to a - * Verdaccio-published build of this monorepo (react-native from a - * local tarball via --rn-tarball, @react-native/* from the proxy). - * - * Flow (per app): - * 1. Resolve/prepare the app's ios dir. - * 2. `npx react-native spm scaffold` (tolerate non-zero) - * 3. `npx react-native spm add --deintegrate --artifacts --download skip` - * 4. Assert the .spm-injected.json marker + that the Podfile lost use_react_native!. - * 5. xcodebuild the requested configuration for the iOS simulator. For Release, - * point HERMES_CLI_PATH at the app's hermes-compiler hermesc so JS bundling - * to Hermes bytecode resolves (SPM has no hermes-engine pod to set it). - * 6. Light sanity check: the embedded React.framework flavor matches the build. - * - * `--artifacts ` must already contain complete debug/ and release/ slots - * (see spm-prime-artifacts.js). `spm add` validates BOTH regardless of the - * single configuration this run builds. - * - * Usage (from the repo root): - * node scripts/e2e/spm-ios-e2e.js --app helloworld --flavor Debug \ - * --artifacts /tmp/spm-artifacts - * node scripts/e2e/spm-ios-e2e.js --app newapp --flavor Release \ - * --artifacts /tmp/spm-artifacts --rn-tarball /tmp/rn/react-native-*.tgz - */ - -const {PRIVATE_DIR, REPO_ROOT} = require('../shared/consts'); -const {getPackages} = require('../shared/monorepoUtils'); -const { - VERDACCIO_SERVER_URL, - VERDACCIO_STORAGE_PATH, - setupVerdaccio, -} = require('./utils/verdaccio'); -const {execFileSync, execSync} = require('node:child_process'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const {parseArgs} = require('node:util'); - -const HELP = ` -Usage: node scripts/e2e/spm-ios-e2e.js [options] - -Converts a React Native iOS app to SwiftPM mode with prebuilt XCFrameworks and -compiles it. Does not use @react-native-community/cli. - -Options: - --app (required) App to convert + build. - --flavor (required) Build configuration. - --artifacts (required) Primed artifact root with - debug/ and release/ slots. - --rn-tarball (newapp only) The react-native .tgz to - install (e.g. the react-native-package - CI artifact). - --simulator [optional] Simulator name for the build - destination (default: a generic iOS - Simulator destination). - --help Show this help. -`; - -const config = { - options: { - app: {type: 'string'}, - flavor: {type: 'string'}, - artifacts: {type: 'string'}, - 'rn-tarball': {type: 'string'}, - simulator: {type: 'string'}, - help: {type: 'boolean', default: false}, - }, -}; - -/*:: -type AppMeta = { - // Directory the `spm` command runs in (holds the .xcodeproj). - iosDir: string, - // The .xcodeproj basename. - projectName: string, - // The scheme to build. - scheme: string, -}; -*/ - -function log(msg /*: string */) { - console.log(`\x1b[36m[spm-ios-e2e]\x1b[0m ${msg}`); -} -function step(msg /*: string */) { - console.log(`\n\x1b[35m==> ${msg}\x1b[0m`); -} - -function run( - cmd /*: string */, - args /*: Array */, - cwd /*: string */, - env /*:: ?: {[string]: string} */, -) /*: void */ { - console.log(`$ (cd ${cwd} && ${cmd} ${args.join(' ')})`); - // execFileSync's `env` option is invariantly typed as - // {[string]: string | number | void}. process.env is {[string]: string | void} - // and our overrides are strings, so a plain spread's narrower type is rejected; - // build the object into a correctly-typed indexer instead. - const childEnv /*: {[string]: string | number | void} */ = {}; - for (const key of Object.keys(process.env)) { - childEnv[key] = process.env[key]; - } - if (env != null) { - for (const key of Object.keys(env)) { - childEnv[key] = env[key]; - } - } - execFileSync(cmd, args, {cwd, stdio: 'inherit', env: childEnv}); -} - -function resolveInRepoApp(app /*: 'rntester' | 'helloworld' */) /*: AppMeta */ { - if (app === 'rntester') { - return { - iosDir: path.join(REPO_ROOT, 'packages', 'rn-tester'), - projectName: 'RNTesterPods.xcodeproj', - scheme: 'RNTester', - }; - } - return { - iosDir: path.join(PRIVATE_DIR, 'helloworld', 'ios'), - projectName: 'HelloWorld.xcodeproj', - scheme: 'HelloWorld', - }; -} - -/** - * Copy private/helloworld into a fresh temp dir, skipping build/dependency - * output that must not (or need not) travel with the source. - */ -function copyHelloWorld(dest /*: string */) /*: void */ { - const src = path.join(PRIVATE_DIR, 'helloworld'); - const skipTop = new Set(['node_modules', 'android', 'vendor']); - fs.cpSync(src, dest, { - recursive: true, - filter: (from /*: string */) => { - const rel = path.relative(src, from); - if (rel === '') { - return true; - } - const segments = rel.split(path.sep); - if (segments.length === 1 && skipTop.has(segments[0])) { - return false; - } - // Drop gitignored iOS byproducts from any prior local build. - if ( - segments[0] === 'ios' && - (segments[1] === 'build' || segments[1] === 'Pods') - ) { - return false; - } - return true; - }, - }); -} - -/** - * Mirror init-project-e2e.js's _prepareHelloWorld against a standalone copy: - * repoint react-native at the local tarball and every in-repo @react-native/* - * dependency at the version published to the proxy (or a file: path for the - * unpublished `*` reference packages). - */ -function wireNewAppPackageJson( - appDir /*: string */, - version /*: string */, - rnTarball /*: string */, - localPackages /*: ProjectInfo */, -) /*: void */ { - const pkgPath = path.join(appDir, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - - const updateDependencies = (deps /*: ?{[string]: string} */) => { - if (deps == null) { - return; - } - for (const key of Object.keys(deps)) { - if (!key.startsWith('@react-native/')) { - continue; - } - if (deps[key] === '*') { - const localPackage = localPackages[key]; - if (localPackage != null) { - deps[key] = `file:${path.relative(appDir, localPackage.path)}`; - } - } else { - deps[key] = version; - } - } - }; - updateDependencies(packageJson.dependencies); - updateDependencies(packageJson.devDependencies); - - packageJson.dependencies['react-native'] = `file:${rnTarball}`; - - fs.writeFileSync(pkgPath, JSON.stringify(packageJson, null, 2)); -} - -/** - * The helloworld Xcode "Bundle React Native code and images" phase reads - * .react-native.config, which ships with REACT_NATIVE_PATH / HELLOWORLD_PATH - * placeholders. For a standalone copy, substitute concrete paths so Release JS - * bundling resolves react-native from the app's own node_modules. - */ -function fixupNewAppConfig(appDir /*: string */) /*: void */ { - const cfg = path.join(appDir, '.react-native.config'); - if (!fs.existsSync(cfg)) { - return; - } - const rnPath = path.join(appDir, 'node_modules', 'react-native'); - const content = fs - .readFileSync(cfg, 'utf8') - .replace(/REACT_NATIVE_PATH/g, rnPath) - .replace(/HELLOWORLD_PATH/g, appDir); - fs.writeFileSync(cfg, content); -} - -async function prepareNewApp(rnTarball /*: ?string */) /*: Promise */ { - if (rnTarball == null) { - throw new Error('--app newapp requires --rn-tarball '); - } - const resolvedTarball = path.resolve(rnTarball); - if (!fs.existsSync(resolvedTarball)) { - throw new Error(`--rn-tarball not found: ${resolvedTarball}`); - } - - step('newapp: starting Verdaccio + publishing this monorepo'); - const verdaccioPid = setupVerdaccio(); - try { - execSync('node ./scripts/build/build.js', { - cwd: REPO_ROOT, - stdio: 'inherit', - }); - - const packages = await getPackages({ - includeReactNative: false, - includePrivate: false, - }); - // Packages are versioned in lockstep — any of them yields the version. - const version = packages[Object.keys(packages)[0]].packageJson.version; - - for (const {path: packagePath} of Object.values(packages)) { - execSync( - `npm publish --registry ${VERDACCIO_SERVER_URL} --access public --tag react-native-e2e`, - {cwd: packagePath, stdio: 'inherit'}, - ); - } - - step('newapp: copying private/helloworld to a temp dir'); - const appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-newapp-')); - copyHelloWorld(appDir); - - const localPackages = await getPackages({ - includeReactNative: false, - includePrivate: true, - }); - wireNewAppPackageJson(appDir, version, resolvedTarball, localPackages); - - step('newapp: npm install via the local proxy'); - execSync(`npm install --registry ${VERDACCIO_SERVER_URL}`, { - cwd: appDir, - stdio: 'inherit', - }); - - fixupNewAppConfig(appDir); - - return { - iosDir: path.join(appDir, 'ios'), - projectName: 'HelloWorld.xcodeproj', - scheme: 'HelloWorld', - }; - } finally { - try { - execSync(`kill ${verdaccioPid} || kill -9 ${verdaccioPid}`); - execSync('killall verdaccio'); - } catch { - console.warn('Failed to kill Verdaccio process'); - } - try { - execSync(`rm -rf ${VERDACCIO_STORAGE_PATH}`); - } catch {} - } -} - -/** - * Resolve the app's hermes-compiler host hermesc so react-native-xcode.sh can - * compile JS to Hermes bytecode under SwiftPM (no hermes-engine pod to set - * HERMES_CLI_PATH). Returns null when unresolved (Debug skips bundling anyway). - */ -function resolveHermesc(iosDir /*: string */) /*: ?string */ { - try { - const pkg = require.resolve('hermes-compiler/package.json', { - paths: [iosDir], - }); - const hermesc = path.join( - path.dirname(pkg), - 'hermesc', - 'osx-bin', - 'hermesc', - ); - return fs.existsSync(hermesc) ? hermesc : null; - } catch { - return null; - } -} - -/** - * Light, best-effort sanity check: the embedded React.framework flavor should - * match the build (Debug ships getDebugProps symbols, Release strips them). - * Skips quietly when the framework or `nm` is unavailable. - */ -function assertEmbeddedFlavor( - derivedData /*: string */, - flavor /*: string */, -) /*: void */ { - const productsDir = path.join( - derivedData, - 'Build', - 'Products', - `${flavor}-iphonesimulator`, - ); - if (!fs.existsSync(productsDir)) { - log('Skipping flavor check: no products dir.'); - return; - } - const app = fs.readdirSync(productsDir).find(name => name.endsWith('.app')); - if (app == null) { - log('Skipping flavor check: no .app found.'); - return; - } - const binary = path.join( - productsDir, - app, - 'Frameworks', - 'React.framework', - 'React', - ); - if (!fs.existsSync(binary)) { - log('Skipping flavor check: no embedded React.framework binary.'); - return; - } - let count = 0; - try { - const out = execFileSync('nm', [binary], {encoding: 'utf8'}); - count = (out.match(/getDebugProps/g) ?? []).length; - } catch { - log('Skipping flavor check: `nm` unavailable.'); - return; - } - const isDebug = flavor === 'Debug'; - if (isDebug && count === 0) { - throw new Error( - `Embedded React.framework has no getDebugProps symbols in a Debug build (expected >0).`, - ); - } - if (!isDebug && count !== 0) { - throw new Error( - `Embedded React.framework has getDebugProps symbols in a Release build (expected 0, got ${count}).`, - ); - } - log(`Embedded framework flavor matches ${flavor} (getDebugProps=${count}).`); -} - -async function main() /*: Promise */ { - const { - values, - /* $FlowFixMe[incompatible-type] Natural Inference rollout. See - * https://fburl.com/workplace/6291gfvu */ - } = parseArgs(config); - - if (values.help) { - console.log(HELP); - return; - } - - const app = values.app; - if (app !== 'rntester' && app !== 'helloworld' && app !== 'newapp') { - throw new Error( - `--app must be one of rntester|helloworld|newapp, got "${String(app)}"`, - ); - } - const flavor = values.flavor; - if (flavor !== 'Debug' && flavor !== 'Release') { - throw new Error( - `--flavor must be Debug or Release, got "${String(flavor)}"`, - ); - } - if (values.artifacts == null) { - throw new Error('--artifacts is required'); - } - const artifacts = path.resolve(String(values.artifacts)); - for (const slot of ['debug', 'release']) { - if (!fs.existsSync(path.join(artifacts, slot))) { - throw new Error( - `--artifacts is missing the ${slot}/ slot: ${path.join(artifacts, slot)}`, - ); - } - } - - const meta = - app === 'newapp' - ? await prepareNewApp(values['rn-tarball']) - : resolveInRepoApp(app); - const {iosDir, projectName, scheme} = meta; - const pbxprojDir = path.join(iosDir, projectName); - - step(`Scaffold community Package.swift manifests (${app})`); - try { - run('npx', ['react-native', 'spm', 'scaffold'], iosDir); - } catch { - log('scaffold exited non-zero (tolerated).'); - } - - step(`Convert to SwiftPM: spm add --deintegrate (${app})`); - run( - 'npx', - [ - 'react-native', - 'spm', - 'add', - '--deintegrate', - '--artifacts', - artifacts, - '--download', - 'skip', - ], - iosDir, - ); - - const marker = path.join(pbxprojDir, '.spm-injected.json'); - if (!fs.existsSync(marker)) { - throw new Error(`spm add did not inject SPM (${marker} missing)`); - } - const podfile = path.join(iosDir, 'Podfile'); - if ( - fs.existsSync(podfile) && - /use_react_native!/.test(fs.readFileSync(podfile, 'utf8')) - ) { - throw new Error( - 'spm add --deintegrate did not strip use_react_native! from the Podfile', - ); - } - log('SPM injected in place; Podfile de-integrated.'); - - step(`Build ${scheme} (${flavor}, SwiftPM)`); - const derivedData = path.join(iosDir, 'build', 'spm-e2e-dd'); - fs.rmSync(derivedData, {recursive: true, force: true}); - - const destination = - values.simulator != null - ? `platform=iOS Simulator,name=${String(values.simulator)}` - : 'generic/platform=iOS Simulator'; - - const buildEnv /*: {[string]: string} */ = {}; - if (flavor === 'Release') { - const hermesc = resolveHermesc(iosDir); - if (hermesc == null) { - throw new Error( - 'Release build needs a hermes-compiler hermesc to bundle JS to Hermes ' + - 'bytecode (SwiftPM has no hermes-engine pod to set HERMES_CLI_PATH), but none was ' + - `resolved from ${iosDir}'s node_modules. Ensure hermes-compiler is installed. ` + - 'Failing early so this is not mistaken for an xcodebuild bundling error.', - ); - } - buildEnv.HERMES_CLI_PATH = hermesc; - log(`HERMES_CLI_PATH=${hermesc}`); - } - - run( - 'xcodebuild', - [ - '-project', - path.join(iosDir, projectName), - '-scheme', - scheme, - '-configuration', - flavor, - '-sdk', - 'iphonesimulator', - '-destination', - destination, - '-derivedDataPath', - derivedData, - 'build', - ], - iosDir, - Object.keys(buildEnv).length > 0 ? buildEnv : undefined, - ); - - step('Sanity check: embedded framework flavor'); - assertEmbeddedFlavor(derivedData, flavor); - - console.log(`\n\x1b[32m[spm-ios-e2e] PASS — ${app} (${flavor})\x1b[0m`); -} - -if (require.main === module) { - main().catch(err => { - console.error(`\x1b[31m[spm-ios-e2e] ${err.message}\x1b[0m`); - process.exit(1); - }); -} - -module.exports = {main}; diff --git a/scripts/e2e/spm-prime-artifacts.js b/scripts/e2e/spm-prime-artifacts.js deleted file mode 100644 index 3c281a739e07..000000000000 --- a/scripts/e2e/spm-prime-artifacts.js +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - * @format - */ - -'use strict'; - -// download-spm-artifacts.js uses inline Flow type syntax; register the monorepo -// babel transform so plain `node` can require it from source. -require('../shared/babelRegister').registerForMonorepo(); - -/** - * spm-prime-artifacts.js - * - * Lays prebuilt React Native iOS xcframework tarballs into the two-flavor - * `--artifacts ` layout that `npx react-native spm add --artifacts - * --download skip` consumes offline: - * - * //React.xcframework - * //ReactNativeHeaders.xcframework - * //ReactNativeDependencies.xcframework - * //ReactNativeDependenciesHeaders.xcframework - * //hermes-engine.xcframework - * //hermes-headers/hermes - * //artifacts.json - * - * `` is the lower-cased flavor name (debug / release). - * - * This is a per-flavor operation (call it once per flavor into the same - * `--artifacts` root). `spm add` validates BOTH the debug/ and release/ slots - * regardless of which configuration is later built, so a full run primes both. - * - * The React core + ReactNativeHeaders xcframeworks come from the - * `ReactCore.xcframework.tar.gz` CI artifact (produced by - * prebuild-ios-core.yml), and ReactNativeDependencies + - * ReactNativeDependenciesHeaders from the - * `ReactNativeDependencies.xcframework.tar.gz` artifact (produced by - * prebuild-ios-dependencies.yml). Neither prebuild produces hermes-engine, so - * it is fetched from Maven (the `hermes-compiler` latest-v1 dist-tag by - * default; override with HERMES_VERSION). - * - * Rather than hand-rolling the tar/overlay dance, this delegates to - * download-spm-artifacts.js, whose `--core-tarball` / `--deps-tarball` - * local-tarball overrides extract exactly these companions, download + stage - * hermes (with its public headers), and write a valid artifacts.json — the - * same code path a real consumer's `spm download` runs. Passing the CI - * tarballs as-is is sufficient; the deps tarball nests its xcframeworks under - * `packages/react-native/third-party/`, which the extractor locates. - * - * Usage (from the repo root): - * node scripts/e2e/spm-prime-artifacts.js \ - * --artifacts /tmp/spm-artifacts --flavor Debug \ - * --core-tarball /tmp/rc/ReactCoreDebug.xcframework.tar.gz \ - * --deps-tarball /tmp/deps/ReactNativeDependenciesDebug.xcframework.tar.gz - */ - -const { - main: downloadArtifacts, - validateArtifactsCache, -} = require('../../packages/react-native/scripts/spm/download-spm-artifacts'); -const fs = require('node:fs'); -const path = require('node:path'); -const {parseArgs} = require('node:util'); - -const HELP = ` -Usage: node scripts/e2e/spm-prime-artifacts.js [options] - -Lays prebuilt xcframework tarballs into the --artifacts // layout -that \`npx react-native spm add --artifacts --download skip\` consumes. - -Options: - --artifacts (required) Output root. Writes //. - --flavor (required) Flavor to prime. - --core-tarball (required) ReactCore.xcframework.tar.gz - (React + ReactNativeHeaders). - --deps-tarball (required) ReactNativeDependencies.xcframework.tar.gz - (ReactNativeDependencies + …DependenciesHeaders). - --version [optional] React Native version label (for logs). - --help Show this help. - -Environment: - HERMES_VERSION Override the hermes-engine version to fetch - (default: the hermes-compiler latest-v1 dist-tag). -`; - -const config = { - options: { - artifacts: {type: 'string'}, - flavor: {type: 'string'}, - 'core-tarball': {type: 'string'}, - 'deps-tarball': {type: 'string'}, - version: {type: 'string'}, - help: {type: 'boolean', default: false}, - }, -}; - -async function main() /*: Promise */ { - const { - values, - /* $FlowFixMe[incompatible-type] Natural Inference rollout. See - * https://fburl.com/workplace/6291gfvu */ - } = parseArgs(config); - - if (values.help) { - console.log(HELP); - return; - } - - const missing = [ - 'artifacts', - 'flavor', - 'core-tarball', - 'deps-tarball', - ].filter( - // $FlowFixMe[invalid-computed-prop] - k => values[k] == null, - ); - if (missing.length > 0) { - throw new Error( - `Missing required option(s): ${missing - .map(k => '--' + k) - .join(', ')}\n${HELP}`, - ); - } - - const flavorRaw = String(values.flavor); - const flavor = flavorRaw.toLowerCase(); - if (flavor !== 'debug' && flavor !== 'release') { - throw new Error(`--flavor must be Debug or Release, got "${flavorRaw}"`); - } - - const coreTarball = path.resolve(String(values['core-tarball'])); - const depsTarball = path.resolve(String(values['deps-tarball'])); - for (const [label, p] of [ - ['--core-tarball', coreTarball], - ['--deps-tarball', depsTarball], - ]) { - if (!fs.existsSync(p)) { - throw new Error(`${label} not found: ${p}`); - } - } - - const outputDir = path.join(path.resolve(String(values.artifacts)), flavor); - fs.mkdirSync(outputDir, {recursive: true}); - - const argv = [ - '--flavor', - flavor, - '--output', - outputDir, - '--core-tarball', - coreTarball, - '--deps-tarball', - depsTarball, - ]; - if (values.version != null) { - argv.push('--version', String(values.version)); - } - - console.log( - `[spm-prime-artifacts] Priming ${flavorRaw} slot at ${outputDir}\n` + - ` core: ${coreTarball}\n deps: ${depsTarball}`, - ); - await downloadArtifacts(argv); - - const error = validateArtifactsCache(outputDir); - if (error != null) { - throw new Error(`Primed ${flavor} slot is incomplete: ${error}`); - } - console.log(`[spm-prime-artifacts] ${flavorRaw} slot ready: ${outputDir}`); -} - -if (require.main === module) { - main().catch(err => { - console.error(`\x1b[31m[spm-prime-artifacts] ${err.message}\x1b[0m`); - process.exit(1); - }); -} - -module.exports = {main};