> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://tester.army/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://tester.army/_mcp/server.

# GitHub Actions

GitHub Actions is one way to run mobile tests automatically from CI. For Expo apps, we recommend using [Expo EAS](/mobile/expo-eas) instead.

You only need to provide an iOS Simulator build or Android APK and your TesterArmy credentials. TesterArmy handles the cloud simulator/emulator setup, app installation, test execution, and run orchestration for you, so you do not need to manage devices in your CI pipeline.

For Expo EAS workflows, see [Expo EAS](/mobile/expo-eas).

Before you set it up, make sure you have already:

* built and uploaded a mobile app artifact,
* created at least one mobile test in TesterArmy,
* run that test manually once,
* added the test to a group.

If you want a working reference, see the [mobile GitHub Action](https://github.com/tester-army/mobile-github-action) and the [mobile example app](https://github.com/tester-army/mobile-example).

## What you need

Add these GitHub secrets to your repository:

| Secret                  | Description                                          |
| ----------------------- | ---------------------------------------------------- |
| `TESTERARMY_API_KEY`    | API key from **Profile → API Keys**                  |
| `TESTERARMY_PROJECT_ID` | Your TesterArmy project ID from **Project Settings** |
| `TESTERARMY_GROUP_ID`   | Test group ID for the mobile tests you want to run   |

You can copy the group ID from the test group menu in the dashboard.

## Recommended workflow

The most reliable setup is:

1. Build the app artifact in a dedicated job.
2. Upload the `.app` bundle or `.apk` as a GitHub artifact.
3. Download that artifact on Linux.
4. Run `tester-army/mobile-github-action` with the matching `platform` to upload the app, run the group, wait for results, and clean up.

#### iOS

```yaml
name: iOS Mobile Tests

on:
  push:
    branches:
      - main
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build_ios:
    name: Build iOS Simulator app
    runs-on: macos-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v5

      - name: Build iOS Simulator app
        shell: bash
        run: |
          set -euo pipefail
          xcodebuild \
            -workspace ios/MyApp.xcworkspace \
            -scheme MyApp \
            -configuration Release \
            -sdk iphonesimulator \
            -destination 'generic/platform=iOS Simulator' \
            -derivedDataPath ios/build \
            build

      - name: Copy .app to artifact directory
        shell: bash
        run: |
          set -euo pipefail
          mkdir -p .build
          cp -R ios/build/Build/Products/Release-iphonesimulator/MyApp.app .build/MyApp.app

      - name: Upload .app artifact
        uses: actions/upload-artifact@v5
        with:
          name: ios-simulator-app
          path: .build/MyApp.app
          retention-days: 1

  test_ios:
    name: Run iOS TesterArmy tests
    needs: build_ios
    runs-on: ubuntu-latest

    steps:
      - name: Download .app artifact
        uses: actions/download-artifact@v5
        with:
          name: ios-simulator-app
          path: .build/MyApp.app

      - name: Upload app and run TesterArmy tests
        id: mobile
        uses: tester-army/mobile-github-action@v1
        with:
          app_path: .build/MyApp.app
          platform: ios
          api_key: ${{ secrets.TESTERARMY_API_KEY }}
          project_id: ${{ secrets.TESTERARMY_PROJECT_ID }}
          group_id: ${{ secrets.TESTERARMY_GROUP_ID }}
          delete_app_after_run: "true"
          remove_after: "3600"

      - name: Print overall status
        run: echo "Overall status: ${{ steps.mobile.outputs.overall_status }}"
```

#### Android

```yaml
name: Android Mobile Tests

on:
  push:
    branches:
      - main
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build_android:
    name: Build Android app
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v5

      - name: Set up Node.js
        uses: actions/setup-node@v5
        with:
          node-version: "24"
          cache: npm

      - name: Set up Java
        uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: "17"

      - name: Set up Android SDK
        uses: android-actions/setup-android@v3

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Install dependencies
        run: npm ci

      - name: Expo prebuild (Android)
        env:
          CI: "true"
        run: npx expo prebuild --platform android --no-install

      - name: Build Android APK
        working-directory: android
        run: ./gradlew :app:assembleRelease

      - name: Copy APK to artifact directory
        shell: bash
        run: |
          set -euo pipefail
          APK_PATH="$(find android/app/build/outputs/apk/release -type f -name '*.apk' | head -1)"
          if [[ -z "${APK_PATH:-}" || ! -f "$APK_PATH" ]]; then
            echo "Could not find a release APK under android/app/build/outputs/apk/release"
            exit 1
          fi
          mkdir -p .build
          cp "$APK_PATH" .build/MyApp.apk

      - name: Upload APK artifact
        uses: actions/upload-artifact@v5
        with:
          name: android-apk
          path: .build/MyApp.apk
          retention-days: 1

  test_android:
    name: Run Android TesterArmy tests
    needs: build_android
    runs-on: ubuntu-latest

    steps:
      - name: Download APK artifact
        uses: actions/download-artifact@v5
        with:
          name: android-apk
          path: .build

      - name: Upload app and run TesterArmy tests
        id: mobile
        uses: tester-army/mobile-github-action@v1
        with:
          app_path: .build/MyApp.apk
          platform: android
          api_key: ${{ secrets.TESTERARMY_API_KEY }}
          project_id: ${{ secrets.TESTERARMY_PROJECT_ID }}
          group_id: ${{ secrets.TESTERARMY_GROUP_ID }}
          delete_app_after_run: "true"
          remove_after: "3600"

      - name: Print overall status
        run: echo "Overall status: ${{ steps.mobile.outputs.overall_status }}"
```

**No manual upload step in CI**

The GitHub Action accepts either a `.app` directory, an archived iOS app file,
or an Android `.apk`. If you pass a `.app` directory, the action zips and
uploads it for you.

## What the action does

By default (`mode: all`) `tester-army/mobile-github-action` handles the full CI flow for you in a single job:

1. Uploads the app to TesterArmy.
2. Provisions the cloud simulator or emulator environment and installs the app for the run.
3. Starts your mobile test group and orchestrates the run lifecycle.
4. Polls until the runs finish.
5. Deletes the uploaded app after the run if cleanup is enabled.

Results are written to the GitHub step summary so you can quickly see pass/fail status, duration, issues, and screenshots.

## Action modes

The `mode` input selects what a single invocation does, so you can keep the simple one-job setup or split the work across jobs:

| Mode            | What it does                                                                                        |
| --------------- | --------------------------------------------------------------------------------------------------- |
| `all` (default) | Uploads the app and runs the dashboard group in one job.                                            |
| `upload`        | Uploads the app and exposes the `app_id` output for downstream jobs.                                |
| `test`          | Runs the dashboard group against an existing `app_id`.                                              |
| `dynamic_agent` | Runs the [dynamic PR agent](#dynamic-pr-agent) against an existing `app_id` on pull request events. |

## Run defined tests and the dynamic PR agent in parallel

The [dynamic PR agent](#dynamic-pr-agent) explores what a pull request changes instead of running a fixed group. To run it alongside your defined tests, split the flow into separate jobs: upload the app once, then run the `test` and `dynamic_agent` jobs in parallel against the uploaded `app_id`. Composite actions cannot create jobs, so the split lives in your workflow.

This example covers one platform. To test both iOS and Android, duplicate the `upload`, `defined_tests`, and `dynamic_agent` jobs for the second platform, point the upload job at the Android artifact, and set `platform: android` in its test and dynamic agent jobs.

```yaml
jobs:
  upload:
    runs-on: ubuntu-latest
    outputs:
      app_id: ${{ steps.upload.outputs.app_id }}
    steps:
      - uses: actions/download-artifact@v5
        with:
          name: ios-simulator-app
          path: .build/MyApp.app

      - uses: tester-army/mobile-github-action@v1
        id: upload
        with:
          mode: upload
          app_path: .build/MyApp.app
          api_key: ${{ secrets.TESTERARMY_API_KEY }}
          project_id: ${{ secrets.TESTERARMY_PROJECT_ID }}
          remove_after: "86400"

  defined_tests:
    runs-on: ubuntu-latest
    needs: upload
    steps:
      - uses: tester-army/mobile-github-action@v1
        id: defined
        with:
          mode: test
          app_id: ${{ needs.upload.outputs.app_id }}
          platform: ios
          api_key: ${{ secrets.TESTERARMY_API_KEY }}
          project_id: ${{ secrets.TESTERARMY_PROJECT_ID }}
          group_id: ${{ secrets.TESTERARMY_GROUP_ID }}

      - run: echo "Defined tests: ${{ steps.defined.outputs.overall_status }}"

  dynamic_agent:
    runs-on: ubuntu-latest
    needs: upload
    if: ${{ github.event_name == 'pull_request' }}
    steps:
      - uses: tester-army/mobile-github-action@v1
        id: dynamic
        with:
          mode: dynamic_agent
          app_id: ${{ needs.upload.outputs.app_id }}
          platform: ios
          api_key: ${{ secrets.TESTERARMY_API_KEY }}
          project_id: ${{ secrets.TESTERARMY_PROJECT_ID }}

      - run: echo "Dynamic agent: ${{ steps.dynamic.outputs.dynamic_agent_status }}"
```

The upload job sets `remove_after` so TesterArmy removes the shared app on its own once both jobs are done.

The `dynamic_agent` job only runs on pull requests, so gate it with `if: ${{ github.event_name == 'pull_request' }}`. To add an on/off switch, you can also check a repository variable such as `vars.TESTERARMY_DYNAMIC_AGENT_ENABLED == 'true'`.

## Direct CLI usage

For CI providers such as Expo EAS, [Bitrise](/mobile/bitrise), Fastlane,
Buildkite, or GitLab CI, upload the app with `testerarmy upload-app`, then use
the returned app ID in the provider-agnostic CLI flow:

```bash
testerarmy ci \
  --group "$GROUP_ID" \
  --project "$PROJECT_ID" \
  --platform ios \
  --app-id "$APP_ID" \
  --commit-sha "$COMMIT_SHA" \
  --pr-number "$PR_NUMBER"
```

Use `--platform android` for Android app runs. `ci` calls
`POST /api/v1/groups/{groupId}/runs`, waits for every returned run to finish,
and deletes only the explicit `--app-id` when cleanup is enabled and the run did
not time out.

## Dynamic PR agent

The dynamic PR agent explores what a pull request changes against a mobile build instead of running a predefined test group. In the GitHub Action, run it with `mode: dynamic_agent` (see [Run defined tests and the dynamic PR agent in parallel](#run-defined-tests-and-the-dynamic-pr-agent-in-parallel)). On other CI providers, call the CLI directly with `testerarmy pr run-dynamic`:

```bash
testerarmy pr run-dynamic \
  --project "$PROJECT_ID" \
  --platform android \
  --app-id "$TESTERARMY_APP_ID" \
  --pr-number "$PR_NUMBER" \
  --pr-title "$PR_TITLE" \
  --commit-sha "$GITHUB_SHA"
```

Use `--platform ios` with an iOS Simulator `.app` build or `--platform android` with an Android `.apk`. You can use `--app-id`, `--bundle-id`, or `--artifact-url` with `--artifact-filename`, and pass optional PR context with `--pr-description`, `--base-branch`, and `--head-branch`. The command waits for the run to finish and uses the same result output as group CI runs.

If one binary serves many PRs and your app supports a configuration deep link (for example an Expo update-channel or backend-selection link), pass it with `--deeplink` (or `deeplink` on the API). TesterArmy opens the link on the device after launching the app and before testing starts, so the per-PR configuration is applied deterministically — the agent is told the setup is already done and must not re-run it. The link is visible in run artifacts and PR comments, so use per-PR parameters (channel names, update IDs, backend hosts) rather than long-lived secrets.

If your project has a supported GitHub connection, TesterArmy looks at what a pull request actually changes before running. If nothing in the change is relevant to the selected platform, TesterArmy can skip the run instead of spinning up a simulator or emulator. A skipped run is neutral — it's reported as cancelled with a short reason explaining why, not as a failed test — so it never blocks your PR. When a change can't be clearly assessed, TesterArmy runs the tests rather than skipping.

If you do not want to use the CLI, call the pull request run API directly with `platform: "ios"` or `"android"` and a mobile app selector:

```bash
curl -X POST "https://tester.army/api/v1/projects/$PROJECT_ID/pull-request-runs" \
  -H "Authorization: Bearer $TESTERARMY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "android",
    "pullRequest": {
      "number": 123,
      "title": "Improve onboarding",
      "description": "PR body or CI summary",
      "commitSha": "'$GITHUB_SHA'"
    },
    "mobile": {
      "appId": "'$TESTERARMY_APP_ID'"
    }
  }'
```

The endpoint does not require changed files; if a supported GitHub connection exists, TesterArmy enriches the plan server-side and may skip execution for irrelevant changes as described above.

## Important options

| Input                  | Required                       | Description                                                                                                    |
| ---------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `app_path`             | For `all`, `upload`            | Path to the iOS Simulator `.app`/archive or Android `.apk` to upload                                           |
| `api_key`              | Yes                            | TesterArmy API key                                                                                             |
| `project_id`           | Yes                            | TesterArmy project ID                                                                                          |
| `group_id`             | For `all`, `test`              | TesterArmy test group ID                                                                                       |
| `app_id`               | For `test`, PR `dynamic_agent` | Existing TesterArmy uploaded app ID to reuse                                                                   |
| `mode`                 | No                             | Action mode: `all`, `upload`, `test`, or `dynamic_agent`. Defaults to `all`                                    |
| `platform`             | No                             | Mobile runtime platform: `ios` or `android`. Defaults to `ios`                                                 |
| `delete_app_after_run` | No                             | Delete the uploaded app after terminal runs, only when the same invocation owns the upload. Defaults to `true` |
| `remove_after`         | No                             | Seconds before TesterArmy auto-removes the upload. `0` disables it. Defaults to `3600`                         |

## Outputs

The action exposes these outputs:

* `app_id` — the uploaded or supplied TesterArmy app ID
* `overall_status` — `passed`, `failed`, or `timed_out`
* `dynamic_agent_status` — `passed`, `failed`, `timed_out`, or `skipped`

You can use `overall_status` or `dynamic_agent_status` in later workflow steps if you want custom reporting or notifications.

## Troubleshooting

### The app upload fails immediately

Make sure you are passing an iOS Simulator `.app` / `.app.zip` or an Android `.apk`. An `.ipa`, `.aab`, or `.xapk` will not work.

### The action cannot find your build

Double-check the app path inside the build job. The most common iOS output path is:

```bash
ios/build/Build/Products/Release-iphonesimulator/MyApp.app
```

For Android release `.apk` files, the output is usually under:

```bash
android/app/build/outputs/apk/release/
```

### The workflow starts but no tests run

Make sure your `TESTERARMY_GROUP_ID` points to a group that already contains mobile tests.

## Related docs

* [App Uploads](/mobile/app-uploads)
* [Expo EAS](/mobile/expo-eas)
* [Bitrise](/mobile/bitrise)
* [API reference](/api-reference/tester-army-api/projects/upload-a-project-mobile-app)