From 75c54e78ea04b6387bf6e8a0d00d31a38f753dd0 Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Fri, 16 Oct 2026 11:31:28 +0200 Subject: [PATCH 1/2] Run the iOS Maestro E2E tests on Linux against remote simulators The two iOS E2E test jobs move from macos-26-large to ubuntu-latest and drive a simulator on a remote macOS host instead of a local one. App builds stay on macOS. The Maestro action installs the sim-remote CLI, takes a machine for the job (released when the job ends, also on failure or cancellation) and puts sim-remote's stand-ins for xcrun, xcodebuild, plutil and friends on PATH, so the stock Maestro CLI runs unchanged. The runner script tunnels Metro's port to the remote simulator in Debug and skips foregrounding a simulator that is not local. The job authenticates with the OIDC id token GitHub mints for it, so no secret is needed; the workflows grant `id-token: write` for that. RNTester runs on iPhone 18 Pro / iOS 27.0, the simulators the remote hosts provide. --- .github/actions/maestro-ios/action.yml | 14 +++++++++----- .github/workflow-scripts/maestro-ios.js | 20 ++++++++++++++++++++ .github/workflows/e2e-ios-rntester.yml | 7 ++++--- .github/workflows/e2e-ios-templateapp.yml | 4 +++- .github/workflows/test-all.yml | 1 + 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/actions/maestro-ios/action.yml b/.github/actions/maestro-ios/action.yml index b5e4036d6280..956d7241c3f0 100644 --- a/.github/actions/maestro-ios/action.yml +++ b/.github/actions/maestro-ios/action.yml @@ -33,11 +33,13 @@ runs: - name: Installing Maestro shell: bash run: export MAESTRO_VERSION=2.6.1; curl -Ls "https://get.maestro.mobile.dev" | bash - - name: Installing Maestro dependencies - shell: bash - run: | - brew tap facebook/fb - brew install facebook/fb/idb-companion + - name: Install sim-remote + uses: software-mansion-labs/argent-cloud-actions/install@v2 + - name: Acquire remote simulator + # Logs in with the job's OIDC id token; released in the post step. + uses: software-mansion-labs/argent-cloud-actions/acquire@v2 + with: + label: react-native-e2e - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -57,6 +59,8 @@ runs: cd ${{ inputs.working-directory }} yarn start & sleep 5 # to give metro time to load + - name: Install Maestro shims + uses: software-mansion-labs/argent-cloud-actions/maestro-shims@v2 - name: Run tests id: run-tests shell: bash diff --git a/.github/workflow-scripts/maestro-ios.js b/.github/workflow-scripts/maestro-ios.js index a9782ec6506f..2249081d3912 100644 --- a/.github/workflow-scripts/maestro-ios.js +++ b/.github/workflow-scripts/maestro-ios.js @@ -81,7 +81,24 @@ function installAppOnSimulator(appPath, udid) { childProcess.execSync(`xcrun simctl install "${udid}" "${appPath}"`); } +// A remote simulator's localhost is the remote host, so Metro needs a tunnel. +function exposeMetroToSimulator(udid) { + const simRemote = process.env.SIM_REMOTE_BIN; + if (!simRemote) { + return; + } + + console.log('Exposing local Metro port 8081 to the remote simulator'); + childProcess.execSync(`"${simRemote}" reverse start "${udid}" 8081`, { + stdio: 'inherit', + }); +} + function bringSimulatorInForeground() { + // Nothing to foreground on a remote simulator. + if (process.env.SIM_REMOTE_BIN) { + return; + } console.log('Bringing simulator in foreground'); childProcess.execSync('open -a simulator'); } @@ -266,6 +283,9 @@ async function main(args = process.argv.slice(2)) { launchSimulator(simulator); installAppOnSimulator(appPath, simulator.udid); bringSimulatorInForeground(); + if (isDebug) { + exposeMetroToSimulator(simulator.udid); + } await launchAppOnSimulator(appId, simulator.udid, isDebug); await executeFlows(appId, simulator.udid, maestroFlow, jsengine); console.log('Test finished'); diff --git a/.github/workflows/e2e-ios-rntester.yml b/.github/workflows/e2e-ios-rntester.yml index 0660257487bf..486e243356f3 100644 --- a/.github/workflows/e2e-ios-rntester.yml +++ b/.github/workflows/e2e-ios-rntester.yml @@ -2,6 +2,7 @@ name: E2E iOS RNTester permissions: contents: read + id-token: write on: workflow_call: @@ -16,7 +17,7 @@ on: jobs: test: - runs-on: macos-26-large + runs-on: ubuntu-latest outputs: status: ${{ steps.report-status.outputs.status }} strategy: @@ -44,8 +45,8 @@ jobs: app-id: com.meta.RNTester.localDevelopment maestro-flow: ./packages/rn-tester/.maestro/ flavor: ${{ matrix.flavor }} - device-model: iPhone-17-Pro - device-os: iOS-26-2 + device-model: iPhone-18-Pro + device-os: iOS-27-0 - name: Report status id: report-status if: ${{ always() && steps.run-tests.outcome == 'failure' }} diff --git a/.github/workflows/e2e-ios-templateapp.yml b/.github/workflows/e2e-ios-templateapp.yml index 780d880a1749..a3d9689ac82e 100644 --- a/.github/workflows/e2e-ios-templateapp.yml +++ b/.github/workflows/e2e-ios-templateapp.yml @@ -2,6 +2,7 @@ name: E2E iOS Template App permissions: contents: read + id-token: write on: workflow_call: @@ -15,6 +16,7 @@ on: value: ${{ jobs.report.outputs.status }} jobs: + # Building needs Xcode; the tests drive a remote simulator from Linux. build: runs-on: macos-26-large strategy: @@ -99,7 +101,7 @@ jobs: test: needs: build - runs-on: macos-26-large + runs-on: ubuntu-latest outputs: status: ${{ steps.report-status.outputs.status }} strategy: diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index fe4f2a56633b..4a2be786028f 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -10,6 +10,7 @@ on: permissions: contents: read + id-token: write jobs: set_release_type: From 9fd2d327fd729f393ed411f0310a83e5e6d1cf6e Mon Sep 17 00:00:00 2001 From: Juliusz Wajgelt Date: Mon, 19 Oct 2026 10:39:55 +0200 Subject: [PATCH 2/2] Run the iOS E2E suites on request from a maintainer's comment A pull_request run from a fork gets no id token, so the E2E test jobs no longer run on that event. Instead, a maintainer comments `@react-native-bot e2e-tests` on the pull request and e2e-ios-on-demand.yml runs the suites from the default branch: the apps come from the artifacts a run of the pull request's head already built, the Maestro flows from the pull request, the workflows and scripts from the default branch. The two reusable E2E workflows take the run to download from and the ref to take the flows from as inputs, and the download-artifact action can read another run. The login label is derived from the calling workflow as `.`, which is how each repository's workflows are registered on the remote simulator service. --- .github/actions/download-artifact/action.yml | 14 +++ .github/actions/maestro-ios/action.yml | 5 +- .github/workflows/e2e-ios-on-demand.yml | 119 +++++++++++++++++++ .github/workflows/e2e-ios-rntester.yml | 38 +++++- .github/workflows/e2e-ios-templateapp.yml | 44 ++++++- .github/workflows/test-all.yml | 11 ++ 6 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/e2e-ios-on-demand.yml diff --git a/.github/actions/download-artifact/action.yml b/.github/actions/download-artifact/action.yml index 0edcd020fa67..d9323aea080c 100644 --- a/.github/actions/download-artifact/action.yml +++ b/.github/actions/download-artifact/action.yml @@ -8,6 +8,14 @@ inputs: path: description: Destination path required: true + run-id: + description: Workflow run to download from; defaults to the current run + required: false + default: '' + github-token: + description: Token with actions:read, needed when run-id is another run + required: false + default: '' outputs: download-path: @@ -25,6 +33,8 @@ runs: with: name: ${{ inputs.name }} path: ${{ inputs.path }} + run-id: ${{ inputs.run-id || github.run_id }} + github-token: ${{ inputs.github-token }} - name: Wait before attempt 2 if: ${{ always() && steps.download-1.outcome == 'failure' }} @@ -41,6 +51,8 @@ runs: with: name: ${{ inputs.name }} path: ${{ inputs.path }} + run-id: ${{ inputs.run-id || github.run_id }} + github-token: ${{ inputs.github-token }} - name: Wait before attempt 3 if: ${{ always() && steps.download-2.outcome == 'failure' }} @@ -57,3 +69,5 @@ runs: with: name: ${{ inputs.name }} path: ${{ inputs.path }} + run-id: ${{ inputs.run-id || github.run_id }} + github-token: ${{ inputs.github-token }} diff --git a/.github/actions/maestro-ios/action.yml b/.github/actions/maestro-ios/action.yml index 956d7241c3f0..1259868963a3 100644 --- a/.github/actions/maestro-ios/action.yml +++ b/.github/actions/maestro-ios/action.yml @@ -26,6 +26,9 @@ inputs: required: false default: '' description: Maestro device OS name, such as iOS-26-2 + argent-label: + required: true + description: Label the calling workflow is registered under on Argent Cloud; the job logs in as it runs: using: composite @@ -39,7 +42,7 @@ runs: # Logs in with the job's OIDC id token; released in the post step. uses: software-mansion-labs/argent-cloud-actions/acquire@v2 with: - label: react-native-e2e + label: ${{ inputs.argent-label }} - name: Set up JDK 17 uses: actions/setup-java@v5 with: diff --git a/.github/workflows/e2e-ios-on-demand.yml b/.github/workflows/e2e-ios-on-demand.yml new file mode 100644 index 000000000000..80b9123cdbc8 --- /dev/null +++ b/.github/workflows/e2e-ios-on-demand.yml @@ -0,0 +1,119 @@ +name: E2E iOS on demand + +# Runs the iOS Maestro E2E suites against a pull request when a maintainer +# comments `@react-native-bot e2e-tests` on it. The apps come from the +# artifacts a run of the pull request's head already built, the Maestro flows +# from the pull request, and the workflows and scripts from the default branch. + +on: + issue_comment: + types: [created] + +permissions: + contents: read + +concurrency: + group: e2e-ios-on-demand-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + request: + if: >- + github.event.issue.pull_request != null && + startsWith(github.event.comment.body, '@react-native-bot e2e-tests') && + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: write + outputs: + head-sha: ${{ steps.find.outputs.head-sha }} + run-id: ${{ steps.find.outputs.run-id }} + steps: + - name: Find the pull request's build + id: find + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR: ${{ github.event.issue.number }} + run: | + set -euo pipefail + sha=$(gh api "repos/$GH_REPO/pulls/$PR" --jq .head.sha) + needed="RNTesterApp-NewArch-Debug RNTesterApp-NewArch-Release RNTestProject-Debug RNTestProject-Release react-native-package" + run_id= + for id in $(gh api "repos/$GH_REPO/actions/runs?head_sha=$sha&per_page=30" --jq '.workflow_runs[].id'); do + have=$(gh api "repos/$GH_REPO/actions/runs/$id/artifacts?per_page=100" --jq '.artifacts[].name') + missing= + for name in $needed; do + grep -qx "$name" <<<"$have" || missing="$missing $name" + done + if [ -z "$missing" ]; then + run_id=$id + break + fi + done + if [ -z "$run_id" ]; then + echo "::error::no run of $sha has built all of: $needed" + exit 1 + fi + echo "head-sha=$sha" >> "$GITHUB_OUTPUT" + echo "run-id=$run_id" >> "$GITHUB_OUTPUT" + - name: Acknowledge the request + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + COMMENT: ${{ github.event.comment.id }} + run: gh api -X POST "repos/$GH_REPO/issues/comments/$COMMENT/reactions" -f content=rocket > /dev/null + + rntester: + needs: request + uses: ./.github/workflows/e2e-ios-rntester.yml + permissions: + contents: read + actions: read + id-token: write + with: + argent-label: facebook.e2e-ios-on-demand + artifact-run-id: ${{ needs.request.outputs.run-id }} + flows-ref: ${{ needs.request.outputs.head-sha }} + fail-on-error: true + + templateapp: + needs: request + uses: ./.github/workflows/e2e-ios-templateapp.yml + permissions: + contents: read + actions: read + id-token: write + with: + argent-label: facebook.e2e-ios-on-demand + artifact-run-id: ${{ needs.request.outputs.run-id }} + flows-ref: ${{ needs.request.outputs.head-sha }} + fail-on-error: true + + report: + needs: [request, rntester, templateapp] + if: always() + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Comment the results + env: + GH_TOKEN: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN || github.token }} + GH_REPO: ${{ github.repository }} + PR: ${{ github.event.issue.number }} + REQUEST: ${{ needs.request.result }} + HEAD_SHA: ${{ needs.request.outputs.head-sha }} + RNTESTER: ${{ needs.rntester.outputs.status }} + TEMPLATEAPP: ${{ needs.templateapp.outputs.status }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if [ "$REQUEST" != "success" ]; then + body="iOS E2E: no run of the pull request's head has built the apps yet, see [the request]($RUN_URL)." + else + body="iOS E2E on ${HEAD_SHA:0:7} ([run]($RUN_URL)): RNTester **${RNTESTER:-skipped}**, template app **${TEMPLATEAPP:-skipped}**." + fi + gh api -X POST "repos/$GH_REPO/issues/$PR/comments" -f body="$body" > /dev/null diff --git a/.github/workflows/e2e-ios-rntester.yml b/.github/workflows/e2e-ios-rntester.yml index 486e243356f3..3d4e45a6fd8b 100644 --- a/.github/workflows/e2e-ios-rntester.yml +++ b/.github/workflows/e2e-ios-rntester.yml @@ -2,6 +2,7 @@ name: E2E iOS RNTester permissions: contents: read + actions: read id-token: write on: @@ -10,6 +11,18 @@ on: fail-on-error: type: boolean default: false + artifact-run-id: + description: Run to take the app artifacts from; defaults to this run + type: string + default: '' + flows-ref: + description: Ref to take the Maestro flows from; defaults to the checked-out tree + type: string + default: '' + argent-label: + description: Label the calling workflow is registered under on Argent Cloud + type: string + required: true outputs: status: description: 'The result of the E2E tests (success or failure)' @@ -17,6 +30,9 @@ on: jobs: test: + # A pull_request run has no id token to log into the remote simulators + # with; the suite runs on request from e2e-ios-on-demand.yml instead. + if: github.event_name != 'pull_request' runs-on: ubuntu-latest outputs: status: ${{ steps.report-status.outputs.status }} @@ -30,10 +46,24 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup-node - name: Download App - uses: actions/download-artifact@v7 + uses: ./.github/actions/download-artifact with: name: RNTesterApp-NewArch-${{ matrix.flavor }} path: /tmp/RNTesterBuild/RNTester.app + run-id: ${{ inputs.artifact-run-id }} + github-token: ${{ github.token }} + - name: Checkout the Maestro flows under test + if: ${{ inputs.flows-ref != '' }} + uses: actions/checkout@v6 + with: + ref: ${{ inputs.flows-ref }} + path: .flows-under-test + sparse-checkout: packages/rn-tester/.maestro + - name: Use the Maestro flows under test + if: ${{ inputs.flows-ref != '' }} + run: | + rm -rf packages/rn-tester/.maestro + mv .flows-under-test/packages/rn-tester/.maestro packages/rn-tester/.maestro - name: Check downloaded folder content run: ls -lR /tmp/RNTesterBuild - name: Run E2E Tests @@ -41,6 +71,7 @@ jobs: continue-on-error: true uses: ./.github/actions/maestro-ios with: + argent-label: ${{ inputs.argent-label }} app-path: '/tmp/RNTesterBuild/RNTester.app' app-id: com.meta.RNTester.localDevelopment maestro-flow: ./packages/rn-tester/.maestro/ @@ -60,8 +91,11 @@ jobs: status: ${{ steps.check.outputs.status }} steps: - id: check + # A skipped test job is not a failure: the tests do not run on a + # pull_request run. run: | - if [[ "${{ needs.test.result }}" != "success" || + test="${{ needs.test.result }}" + if [[ ("$test" != "success" && "$test" != "skipped") || "${{ needs.test.outputs.status }}" == "failure" ]]; then echo "status=failure" >> $GITHUB_OUTPUT else diff --git a/.github/workflows/e2e-ios-templateapp.yml b/.github/workflows/e2e-ios-templateapp.yml index a3d9689ac82e..339d3f38c47d 100644 --- a/.github/workflows/e2e-ios-templateapp.yml +++ b/.github/workflows/e2e-ios-templateapp.yml @@ -2,6 +2,7 @@ name: E2E iOS Template App permissions: contents: read + actions: read id-token: write on: @@ -10,6 +11,18 @@ on: fail-on-error: type: boolean default: false + artifact-run-id: + description: Run to take the app and package artifacts from; defaults to this run, which then builds them + type: string + default: '' + flows-ref: + description: Ref to take the Maestro flows from; defaults to the checked-out tree + type: string + default: '' + argent-label: + description: Label the calling workflow is registered under on Argent Cloud + type: string + required: true outputs: status: description: 'The result of the E2E tests (success or failure)' @@ -18,6 +31,7 @@ on: jobs: # Building needs Xcode; the tests drive a remote simulator from Linux. build: + if: inputs.artifact-run-id == '' runs-on: macos-26-large strategy: fail-fast: false @@ -101,6 +115,11 @@ jobs: test: needs: build + # A pull_request run has no id token to log into the remote simulators + # with; the suite runs on request from e2e-ios-on-demand.yml instead. + if: >- + always() && github.event_name != 'pull_request' && + (needs.build.result == 'success' || needs.build.result == 'skipped') runs-on: ubuntu-latest outputs: status: ${{ steps.report-status.outputs.status }} @@ -120,6 +139,8 @@ jobs: with: name: RNTestProject-${{ matrix.flavor }} path: /tmp/RNTestProjectBuild/RNTestProject.app + run-id: ${{ inputs.artifact-run-id }} + github-token: ${{ github.token }} - name: Check downloaded folder content run: ls -l /tmp/RNTestProjectBuild/RNTestProject.app - name: Download React Native Package @@ -128,6 +149,20 @@ jobs: with: name: react-native-package path: /tmp/react-native-tmp + run-id: ${{ inputs.artifact-run-id }} + github-token: ${{ github.token }} + - name: Checkout the Maestro flows under test + if: ${{ inputs.flows-ref != '' }} + uses: actions/checkout@v6 + with: + ref: ${{ inputs.flows-ref }} + path: .flows-under-test + sparse-checkout: scripts/e2e/.maestro + - name: Use the Maestro flows under test + if: ${{ inputs.flows-ref != '' }} + run: | + rm -rf scripts/e2e/.maestro + mv .flows-under-test/scripts/e2e/.maestro scripts/e2e/.maestro - name: Configure git if: ${{ matrix.flavor == 'Debug' }} shell: bash @@ -154,6 +189,7 @@ jobs: continue-on-error: true uses: ./.github/actions/maestro-ios with: + argent-label: ${{ inputs.argent-label }} app-path: '/tmp/RNTestProjectBuild/RNTestProject.app' app-id: org.reactjs.native.example.RNTestProject maestro-flow: ./scripts/e2e/.maestro/ @@ -172,9 +208,13 @@ jobs: status: ${{ steps.check.outputs.status }} steps: - id: check + # Skipped jobs are not failures: the build is skipped when the apps + # come from another run, the tests on a pull_request run. run: | - if [[ "${{ needs.build.result }}" != "success" || - "${{ needs.test.result }}" != "success" || + build="${{ needs.build.result }}" + test="${{ needs.test.result }}" + if [[ ("$build" != "success" && "$build" != "skipped") || + ("$test" != "success" && "$test" != "skipped") || "${{ needs.test.outputs.status }}" == "failure" ]]; then echo "status=failure" >> $GITHUB_OUTPUT else diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 4a2be786028f..6ad17030fc2f 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -10,6 +10,7 @@ on: permissions: contents: read + actions: read id-token: write jobs: @@ -194,12 +195,16 @@ jobs: needs: test_ios_rntester if: ${{ needs.test_ios_rntester.result == 'success' }} uses: ./.github/workflows/e2e-ios-rntester.yml + with: + argent-label: facebook.test-all secrets: inherit test_e2e_ios_rntester_retry_1: needs: test_e2e_ios_rntester if: ${{ always() && needs.test_e2e_ios_rntester.outputs.status == 'failure' }} uses: ./.github/workflows/e2e-ios-rntester.yml + with: + argent-label: facebook.test-all secrets: inherit test_e2e_ios_rntester_retry_2: @@ -207,6 +212,7 @@ jobs: if: ${{ always() && needs.test_e2e_ios_rntester_retry_1.outputs.status == 'failure' }} uses: ./.github/workflows/e2e-ios-rntester.yml with: + argent-label: facebook.test-all fail-on-error: true secrets: inherit @@ -214,12 +220,16 @@ jobs: needs: [build_npm_package, prebuild_apple_dependencies, check_code_changes] if: needs.check_code_changes.outputs.should_test_ios == 'true' uses: ./.github/workflows/e2e-ios-templateapp.yml + with: + argent-label: facebook.test-all secrets: inherit test_e2e_ios_templateapp_retry_1: needs: test_e2e_ios_templateapp if: ${{ always() && needs.test_e2e_ios_templateapp.outputs.status == 'failure' }} uses: ./.github/workflows/e2e-ios-templateapp.yml + with: + argent-label: facebook.test-all secrets: inherit test_e2e_ios_templateapp_retry_2: @@ -227,6 +237,7 @@ jobs: if: ${{ always() && needs.test_e2e_ios_templateapp_retry_1.outputs.status == 'failure' }} uses: ./.github/workflows/e2e-ios-templateapp.yml with: + argent-label: facebook.test-all fail-on-error: true secrets: inherit