diff --git a/.github/actions/setup-spm-artifacts/action.yml b/.github/actions/setup-spm-artifacts/action.yml new file mode 100644 index 000000000000..059cf03b4e5a --- /dev/null +++ b/.github/actions/setup-spm-artifacts/action.yml @@ -0,0 +1,57 @@ +name: setup-spm-artifacts +description: >- + Downloads the ReactCore and ReactNativeDependencies XCFrameworks built in this + run, in both flavors, into the SwiftPM artifact root /tmp/spm-artifacts. Pass + `--artifacts /tmp/spm-artifacts --download skip` to the `react-native spm` + command that follows. +inputs: + working-directory: + description: The app directory that `npx react-native spm download` runs from. + required: true +runs: + using: composite + steps: + # Both flavors are needed by every build, not just the one it builds: + # `spm` 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: ./.github/actions/download-artifact + with: + name: ReactCoreDebug.xcframework.tar.gz + path: /tmp/rc-debug + - name: Download ReactCore (Release) + uses: ./.github/actions/download-artifact + with: + name: ReactCoreRelease.xcframework.tar.gz + path: /tmp/rc-release + - name: Download ReactNativeDependencies (Debug) + uses: ./.github/actions/download-artifact + with: + name: ReactNativeDependenciesDebug.xcframework.tar.gz + path: /tmp/deps-debug + - name: Download ReactNativeDependencies (Release) + uses: ./.github/actions/download-artifact + with: + name: ReactNativeDependenciesRelease.xcframework.tar.gz + path: /tmp/deps-release + # The ordinary `spm download`, pointed at the XCFrameworks this run just + # built rather than at the published nightly (RN_CORE_TARBALL_PATH / + # RN_DEPS_TARBALL_PATH are overrides download-spm-artifacts.js already + # supports). It fills both flavor slots per call and skips any slot that + # already validates, so it runs twice: the first pass fills both slots + # from the Release tarballs, then debug/ is dropped and refilled from the + # Debug tarballs while release/ is left alone. hermes-engine is fetched + # from Maven by the command itself. + - name: Stage the XCFrameworks for SwiftPM (Debug + Release) + shell: bash + working-directory: ${{ inputs.working-directory }} + 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 diff --git a/.github/actions/test-ios-helloworld/action.yml b/.github/actions/test-ios-helloworld/action.yml index 639249c0cca8..28a5cb206ee0 100644 --- a/.github/actions/test-ios-helloworld/action.yml +++ b/.github/actions/test-ios-helloworld/action.yml @@ -1,15 +1,12 @@ name: test-ios-helloworld description: Test iOS Hello World inputs: - use-frameworks: - description: Whether to build with dynamic frameworks. - default: false - ruby-version: - description: The version of ruby that must be used - default: 2.6.10 flavor: description: The flavor of the build. Must be one of "Debug", "Release". default: Debug + dependency-manager: + description: 'How the app gets its native dependencies: swiftpm or cocoapods' + default: swiftpm runs: using: composite steps: @@ -19,46 +16,64 @@ runs: uses: ./.github/actions/setup-node - name: Run yarn install uses: ./.github/actions/yarn-install - - name: Setup ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ inputs.ruby-version }} - 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: Setup ruby + if: ${{ inputs.dependency-manager == 'cocoapods' }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.6.10 - name: Download ReactNativeDependencies + if: ${{ inputs.dependency-manager == 'cocoapods' }} uses: ./.github/actions/download-artifact with: name: ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/third-party - - name: Print third-party folder - shell: bash - run: ls -lR /tmp/third-party - name: Download React Native Prebuilds + if: ${{ inputs.dependency-manager == 'cocoapods' }} uses: ./.github/actions/download-artifact with: name: ReactCore${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/ReactCore - - name: Print ReactCore folder - shell: bash - run: ls -lR /tmp/ReactCore - - name: Install iOS dependencies - Configuration ${{ inputs.flavor }}; + - name: Install iOS dependencies - Configuration ${{ inputs.flavor }} + if: ${{ inputs.dependency-manager == 'cocoapods' }} shell: bash run: | cd private/helloworld - args=() - - if [[ "${{ inputs.use-frameworks }}" == "true" ]]; then - args+=(--frameworks dynamic) - fi export RCT_USE_LOCAL_RN_DEP="/tmp/third-party/ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz" export RCT_TESTONLY_RNCORE_TARBALL_PATH="/tmp/ReactCore/ReactCore${{ inputs.flavor }}.xcframework.tar.gz" - yarn bootstrap ios "${args[@]}" | cat + yarn bootstrap ios | cat + - name: Download the XCFrameworks built in this run + if: ${{ inputs.dependency-manager == 'swiftpm' }} + uses: ./.github/actions/setup-spm-artifacts + with: + working-directory: private/helloworld/ios + # HelloWorld is a CocoaPods project in git. CI converts it to SwiftPM on every run. + - name: Convert HelloWorld to SwiftPM + if: ${{ inputs.dependency-manager == 'swiftpm' }} + shell: bash + working-directory: private/helloworld/ios + run: RN_STRICT_API=1 npx react-native spm add --deintegrate --artifacts /tmp/spm-artifacts --download skip + - name: Assert HelloWorld is on SwiftPM + if: ${{ inputs.dependency-manager == 'swiftpm' }} + shell: bash + working-directory: private/helloworld/ios + run: | + if [[ ! -f HelloWorld.xcodeproj/.spm-injected.json ]]; then + echo "::error::spm add did not inject SwiftPM: HelloWorld.xcodeproj/.spm-injected.json is missing" + exit 1 + fi + if grep -q 'Pods-' HelloWorld.xcodeproj/project.pbxproj; then + echo "::error::spm add --deintegrate left CocoaPods references in HelloWorld.xcodeproj" + exit 1 + fi + echo "SwiftPM injected in place; CocoaPods de-integrated." - name: Run Helloworld tests shell: bash diff --git a/.github/actions/test-ios-rntester/action.yml b/.github/actions/test-ios-rntester/action.yml index 22382fb7b943..e54d2cc1be20 100644 --- a/.github/actions/test-ios-rntester/action.yml +++ b/.github/actions/test-ios-rntester/action.yml @@ -1,9 +1,6 @@ name: test-ios-rntester description: Test iOS RNTester inputs: - ruby-version: - description: The version of ruby that must be used - default: 2.6.10 run-unit-tests: description: whether unit tests should run or not. default: false @@ -14,19 +11,9 @@ inputs: description: Whether we want to run E2E tests or not required: false default: false - use-frameworks: - description: Whether we have to build with Dynamic Frameworks. - required: false - default: false - use-prebuilds: - description: >- - Whether to consume the prebuilt ReactCore/ReactNativeDependencies - artifacts. 'auto' (default) keeps the historical coupling: prebuilds for - static, source for dynamic frameworks. Pass 'true' with - use-frameworks:true for the prebuilt + dynamic-frameworks lane (the - config of the 2026-07-03 SocketRocket dual-copy regression). - required: false - default: auto + dependency-manager: + description: 'How the app gets its native dependencies: swiftpm or cocoapods' + default: swiftpm runs: using: composite @@ -37,12 +24,23 @@ runs: uses: ./.github/actions/setup-node - name: Run yarn 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: Prepare IOS Tests + if: ${{ inputs.run-unit-tests == 'true' }} + uses: ./.github/actions/prepare-ios-tests - name: Setup ruby + if: ${{ inputs.dependency-manager == 'cocoapods' }} uses: ruby/setup-ruby@v1 with: - ruby-version: ${{ inputs.ruby-version }} + ruby-version: 2.6.10 bundler: none - name: Install Bundler + if: ${{ inputs.dependency-manager == 'cocoapods' }} shell: bash run: | BUNDLER_VERSION="$(awk '/BUNDLED WITH/{getline; print $1}' Gemfile.lock)" @@ -62,86 +60,100 @@ runs: done echo "All 3 attempts failed." exit 1 - - 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: Prepare IOS Tests - if: ${{ inputs.run-unit-tests == 'true' }} - uses: ./.github/actions/prepare-ios-tests - - name: Resolve prebuilds mode - id: prebuilds - shell: bash - run: | - if [[ "${{ inputs.use-prebuilds }}" == "auto" ]]; then - # Historical coupling: prebuilds for static, source for dynamic frameworks. - if [[ "${{ inputs.use-frameworks }}" == "true" ]]; then - echo "enabled=false" >> "$GITHUB_OUTPUT" - else - echo "enabled=true" >> "$GITHUB_OUTPUT" - fi - elif [[ "${{ inputs.use-prebuilds }}" == "true" ]]; then - echo "enabled=true" >> "$GITHUB_OUTPUT" - elif [[ "${{ inputs.use-prebuilds }}" == "false" ]]; then - echo "enabled=false" >> "$GITHUB_OUTPUT" - else - # Don't silently treat a typo as 'disabled' — surface it. - echo "::warning::Unexpected use-prebuilds value '${{ inputs.use-prebuilds }}' (expected auto/true/false); treating as disabled." - echo "enabled=false" >> "$GITHUB_OUTPUT" - fi - name: Download ReactNativeDependencies - if: ${{ steps.prebuilds.outputs.enabled == 'true' }} + if: ${{ inputs.dependency-manager == 'cocoapods' }} uses: ./.github/actions/download-artifact with: name: ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/third-party/ - - name: Print third-party folder - if: ${{ steps.prebuilds.outputs.enabled == 'true' }} - shell: bash - run: ls -lR /tmp/third-party - name: Download React Native Prebuilds - if: ${{ steps.prebuilds.outputs.enabled == 'true' }} + if: ${{ inputs.dependency-manager == 'cocoapods' }} uses: ./.github/actions/download-artifact with: name: ReactCore${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/ReactCore - - name: Print ReactCore folder - if: ${{ steps.prebuilds.outputs.enabled == 'true' }} - shell: bash - run: ls -lR /tmp/ReactCore - name: Install CocoaPods dependencies + if: ${{ inputs.dependency-manager == 'cocoapods' }} shell: bash run: | - if [[ ${{ inputs.use-frameworks }} == "true" ]]; then - export USE_FRAMEWORKS=dynamic - fi - if [[ "${{ steps.prebuilds.outputs.enabled }}" == "true" ]]; then - export RCT_USE_LOCAL_RN_DEP="/tmp/third-party/ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz" - export RCT_TESTONLY_RNCORE_TARBALL_PATH="/tmp/ReactCore/ReactCore${{ inputs.flavor }}.xcframework.tar.gz" - fi + export RCT_USE_LOCAL_RN_DEP="/tmp/third-party/ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz" + export RCT_TESTONLY_RNCORE_TARBALL_PATH="/tmp/ReactCore/ReactCore${{ inputs.flavor }}.xcframework.tar.gz" cd packages/rn-tester bundle install bundle exec pod update hermes-engine --no-repo-update + - name: Download the XCFrameworks built in this run + if: ${{ inputs.dependency-manager == 'swiftpm' }} + uses: ./.github/actions/setup-spm-artifacts + with: + working-directory: packages/rn-tester + # RNTester is a CocoaPods project in git, so developers can build React + # Native from source. CI converts it to SwiftPM on every run. + # react-native-test-library ships only a podspec; scaffold writes its + # Package.swift. + - name: Scaffold Package.swift manifests for community dependencies + if: ${{ inputs.dependency-manager == 'swiftpm' }} + shell: bash + working-directory: packages/rn-tester + run: npx react-native spm scaffold --artifacts /tmp/spm-artifacts --download skip || true + - name: Convert RNTester to SwiftPM + if: ${{ inputs.dependency-manager == 'swiftpm' }} + shell: bash + working-directory: packages/rn-tester + run: RN_STRICT_API=1 npx react-native spm add --deintegrate --artifacts /tmp/spm-artifacts --download skip + - name: Assert RNTester is on SwiftPM + if: ${{ inputs.dependency-manager == 'swiftpm' }} + shell: bash + working-directory: packages/rn-tester + run: | + if [[ ! -f RNTesterPods.xcodeproj/.spm-injected.json ]]; then + echo "::error::spm add did not inject SwiftPM: RNTesterPods.xcodeproj/.spm-injected.json is missing" + exit 1 + fi + if grep -q 'Pods-' RNTesterPods.xcodeproj/project.pbxproj; then + echo "::error::spm add --deintegrate left CocoaPods references in RNTesterPods.xcodeproj" + exit 1 + fi + echo "SwiftPM injected in place; CocoaPods de-integrated." - name: Build RNTester shell: bash run: | + if [[ "${{ inputs.dependency-manager }}" == "cocoapods" ]]; then + container=(-workspace packages/rn-tester/RNTesterPods.xcworkspace) + else + container=(-project packages/rn-tester/RNTesterPods.xcodeproj) + fi xcodebuild \ -scheme "RNTester" \ - -workspace packages/rn-tester/RNTesterPods.xcworkspace \ + "${container[@]}" \ -configuration "${{ inputs.flavor }}" \ -sdk "iphonesimulator" \ -destination "generic/platform=iOS Simulator" \ -derivedDataPath "/tmp/RNTesterBuild" - echo "Print path to *.app file" - APP_PATH=$(find "/tmp/RNTesterBuild" -type d -name "*.app") - - echo "App found at $APP_PATH" - echo "app-path=$APP_PATH" >> $GITHUB_ENV + APP_PATH="/tmp/RNTesterBuild/Build/Products/${{ inputs.flavor }}-iphonesimulator/RNTester.app" + echo "App found at $APP_PATH" + echo "app-path=$APP_PATH" >> $GITHUB_ENV + - name: Check the embedded React.framework flavor + if: ${{ inputs.dependency-manager == 'swiftpm' }} + shell: bash + run: | + BINARY="${{ env.app-path }}/Frameworks/React.framework/React" + if [[ ! -f "$BINARY" ]]; then + echo "::error::No embedded React.framework binary at $BINARY" + exit 1 + fi + COUNT=$(nm "$BINARY" | grep -c getDebugProps || true) + echo "getDebugProps symbols in $BINARY: $COUNT" + if [[ "${{ inputs.flavor }}" == 'Debug' && "$COUNT" -eq 0 ]]; then + echo "::error::Debug build embeds a Release React.framework (expected getDebugProps symbols, found none)" + exit 1 + fi + if [[ "${{ inputs.flavor }}" == 'Release' && "$COUNT" -ne 0 ]]; then + echo "::error::Release build embeds a Debug React.framework ($COUNT getDebugProps symbols, expected none)" + exit 1 + fi - name: 'Run Tests: iOS Unit and Integration Tests' if: ${{ inputs.run-unit-tests == 'true' }} shell: bash @@ -156,19 +168,19 @@ runs: tar -zcvf xcresults.tar.gz "/tmp/RNTesterTestResults" - name: Upload artifact uses: ./.github/actions/upload-artifact - if: ${{ inputs.run-unit-tests == 'true' }} + if: ${{ inputs.run-unit-tests == 'true' && inputs.dependency-manager == 'swiftpm' }} with: - name: xcresults-${{ inputs.flavor }}-${{ inputs.use-frameworks == 'true' && 'dynamic-frameworks' || 'static-libraries' }}-Ruby${{ inputs.ruby-version }} + name: xcresults-${{ inputs.flavor }} path: /Users/runner/Library/Developer/Xcode/xcresults.tar.gz - name: Upload RNTester App - if: ${{ inputs.use-frameworks == 'false' && inputs.ruby-version == '2.6.10' }} # This is needed to avoid conflicts with the artifacts + if: ${{ inputs.dependency-manager == 'swiftpm' }} # The e2e tests download this artifact; one upload per name uses: ./.github/actions/upload-artifact with: name: RNTesterApp-NewArch-${{ inputs.flavor }} path: ${{ env.app-path }} - name: Store test results - if: ${{ inputs.run-unit-tests == 'true' }} + if: ${{ inputs.run-unit-tests == 'true' && inputs.dependency-manager == 'swiftpm' }} uses: ./.github/actions/upload-artifact with: - name: test-results-${{ inputs.flavor }}-${{ inputs.use-frameworks == 'true' && 'dynamic-frameworks' || 'static-libraries' }}-Ruby${{ inputs.ruby-version }} + name: test-results-${{ inputs.flavor }} path: ./reports/junit diff --git a/.github/workflows/e2e-ios-rntester.yml b/.github/workflows/e2e-ios-rntester.yml index 9f5fd3324e1d..c884c73e9581 100644 --- a/.github/workflows/e2e-ios-rntester.yml +++ b/.github/workflows/e2e-ios-rntester.yml @@ -112,9 +112,7 @@ jobs: if [[ "${{ steps.test-state.outputs.should-run }}" == "false" || "${{ steps.run-tests.outcome }}" == "success" ]]; then RESULT=passed elif [[ "${{ steps.download-app.outcome }}" == "failure" ]]; then - # test_ios_rntester uses continue-on-error, so it reports success - # even when the build that uploads this app failed. - echo "::error::RNTesterApp-NewArch-${{ matrix.flavor }} is missing. Check the test_ios_rntester (${{ matrix.flavor }}, frameworks: false) job." + echo "::error::RNTesterApp-NewArch-${{ matrix.flavor }} is missing. Check the test_ios_rntester (${{ matrix.flavor }}) job." RESULT=failed elif [[ -f "$STATE" ]] && jq -e 'any(.flows[]; .status == "failed")' "$STATE" > /dev/null; then RESULT=failed diff --git a/.github/workflows/e2e-ios-templateapp.yml b/.github/workflows/e2e-ios-templateapp.yml index 22795a58ef0a..56a7902a5adf 100644 --- a/.github/workflows/e2e-ios-templateapp.yml +++ b/.github/workflows/e2e-ios-templateapp.yml @@ -32,10 +32,6 @@ jobs: uses: ./.github/actions/setup-node - name: Run yarn uses: ./.github/actions/yarn-install - - name: Setup ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 2.6.10 - name: Download React Native Package uses: ./.github/actions/download-artifact with: @@ -43,28 +39,12 @@ jobs: path: /tmp/react-native-tmp - name: Print /tmp folder run: ls -lR /tmp/react-native-tmp - - name: Download ReactNativeDependencies - uses: ./.github/actions/download-artifact - with: - name: ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz - path: /tmp/third-party - - name: Print third-party folder - shell: bash - run: ls -lR /tmp/third-party - - name: Download React Native Prebuilds - uses: ./.github/actions/download-artifact - with: - name: ReactCore${{ matrix.flavor }}.xcframework.tar.gz - path: /tmp/ReactCore - - name: Print ReactCore folder - shell: bash - run: ls -lR /tmp/ReactCore - name: Configure git shell: bash run: | git config --global user.email "react-native-bot@meta.com" git config --global user.name "React Native Bot" - - name: Build the app + - name: Create the app run: | REACT_NATIVE_PKG=$(find /tmp/react-native-tmp -type f -name "*.tgz") echo "React Native tgs is $REACT_NATIVE_PKG" @@ -77,19 +57,19 @@ jobs: fi node ./scripts/e2e/init-project-e2e.js --projectName RNTestProject --currentBranch $BRANCH --directory /tmp/RNTestProject --pathToLocalReactNative $REACT_NATIVE_PKG - - cd /tmp/RNTestProject/ios - bundle install - NEW_ARCH_ENABLED=1 - - export RCT_USE_LOCAL_RN_DEP=/tmp/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz - # Disable prebuilds for now, as they are causing issues with E2E tests for 0.82-stable branch - export RCT_TESTONLY_RNCORE_TARBALL_PATH="/tmp/ReactCore/ReactCore${{ matrix.flavor }}.xcframework.tar.gz" - RN_STRICT_API=1 RCT_NEW_ARCH_ENABLED=$NEW_ARCH_ENABLED bundle exec pod install - + - name: Download the XCFrameworks built in this run + uses: ./.github/actions/setup-spm-artifacts + with: + working-directory: /tmp/RNTestProject/ios + - name: Convert the app to SwiftPM + working-directory: /tmp/RNTestProject/ios + run: RN_STRICT_API=1 npx react-native spm add --deintegrate --artifacts /tmp/spm-artifacts --download skip + - name: Build the app + working-directory: /tmp/RNTestProject/ios + run: | xcodebuild \ -scheme "RNTestProject" \ - -workspace RNTestProject.xcworkspace \ + -project RNTestProject.xcodeproj \ -configuration "${{ matrix.flavor }}" \ -sdk "iphonesimulator" \ -destination "generic/platform=iOS Simulator" \ diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 2c67bb36dc05..f1a7d907ff6b 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -113,25 +113,10 @@ jobs: secrets: inherit needs: [prebuild_apple_dependencies] - test_ios_rntester_ruby_3_2_0: - runs-on: macos-15 + test_ios_rntester: + runs-on: macos-15-large needs: [prebuild_apple_dependencies, prebuild_react_native_core] if: ${{ needs.prebuild_react_native_core.result == 'success' }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Run it - uses: ./.github/actions/test-ios-rntester - with: - ruby-version: '3.2.0' - flavor: Debug - - test_ios_rntester_dynamic_frameworks: - runs-on: macos-15-large - needs: check_code_changes - if: | - needs.check_code_changes.outputs.any_code_change == 'true' && - needs.check_code_changes.outputs.should_test_ios == 'true' strategy: fail-fast: false matrix: @@ -143,33 +128,20 @@ jobs: uses: ./.github/actions/test-ios-rntester with: flavor: ${{ matrix.flavor }} - use-frameworks: true - run-unit-tests: false # tests for dynamic frameworks are already run in the test_ios_rntester job; this is to just a test build from source (no prebuilds) - test_ios_rntester: + # The CocoaPods opt-out stays supported, so CI keeps one CocoaPods build per test app. + test_ios_rntester_cocoapods: runs-on: macos-15-large needs: [prebuild_apple_dependencies, prebuild_react_native_core] if: ${{ needs.prebuild_react_native_core.result == 'success' }} - continue-on-error: true - strategy: - fail-fast: false - matrix: - flavor: [Debug, Release] - frameworks: [false, true] steps: - name: Checkout uses: actions/checkout@v6 - name: Run it uses: ./.github/actions/test-ios-rntester with: - use-frameworks: ${{ matrix.frameworks }} - # Consume the prebuilt artifacts in the dynamic-frameworks cells too: - # prebuilt + use_frameworks is the config of the 2026-07-03 - # SocketRocket dual-copy regression, previously covered by no lane - # (source-built dynamic frameworks stay covered by - # test_ios_rntester_dynamic_frameworks). - use-prebuilds: true - flavor: ${{ matrix.flavor }} + flavor: Debug + dependency-manager: cocoapods test_e2e_ios_rntester: needs: test_ios_rntester @@ -225,28 +197,6 @@ 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: [ @@ -490,10 +440,13 @@ jobs: path: ./private/helloworld/android/app/build/outputs/apk/ compression-level: 0 - test_ios_helloworld_with_ruby_3_2_0: + test_ios_helloworld: runs-on: macos-15 needs: [prebuild_apple_dependencies, prebuild_react_native_core] if: ${{ needs.prebuild_react_native_core.result == 'success' }} + strategy: + matrix: + flavor: [Debug, Release] env: PROJECT_NAME: iOSTemplateProject YARN_ENABLE_IMMUTABLE_INSTALLS: false @@ -502,21 +455,13 @@ jobs: uses: actions/checkout@v6 - uses: ./.github/actions/test-ios-helloworld with: - ruby-version: 3.2.0 - flavor: Debug + flavor: ${{ matrix.flavor }} - test_ios_helloworld: + # The CocoaPods opt-out stays supported, so CI keeps one CocoaPods build per test app. + test_ios_helloworld_cocoapods: runs-on: macos-15 needs: [prebuild_apple_dependencies, prebuild_react_native_core] if: ${{ needs.prebuild_react_native_core.result == 'success' }} - strategy: - matrix: - flavor: [Debug, Release] - use_frameworks: [false, true] - exclude: - # This config is tested with Ruby 3.2.0. Let's not double test it. - - flavor: Debug - use_frameworks: false env: PROJECT_NAME: iOSTemplateProject YARN_ENABLE_IMMUTABLE_INSTALLS: false @@ -525,8 +470,8 @@ jobs: uses: actions/checkout@v6 - uses: ./.github/actions/test-ios-helloworld with: - flavor: ${{ matrix.flavor }} - use-frameworks: ${{ matrix.use_frameworks }} + flavor: Debug + dependency-manager: cocoapods lint: runs-on: ubuntu-latest diff --git a/.github/workflows/test-ios-spm-helloworld.yml b/.github/workflows/test-ios-spm-helloworld.yml deleted file mode 100644 index 12713e06dc9f..000000000000 --- a/.github/workflows/test-ios-spm-helloworld.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: Test iOS SwiftPM - Hello World - -permissions: - contents: read - -on: - workflow_call: - -jobs: - test: - runs-on: macos-15-large - strategy: - fail-fast: false - matrix: - flavor: [Debug, Release] - env: - APP_IOS_DIR: private/helloworld/ios - XCODE_PROJECT: HelloWorld.xcodeproj - XCODE_SCHEME: HelloWorld - 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 (`spm add --deintegrate` shells out to `pod`) - shell: bash - run: pod --version || sudo gem install cocoapods --no-document - # 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: ./.github/actions/download-artifact - with: - name: ReactCoreDebug.xcframework.tar.gz - path: /tmp/rc-debug - - name: Download ReactCore (Release) - uses: ./.github/actions/download-artifact - with: - name: ReactCoreRelease.xcframework.tar.gz - path: /tmp/rc-release - - name: Download ReactNativeDependencies (Debug) - uses: ./.github/actions/download-artifact - with: - name: ReactNativeDependenciesDebug.xcframework.tar.gz - path: /tmp/deps-debug - - name: Download ReactNativeDependencies (Release) - uses: ./.github/actions/download-artifact - with: - name: ReactNativeDependenciesRelease.xcframework.tar.gz - path: /tmp/deps-release - # The ordinary `spm download`, pointed at the XCFrameworks this run just - # built rather than at the published nightly (RN_CORE_TARBALL_PATH / - # RN_DEPS_TARBALL_PATH are overrides download-spm-artifacts.js already - # supports). It fills both flavor slots per call and skips any slot that - # already validates, so it runs twice: the first pass fills both slots - # from the Release tarballs, then debug/ is dropped and refilled from the - # Debug tarballs while release/ is left alone. hermes-engine is fetched - # from Maven by the command itself. - - name: Download XCFrameworks built in this run (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 grep -q 'Pods-' "$XCODE_PROJECT/project.pbxproj"; then - echo "::error::spm add --deintegrate left CocoaPods references in $XCODE_PROJECT" - exit 1 - fi - echo "SwiftPM injected in place; CocoaPods de-integrated." - - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) - shell: bash - working-directory: ${{ env.APP_IOS_DIR }} - run: | - 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: | - 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 8fafbf48fa55..5607e8b94804 100644 --- a/.github/workflows/test-ios-spm-newapp.yml +++ b/.github/workflows/test-ios-spm-newapp.yml @@ -31,34 +31,6 @@ 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 (`spm add --deintegrate` shells out to `pod`) - shell: bash - run: pod --version || sudo gem install cocoapods --no-document - # 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: ./.github/actions/download-artifact - with: - name: ReactCoreDebug.xcframework.tar.gz - path: /tmp/rc-debug - - name: Download ReactCore (Release) - uses: ./.github/actions/download-artifact - with: - name: ReactCoreRelease.xcframework.tar.gz - path: /tmp/rc-release - - name: Download ReactNativeDependencies (Debug) - uses: ./.github/actions/download-artifact - with: - name: ReactNativeDependenciesDebug.xcframework.tar.gz - path: /tmp/deps-debug - - name: Download ReactNativeDependencies (Release) - uses: ./.github/actions/download-artifact - with: - name: ReactNativeDependenciesRelease.xcframework.tar.gz - path: /tmp/deps-release - name: Download React Native package uses: ./.github/actions/download-artifact with: @@ -70,34 +42,16 @@ jobs: - 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)" - # The ordinary `spm download`, pointed at the XCFrameworks this run just - # built rather than at the published nightly (RN_CORE_TARBALL_PATH / - # RN_DEPS_TARBALL_PATH are overrides download-spm-artifacts.js already - # supports). It fills both flavor slots per call and skips any slot that - # already validates, so it runs twice: the first pass fills both slots - # from the Release tarballs, then debug/ is dropped and refilled from the - # Debug tarballs while release/ is left alone. hermes-engine is fetched - # from Maven by the command itself. - - name: Download XCFrameworks built in this run (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 + - name: Download the XCFrameworks built in this run + uses: ./.github/actions/setup-spm-artifacts + with: + working-directory: ${{ env.APP_IOS_DIR }} + # HelloWorld is a CocoaPods project in git. CI converts it to SwiftPM on every run. + - name: Convert HelloWorld 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 + run: RN_STRICT_API=1 npx react-native spm add --deintegrate --artifacts /tmp/spm-artifacts --download skip + - name: Assert HelloWorld is on SwiftPM shell: bash working-directory: ${{ env.APP_IOS_DIR }} run: | diff --git a/.github/workflows/test-ios-spm-rntester.yml b/.github/workflows/test-ios-spm-rntester.yml deleted file mode 100644 index cce98b0ba3d4..000000000000 --- a/.github/workflows/test-ios-spm-rntester.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: Test iOS SwiftPM - RN Tester - -permissions: - contents: read - -on: - workflow_call: - -jobs: - test: - runs-on: macos-15-large - strategy: - fail-fast: false - matrix: - flavor: [Debug, Release] - env: - APP_IOS_DIR: packages/rn-tester - XCODE_PROJECT: RNTesterPods.xcodeproj - XCODE_SCHEME: RNTester - 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 (`spm add --deintegrate` shells out to `pod`) - shell: bash - run: pod --version || sudo gem install cocoapods --no-document - # 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: ./.github/actions/download-artifact - with: - name: ReactCoreDebug.xcframework.tar.gz - path: /tmp/rc-debug - - name: Download ReactCore (Release) - uses: ./.github/actions/download-artifact - with: - name: ReactCoreRelease.xcframework.tar.gz - path: /tmp/rc-release - - name: Download ReactNativeDependencies (Debug) - uses: ./.github/actions/download-artifact - with: - name: ReactNativeDependenciesDebug.xcframework.tar.gz - path: /tmp/deps-debug - - name: Download ReactNativeDependencies (Release) - uses: ./.github/actions/download-artifact - with: - name: ReactNativeDependenciesRelease.xcframework.tar.gz - path: /tmp/deps-release - # The ordinary `spm download`, pointed at the XCFrameworks this run just - # built rather than at the published nightly (RN_CORE_TARBALL_PATH / - # RN_DEPS_TARBALL_PATH are overrides download-spm-artifacts.js already - # supports). It fills both flavor slots per call and skips any slot that - # already validates, so it runs twice: the first pass fills both slots - # from the Release tarballs, then debug/ is dropped and refilled from the - # Debug tarballs while release/ is left alone. hermes-engine is fetched - # from Maven by the command itself. - - name: Download XCFrameworks built in this run (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 grep -q 'Pods-' "$XCODE_PROJECT/project.pbxproj"; then - echo "::error::spm add --deintegrate left CocoaPods references in $XCODE_PROJECT" - exit 1 - fi - echo "SwiftPM injected in place; CocoaPods de-integrated." - - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) - shell: bash - working-directory: ${{ env.APP_IOS_DIR }} - run: | - 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: | - 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/private/core-cli-utils/README.md b/private/core-cli-utils/README.md index 545affa2aa5d..ea98269efafb 100644 --- a/private/core-cli-utils/README.md +++ b/private/core-cli-utils/README.md @@ -1,18 +1,18 @@ # @react-native/core-cli-utils -A reference implementation of React Native CLI tooling. This package provides composable, ordered `Task` objects for common CLI operations including Android Gradle builds, iOS Xcode/CocoaPods workflows, Metro bundling with Hermes support, and cache cleaning. +A reference implementation of React Native CLI tooling. This package provides composable, ordered `Task` objects for common CLI operations including Android Gradle builds, iOS Xcode workflows with CocoaPods or SwiftPM, Metro bundling with Hermes support, and cache cleaning. This is not published to npm. Framework authors can use this code as a starting point for their own CLI tooling, but should not depend on it as a versioned API. ## Modules - **`android`** — Gradle-based Android build tasks (assemble, build, install) -- **`apple`** — Xcode/CocoaPods-based iOS tasks (bootstrap, build, install) +- **`apple`** — Xcode-based iOS tasks: bootstrap with CocoaPods, build with CocoaPods or SwiftPM, install - **`app`** — Metro bundler tasks (watch mode, bundle mode, Hermes bytecode compilation) - **`clean`** — Cache-cleaning tasks (Android/Gradle, Metro, npm, Watchman, Yarn, CocoaPods) - **`version`** — Semver version requirements for platform toolchains (Android NDK/SDK, Xcode, Node, etc.) ## Consumers -- [`private/helloworld/`](../helloworld/) — the primary consumer, using Android, iOS, and Metro modules +- [`private/helloworld/`](../helloworld/) — the primary consumer, using Android, iOS, and Metro modules. Its iOS app uses CocoaPods; CI converts it to SwiftPM - [`packages/rn-tester/`](../../packages/rn-tester/) — uses iOS bootstrap for CocoaPods setup diff --git a/private/core-cli-utils/src/private/apple.js b/private/core-cli-utils/src/private/apple.js index d15664506c4b..c0008b209b06 100644 --- a/private/core-cli-utils/src/private/apple.js +++ b/private/core-cli-utils/src/private/apple.js @@ -27,9 +27,13 @@ type AppleBuildOptions = { mode: AppleBuildMode, scheme?: string, destination: 'device' | 'simulator' | string, + // How the project gets its native dependencies. Defaults to 'cocoapods'. + dependencyManager?: AppleDependencyManager, ...AppleOptions, }; +type AppleDependencyManager = 'cocoapods' | 'swiftpm'; + type AppleBootstrapOption = { // Enabled by default hermes: boolean, @@ -78,6 +82,32 @@ const FIRST = 1, FOURTH = 4, FIFTH = 5; +// The Swift packages `react-native spm` generates, which Xcode resolves before +// it runs any build phase. +const SWIFT_PACKAGE_DIRS = [ + 'build/xcframeworks', + 'build/generated/autolinking', + 'build/generated/ios', +]; + +function assertReadable( + cwd /*: string */, + files /*: ReadonlyArray */, + fix /*: string */, +) { + for (const file of files) { + try { + fs.accessSync( + path.join(cwd, file), + /* eslint-disable-next-line no-bitwise */ + fs.constants.F_OK | fs.constants.R_OK, + ); + } catch (e /*: unknown */) { + throw new Error(`Please run: ${fix}: ` + String(e)); + } + } +} + function getNodePackagePath(packageName /*: string */) /*: string */ { // $FlowFixMe[prop-missing] type definition is incomplete return require.resolve(packageName, {cwd: [process.cwd(), ...module.paths]}); @@ -94,7 +124,7 @@ type BootstrapTasks = { type BuildTasks = { validate: Task, - hasPodsInstalled: Task, + hasDependenciesInstalled: Task, build: Task, }; @@ -170,23 +200,29 @@ const tasks = { assertDependencies(isOnPath('xcodebuild', 'Xcode Commandline Tools')); }, ), - hasPodsInstalled: task(FIRST, 'Check Pods are installed', () => { - for (const file of ['Podfile.lock', 'Pods']) { - try { - fs.accessSync( - path.join(options.cwd, file), - /* eslint-disable-next-line no-bitwise */ - fs.constants.F_OK | fs.constants.R_OK, + hasDependenciesInstalled: task( + FIRST, + 'Check the native dependencies are installed', + () => { + if (options.dependencyManager === 'swiftpm') { + assertReadable( + options.cwd, + SWIFT_PACKAGE_DIRS, + 'npx react-native spm update', ); - } catch (e /*: unknown */) { - throw new Error('Please run: yarn run bootstrap ios: ' + String(e)); + return; } - } - checkPodfileInSyncWithManifest( - path.join(options.cwd, 'Podfile.lock'), - path.join(options.cwd, 'Pods/Manifest.lock'), - ); - }), + assertReadable( + options.cwd, + ['Podfile.lock', 'Pods'], + 'yarn run bootstrap ios', + ); + checkPodfileInSyncWithManifest( + path.join(options.cwd, 'Podfile.lock'), + path.join(options.cwd, 'Pods/Manifest.lock'), + ); + }, + ), build: task(SECOND, 'build an app artifact', () => { const _args = [ options.isWorkspace ? '-workspace' : '-project', diff --git a/private/helloworld/cli.flow.js b/private/helloworld/cli.flow.js index d9732a991877..8c3053cddddb 100644 --- a/private/helloworld/cli.flow.js +++ b/private/helloworld/cli.flow.js @@ -12,6 +12,7 @@ import {run} from './lib/cli'; import {getExistingPath, pauseWatchman} from './lib/filesystem'; import { bootSimulator, + getDependencyManager, getSimulatorDetails, getXcodeBuildSettings, hasPodInstalled, @@ -174,13 +175,17 @@ build break; } + const dependencyManager = getDependencyManager(cwd.ios); + const isWorkspace = dependencyManager === 'cocoapods'; + await pauseWatchman(async () => { await run( apple.build({ - isWorkspace: true, - name: 'HelloWorld.xcworkspace', + isWorkspace, + name: isWorkspace ? 'HelloWorld.xcworkspace' : 'HelloWorld.xcodeproj', mode, scheme: 'HelloWorld', + dependencyManager, cwd: cwd.ios, env: { SKIP_BUNDLING: 'true', @@ -191,6 +196,46 @@ build }); }); +const getJSVMConfig = (): { + jsvm: 'hermes' | 'jsc', + hermes: {path: string, hermesc: string}, +} => { + if (getDependencyManager(cwd.ios) === 'swiftpm') { + // A SwiftPM app has no Pods/hermes-engine. Like react-native-xcode.sh, use + // the hermesc of the hermes-compiler package that react-native depends on. + return { + jsvm: 'hermes', + hermes: { + path: path.dirname( + require.resolve('hermes-compiler/package.json', { + paths: [path.dirname(require.resolve('react-native/package.json'))], + }), + ), + hermesc: 'hermesc/osx-bin/hermesc', + }, + }; + } + + const isHermesInstalled = hasPodInstalled(cwd.ios, 'hermes-engine'); + + // Validate only after initial build, as hermesc may not be prebuilt + const hermesFolder = path.join(cwd.ios, 'Pods/hermes-engine'); + const hermesc = getExistingPath(hermesFolder, possibleHermescPaths); + + if (hermesc == null && isHermesInstalled) { + throw new Error( + `Unable to find hermesc at:\n-${possibleHermescPaths + .map(line => ' - ' + line) + .join('\n')}`, + ); + } + + return { + jsvm: isHermesInstalled ? 'hermes' : 'jsc', + hermes: {path: hermesFolder, hermesc: hermesc ?? ''}, + }; +}; + type BundleOptions = { prod: boolean, watch: boolean, @@ -205,8 +250,8 @@ bundle .action(async ({prod, watch}: BundleOptions) => { const mode = prod ? 'Release' : 'Debug'; - const isHermesInstalled = hasPodInstalled(cwd.ios, 'hermes-engine'); const settings = await getBuildSettings(mode); + const {jsvm, hermes} = getJSVMConfig(); // Metro: src -> js const jsBundlePath = path.join(settings.bundleBuildDir, 'main.jsbundle.js'); @@ -216,18 +261,6 @@ bundle 'main.jsbundle', ); - // Validate only after initial build, as hermesc may not be prebuilt - const hermesFolder = path.join(cwd.ios, 'Pods/hermes-engine'); - const hermesc = getExistingPath(hermesFolder, possibleHermescPaths); - - if (hermesc == null && isHermesInstalled) { - throw new Error( - `Unable to find hermesc at:\n-${possibleHermescPaths - .map(line => ' - ' + line) - .join('\n')}`, - ); - } - const bundler = watch ? app.bundle({ mode: 'watch', @@ -240,11 +273,8 @@ bundle outputSourceMap: settings.bundleResourceDir, outputBundle: binaryBundlePath, dev: !prod, - jsvm: isHermesInstalled ? 'hermes' : 'jsc', - hermes: { - path: hermesFolder, - hermesc: hermesc ?? '', - }, + jsvm, + hermes, callback: metroProcess => { const readline = require('node:readline'); readline.emitKeypressEvents(process.stdin); @@ -267,11 +297,8 @@ bundle outputSourceMap: settings.bundleResourceDir, outputBundle: binaryBundlePath, dev: !prod, - jsvm: isHermesInstalled ? 'hermes' : 'jsc', - hermes: { - path: hermesFolder, - hermesc: hermesc ?? '', - }, + jsvm, + hermes, }); // JS Bundle diff --git a/private/helloworld/lib/__tests__/ios-test.js b/private/helloworld/lib/__tests__/ios-test.js new file mode 100644 index 000000000000..7a1cae637913 --- /dev/null +++ b/private/helloworld/lib/__tests__/ios-test.js @@ -0,0 +1,67 @@ +/** + * 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. + * + * @format + * @noflow + */ + +'use strict'; + +jest.mock('execa', () => ({sync: jest.fn(() => ({stdout: '[]'}))})); + +const {getDependencyManager, getXcodeBuildSettings} = require('../ios'); +const execa = require('execa'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +let iosPath; +beforeEach(() => { + iosPath = fs.mkdtempSync(path.join(os.tmpdir(), 'helloworld-ios-')); + fs.mkdirSync(path.join(iosPath, 'HelloWorld.xcodeproj')); + execa.sync.mockClear(); +}); +afterEach(() => { + fs.rmSync(iosPath, {recursive: true, force: true}); +}); + +function convertToSwiftPM() { + fs.writeFileSync( + path.join(iosPath, 'HelloWorld.xcodeproj', '.spm-injected.json'), + '{}', + ); +} + +describe('getDependencyManager', () => { + it('is cocoapods for the project as checked in', () => { + expect(getDependencyManager(iosPath)).toBe('cocoapods'); + }); + + it('is swiftpm after `react-native spm add` converted the project', () => { + convertToSwiftPM(); + expect(getDependencyManager(iosPath)).toBe('swiftpm'); + }); +}); + +describe('getXcodeBuildSettings', () => { + const xcodebuildArgs = () => execa.sync.mock.calls[0][1]; + + it('reads the CocoaPods workspace', () => { + getXcodeBuildSettings(iosPath, 'Debug'); + expect(xcodebuildArgs()).toEqual( + expect.arrayContaining(['-workspace', 'HelloWorld.xcworkspace']), + ); + }); + + it('reads the Xcode project of a SwiftPM app', () => { + convertToSwiftPM(); + getXcodeBuildSettings(iosPath, 'Debug'); + expect(xcodebuildArgs()).toEqual( + expect.arrayContaining(['-project', 'HelloWorld.xcodeproj']), + ); + expect(xcodebuildArgs()).not.toContain('-workspace'); + }); +}); diff --git a/private/helloworld/lib/ios.js b/private/helloworld/lib/ios.js index 4652497ac23a..49e6ded2157b 100644 --- a/private/helloworld/lib/ios.js +++ b/private/helloworld/lib/ios.js @@ -130,11 +130,14 @@ export function getXcodeBuildSettings( iosProjectFolder: string, mode: 'Release' | 'Debug', ): XcodeBuildSettings[] { + const container = + getDependencyManager(iosProjectFolder) === 'swiftpm' + ? ['-project', 'HelloWorld.xcodeproj'] + : ['-workspace', 'HelloWorld.xcworkspace']; const {stdout} = execa.sync( 'xcodebuild', [ - '-workspace', - 'HelloWorld.xcworkspace', + ...container, '-scheme', 'HelloWorld', '-configuration', @@ -149,6 +152,16 @@ export function getXcodeBuildSettings( return JSON.parse(stdout); } +// HelloWorld is a CocoaPods app in git. `react-native spm add` converts it to +// SwiftPM, as CI does, and leaves this marker in the Xcode project. +export function getDependencyManager(iosPath: string): 'cocoapods' | 'swiftpm' { + return fs.existsSync( + path.join(iosPath, 'HelloWorld.xcodeproj', '.spm-injected.json'), + ) + ? 'swiftpm' + : 'cocoapods'; +} + export function hasPodInstalled(iosPath: string, pkg: string): boolean { const containsPackage = new RegExp(`\n - ${pkg} \\(.+\\):?\n`); return containsPackage.test(