diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 3b1a45e6bd39..0c09daa6d9aa 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -203,6 +203,21 @@ jobs: fail-on-error: true secrets: inherit + # The templateapp lane above pins RNCore to a local tarball, which removes the + # Debug/Release script phase from the podspec. This lane is the only one that + # runs it. + test_ios_prebuilt_config_switch: + 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-prebuilt-config-switch.yml + secrets: inherit + test_ios_spm_rntester: needs: [ diff --git a/.github/workflows/test-ios-prebuilt-config-switch.yml b/.github/workflows/test-ios-prebuilt-config-switch.yml new file mode 100644 index 000000000000..f0804f20469d --- /dev/null +++ b/.github/workflows/test-ios-prebuilt-config-switch.yml @@ -0,0 +1,123 @@ +name: Test iOS Prebuilt Config Switch + +permissions: + contents: read + +on: + workflow_call: + +jobs: + test: + runs-on: macos-15-large + 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 + 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: actions/download-artifact@v7 + with: + name: react-native-package + path: /tmp/react-native-tmp + - name: Download ReactNativeDependencies + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependenciesRelease.xcframework.tar.gz + path: /tmp/third-party + - name: Download React Native Prebuilds + uses: actions/download-artifact@v7 + with: + pattern: ReactCore*.xcframework.tar.gz + path: /tmp/ReactCore + merge-multiple: true + - 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: Create the template app + shell: bash + run: | + REACT_NATIVE_PKG=$(find /tmp/react-native-tmp -type f -name "*.tgz") + + # For stable branches, we want to use the stable branch of the template + # In all the other cases, we want to use "main" + BRANCH=${{ github.ref_name }} + if ! [[ $BRANCH == *-stable* ]]; then + BRANCH=main + fi + + node ./scripts/e2e/init-project-e2e.js --projectName RNTestProject --currentBranch $BRANCH --directory /tmp/RNTestProject --pathToLocalReactNative $REACT_NATIVE_PKG + + # A pod that does not depend on React-Core-prebuilt has no target + # dependency ordering it behind the [RNCore] script phase, so its + # compilation can overlap with what that phase does to + # Pods/React-Core-prebuilt. Without such a pod, this lane proves nothing. + cp -R ./scripts/e2e/fixtures/RNCoreConfigSwitchProbe /tmp/RNTestProject/ios/ + cd /tmp/RNTestProject/ios + awk "/^target /{print; print \" pod 'RNCoreConfigSwitchProbe', :path => './RNCoreConfigSwitchProbe'\"; next} {print}" Podfile > Podfile.probe + mv Podfile.probe Podfile + cat Podfile + - name: Install pods with prebuilt React Native Core + shell: bash + run: | + # RCT_TESTONLY_RNCORE_TARBALL_PATH is deliberately not set here: it + # takes the [RNCore] script phase out of React-Core-prebuilt.podspec, + # and that phase is what this lane exercises. So RNCore resolves the + # regular way, which needs both flavor tarballs reachable over HTTP + # under this commit's version — a version that is never published. + RN_VERSION=$(node -p "require('/tmp/RNTestProject/node_modules/react-native/package.json').version") + ARTIFACTS="/tmp/maven-mirror/com/facebook/react/react-native-artifacts/$RN_VERSION" + mkdir -p "$ARTIFACTS" + cp /tmp/ReactCore/ReactCoreDebug.xcframework.tar.gz "$ARTIFACTS/react-native-artifacts-$RN_VERSION-reactnative-core-debug.tar.gz" + cp /tmp/ReactCore/ReactCoreRelease.xcframework.tar.gz "$ARTIFACTS/react-native-artifacts-$RN_VERSION-reactnative-core-release.tar.gz" + + python3 ./scripts/e2e/local-maven-mirror.py /tmp/maven-mirror 8765 & + MIRROR_PID=$! + trap 'kill $MIRROR_PID || true' EXIT + for _ in $(seq 20); do + curl -sfI -o /dev/null "http://127.0.0.1:8765/com/facebook/react/react-native-artifacts/$RN_VERSION/react-native-artifacts-$RN_VERSION-reactnative-core-debug.tar.gz" && break + sleep 1 + done + + cd /tmp/RNTestProject/ios + bundle install + + export ENTERPRISE_REPOSITORY="http://127.0.0.1:8765" + export RCT_USE_PREBUILT_RNCORE=1 + export RCT_USE_LOCAL_RN_DEP="/tmp/third-party/ReactNativeDependenciesRelease.xcframework.tar.gz" + RCT_NEW_ARCH_ENABLED=1 bundle exec pod install + + # Falling back to a source build, or losing the script phase, would + # make this lane pass without testing anything — which is how #57803 + # got through CI in the first place. + test -f Pods/React-Core-prebuilt/Headers/module.modulemap || + { echo "::error::React-Core-prebuilt was not installed from the prebuilt artifacts"; exit 1; } + grep -q replace-rncore-version.js Pods/Pods.xcodeproj/project.pbxproj || + { echo "::error::the [RNCore] configuration switch script phase is missing"; exit 1; } + - name: Build Release on top of a Debug install + shell: bash + run: | + cd /tmp/RNTestProject/ios + + # Claim the last build was Debug, so the [RNCore] script phase swaps + # React.xcframework while the rest of the project compiles. Empty + # DerivedData makes sure the React module is precompiled during this + # build instead of being reused from an earlier one. + printf Debug > Pods/React-Core-prebuilt/.last_build_configuration + + xcodebuild \ + -scheme "RNTestProject" \ + -workspace RNTestProject.xcworkspace \ + -configuration "Release" \ + -sdk "iphonesimulator" \ + -destination "generic/platform=iOS Simulator" \ + -derivedDataPath "/tmp/RNTestProject-config-switch" diff --git a/scripts/e2e/fixtures/RNCoreConfigSwitchProbe/RNCoreConfigSwitchProbe.m b/scripts/e2e/fixtures/RNCoreConfigSwitchProbe/RNCoreConfigSwitchProbe.m new file mode 100644 index 000000000000..0879eff4fe1d --- /dev/null +++ b/scripts/e2e/fixtures/RNCoreConfigSwitchProbe/RNCoreConfigSwitchProbe.m @@ -0,0 +1,12 @@ +/* + * 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. + */ + +#import + +// The content does not matter: this file exists so the pod has a compile phase +// that Clang has to scan for module dependencies. +void RNCoreConfigSwitchProbe(void) {} diff --git a/scripts/e2e/fixtures/RNCoreConfigSwitchProbe/RNCoreConfigSwitchProbe.podspec b/scripts/e2e/fixtures/RNCoreConfigSwitchProbe/RNCoreConfigSwitchProbe.podspec new file mode 100644 index 000000000000..e878b128e833 --- /dev/null +++ b/scripts/e2e/fixtures/RNCoreConfigSwitchProbe/RNCoreConfigSwitchProbe.podspec @@ -0,0 +1,24 @@ +# 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. + +# Fixture pod for .github/workflows/test-ios-prebuilt-config-switch.yml. +# +# It declares NO dependencies on purpose. React Native activates the prebuilt +# React module map on every pod target, but only the pods that depend on +# React-Core-prebuilt are ordered behind its Debug/Release script phase. A pod +# without that edge is what lets its compilation overlap with the script phase, +# which is what regressed in #57803. Adding a dependency here would silence the +# lane instead of fixing anything. +Pod::Spec.new do |spec| + spec.name = 'RNCoreConfigSwitchProbe' + spec.version = '1.0.0' + spec.summary = 'Compiles one file, depends on nothing, and fails when the prebuilt React module map disappears mid-build.' + spec.homepage = 'https://reactnative.dev/' + spec.license = { :type => 'MIT' } + spec.author = 'Meta Platforms, Inc. and its affiliates' + spec.platform = :ios, '15.1' + spec.source = { :path => '.' } + spec.source_files = 'RNCoreConfigSwitchProbe.m' +end diff --git a/scripts/e2e/local-maven-mirror.py b/scripts/e2e/local-maven-mirror.py new file mode 100755 index 000000000000..9f8989636202 --- /dev/null +++ b/scripts/e2e/local-maven-mirror.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# 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. + +"""Serves a directory as a Maven repository and redirects misses to Maven Central. + +CocoaPods only consumes the prebuilt React Native Core artifacts when they are +reachable over HTTP (see packages/react-native/scripts/cocoapods/rncore.rb), and +a CI run's version is never published. Point ENTERPRISE_REPOSITORY at this +server to serve locally built artifacts under their published names while +Hermes and everything else keep resolving from Maven Central. + +Usage: local-maven-mirror.py +""" + +import functools +import os +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +UPSTREAM = "https://repo1.maven.org/maven2" + + +class MirrorRequestHandler(SimpleHTTPRequestHandler): + def send_head(self): + if os.path.isfile(self.translate_path(self.path)): + return super().send_head() + self.send_response(302) + self.send_header("Location", UPSTREAM + self.path) + self.end_headers() + return None + + +def main(directory, port): + handler = functools.partial(MirrorRequestHandler, directory=directory) + print(f"Mirroring {directory} on port {port}, redirecting misses to {UPSTREAM}") + ThreadingHTTPServer(("127.0.0.1", port), handler).serve_forever() + + +if __name__ == "__main__": + main(sys.argv[1], int(sys.argv[2]))