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

# Bitrise

Bitrise can build your mobile app and then hand the artifact to TesterArmy to run a saved test group. Bitrise owns the build; TesterArmy owns the cloud simulator/emulator, app installation, test execution, and run orchestration, so you do not need to manage devices or emulators in your Bitrise Workflow.

There is no dedicated TesterArmy Bitrise Step. You drive the flow from a Script Step with the [TesterArmy CLI](/cli), which keeps the integration a plain two-command sequence:

1. `upload-app` uploads the build and returns an app ID.
2. `ci` starts the group, waits for every run to finish, and exits non-zero when something failed.

## Prerequisites

Before you add the Workflow, make sure you have already:

* created a mobile project in TesterArmy,
* created at least one mobile test and run it manually so you know it passes,
* added that test to a [test group](/run/groups),
* confirmed your Bitrise build produces a supported artifact (see [Artifact requirements](#artifact-requirements)).

## Add the secrets

TesterArmy needs three values. Add `TESTERARMY_API_KEY` on the **Secrets** tab of the Workflow Editor so it is masked and not exposed to pull requests from forks. The project and group IDs are not sensitive, so you can keep them in `app.envs` in `bitrise.yml`.

| Value                   | Where to put it | Where to find it                                         |
| ----------------------- | --------------- | -------------------------------------------------------- |
| `TESTERARMY_API_KEY`    | Bitrise Secrets | **Profile → [API Keys](/auth/api-keys)**                 |
| `TESTERARMY_PROJECT_ID` | `app.envs`      | **Project Settings**                                     |
| `TESTERARMY_GROUP_ID`   | `app.envs`      | **Tests** tab → the group's three-dot menu → **Copy ID** |

Do not tick **Expose for Pull Requests** on `TESTERARMY_API_KEY` unless you only build pull
requests from branches in your own repository. Bitrise exposes protected secrets to fork builds
when that option is enabled.

The CLI reads `TESTERARMY_API_KEY` from the environment automatically. The project, group, and app IDs have no environment-variable fallback, so they must be passed as flags.

## Artifact requirements

TesterArmy runs your app on a cloud simulator or emulator, which constrains what Bitrise needs to produce:

| Platform | Required artifact                    | Bitrise Step                                                | Path env var            |
| -------- | ------------------------------------ | ----------------------------------------------------------- | ----------------------- |
| iOS      | iOS Simulator `.app` (or `.app.zip`) | **Xcode build for simulator** (`xcode-build-for-simulator`) | `$BITRISE_APP_DIR_PATH` |
| Android  | `.apk` (or `.apks`)                  | **Android Build** (`android-build`) with `build_type: apk`  | `$BITRISE_APK_PATH`     |

An `.ipa` will not work. `xcode-archive` produces a device build; you need
`xcode-build-for-simulator`. On Android, an `.aab` will not work either — set `build_type: apk`.

The CLI accepts the raw `.app` directory and zips it for you, so you can pass `$BITRISE_APP_DIR_PATH` directly.

## Run a group after the build

The simplest setup is one Workflow that builds, uploads, and runs the group. Because both CLI commands run in the same Script Step, the app ID stays a local shell variable and you do not need `envman`.

#### iOS

```yaml bitrise.yml
format_version: "17"
default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git
project_type: ios

app:
  envs:
    - BITRISE_PROJECT_PATH: ios/MyApp.xcworkspace
    - BITRISE_SCHEME: MyApp
    - TESTERARMY_PROJECT_ID: <projectId>
    - TESTERARMY_GROUP_ID: <groupId>

tools:
  nodejs: "22:installed"

meta:
  bitrise.io:
    stack: osx-xcode-16.0.x
    machine_type_id: g2.mac.medium

workflows:
  testerarmy_ios:
    steps:
      - activate-ssh-key@4: {}
      - git-clone@8: {}
      - xcode-build-for-simulator@0:
          inputs:
            - configuration: Release
      - script@1:
          title: Run TesterArmy mobile tests
          inputs:
            - content: |-
                #!/usr/bin/env bash
                set -euo pipefail

                mkdir -p .testerarmy

                npx --yes testerarmy@latest upload-app \
                  --app-path "$BITRISE_APP_DIR_PATH" \
                  --project "$TESTERARMY_PROJECT_ID" \
                  --remove-after 3600 \
                  --output .testerarmy/upload.json

                APP_ID="$(node -e 'const fs=require("fs");process.stdout.write(JSON.parse(fs.readFileSync(".testerarmy/upload.json","utf8")).uploadedAppId)')"
                echo "Uploaded app: $APP_ID"

                args=(
                  ci
                  --group "$TESTERARMY_GROUP_ID"
                  --project "$TESTERARMY_PROJECT_ID"
                  --platform ios
                  --app-id "$APP_ID"
                  --commit-sha "$BITRISE_GIT_COMMIT"
                  --delete-app-after-run
                  --output .testerarmy/ci-result.json
                )

                if [ -n "${BITRISE_PULL_REQUEST:-}" ]; then
                  args+=(--pr-number "$BITRISE_PULL_REQUEST")
                fi

                npx --yes testerarmy@latest "${args[@]}"
```

#### Android

```yaml bitrise.yml
format_version: "17"
default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git
project_type: android

app:
  envs:
    - PROJECT_LOCATION: android
    - MODULE: app
    - VARIANT: release
    - TESTERARMY_PROJECT_ID: <projectId>
    - TESTERARMY_GROUP_ID: <groupId>

tools:
  nodejs: "22:installed"

meta:
  bitrise.io:
    stack: linux-docker-android-22.04
    machine_type_id: elite

workflows:
  testerarmy_android:
    steps:
      - activate-ssh-key@4: {}
      - git-clone@8: {}
      - android-build@1:
          inputs:
            - project_location: $PROJECT_LOCATION
            - module: $MODULE
            - variant: $VARIANT
            - build_type: apk
      - script@1:
          title: Run TesterArmy mobile tests
          inputs:
            - content: |-
                #!/usr/bin/env bash
                set -euo pipefail

                mkdir -p .testerarmy

                npx --yes testerarmy@latest upload-app \
                  --app-path "$BITRISE_APK_PATH" \
                  --project "$TESTERARMY_PROJECT_ID" \
                  --remove-after 3600 \
                  --output .testerarmy/upload.json

                APP_ID="$(node -e 'const fs=require("fs");process.stdout.write(JSON.parse(fs.readFileSync(".testerarmy/upload.json","utf8")).uploadedAppId)')"
                echo "Uploaded app: $APP_ID"

                args=(
                  ci
                  --group "$TESTERARMY_GROUP_ID"
                  --project "$TESTERARMY_PROJECT_ID"
                  --platform android
                  --app-id "$APP_ID"
                  --commit-sha "$BITRISE_GIT_COMMIT"
                  --delete-app-after-run
                  --output .testerarmy/ci-result.json
                )

                if [ -n "${BITRISE_PULL_REQUEST:-}" ]; then
                  args+=(--pr-number "$BITRISE_PULL_REQUEST")
                fi

                npx --yes testerarmy@latest "${args[@]}"
```

The `tools: nodejs` entry makes Node available in `$PATH` before the first Step, which is what
`npx` needs. If your repository already pins Node through a `.nvmrc`, `.node-version`, or
`.tool-versions` file, you can use the **Dependency installer** Step instead.

### Signing an Android release build

If your Android `release` variant requires signing, add `sign-apk@1` after `android-build@1` and upload `$BITRISE_SIGNED_APK_PATH` instead of `$BITRISE_APK_PATH`. TesterArmy also accepts a debug APK, so building `variant: debug` is a valid way to avoid signing config in the test Workflow.

Prefer release builds when the app runs without a Metro dev server. See [Dev Builds and
Metro](/mobile/dev-builds) for the React Native caveats.

## Build once, test both platforms

To test iOS and Android from a single trigger, split the work into a Bitrise Pipeline. The build Workflows run on the stack they need, share the artifact as a Pipeline intermediate file, and the test Workflows run on cheaper Linux machines because the CLI only needs Node.

```yaml bitrise.yml
pipelines:
  testerarmy_mobile:
    workflows:
      build_ios: {}
      build_android: {}
      test_ios:
        depends_on: [build_ios]
      test_android:
        depends_on: [build_android]

workflows:
  build_ios:
    meta:
      bitrise.io:
        stack: osx-xcode-16.0.x
        machine_type_id: g2.mac.medium
    steps:
      - git-clone@8: {}
      - xcode-build-for-simulator@0:
          inputs:
            - configuration: Release
      - deploy-to-bitrise-io@2:
          inputs:
            - pipeline_intermediate_files: "$BITRISE_APP_DIR_PATH:BITRISE_APP_DIR_PATH"

  build_android:
    steps:
      - git-clone@8: {}
      - android-build@1:
          inputs:
            - build_type: apk
      - deploy-to-bitrise-io@2:
          inputs:
            - pipeline_intermediate_files: "$BITRISE_APK_PATH:BITRISE_APK_PATH"

  test_ios:
    steps:
      - pull-intermediate-files@1:
          inputs:
            - artifact_sources: build_ios
      - script@1:
          title: Run TesterArmy iOS tests
          inputs:
            - content: |-
                #!/usr/bin/env bash
                set -euo pipefail
                mkdir -p .testerarmy

                npx --yes testerarmy@latest upload-app \
                  --app-path "$BITRISE_APP_DIR_PATH" \
                  --project "$TESTERARMY_PROJECT_ID" \
                  --remove-after 3600 \
                  --output .testerarmy/upload.json

                APP_ID="$(node -e 'const fs=require("fs");process.stdout.write(JSON.parse(fs.readFileSync(".testerarmy/upload.json","utf8")).uploadedAppId)')"

                npx --yes testerarmy@latest ci \
                  --group "$TESTERARMY_GROUP_ID" \
                  --project "$TESTERARMY_PROJECT_ID" \
                  --platform ios \
                  --app-id "$APP_ID" \
                  --commit-sha "$BITRISE_GIT_COMMIT" \
                  --delete-app-after-run \
                  --output .testerarmy/ci-result.json

  test_android:
    steps:
      - pull-intermediate-files@1:
          inputs:
            - artifact_sources: build_android
      - script@1:
          title: Run TesterArmy Android tests
          inputs:
            - content: |-
                #!/usr/bin/env bash
                set -euo pipefail
                mkdir -p .testerarmy

                npx --yes testerarmy@latest upload-app \
                  --app-path "$BITRISE_APK_PATH" \
                  --project "$TESTERARMY_PROJECT_ID" \
                  --remove-after 3600 \
                  --output .testerarmy/upload.json

                APP_ID="$(node -e 'const fs=require("fs");process.stdout.write(JSON.parse(fs.readFileSync(".testerarmy/upload.json","utf8")).uploadedAppId)')"

                npx --yes testerarmy@latest ci \
                  --group "$TESTERARMY_GROUP_ID" \
                  --project "$TESTERARMY_PROJECT_ID" \
                  --platform android \
                  --app-id "$APP_ID" \
                  --commit-sha "$BITRISE_GIT_COMMIT" \
                  --delete-app-after-run \
                  --output .testerarmy/ci-result.json
```

`deploy-to-bitrise-io` shares each artifact under an env-var key, and `pull-intermediate-files` restores that same env var in the downstream Workflow, so the upload command is identical to the single-Workflow version. Directories such as the iOS `.app` bundle are archived and re-extracted for you.

If you split upload and test across two Workflows instead, pass the app ID between Steps with
`envman add --key TESTERARMY_APP_ID --value "$APP_ID"`. Do not use `--delete-app-after-run` in
that case unless the deleting Workflow is the last consumer of the app — rely on `--remove-after`
instead.

## Command reference

### `upload-app`

| Flag                       | Required | Notes                                                                  |
| -------------------------- | -------- | ---------------------------------------------------------------------- |
| `--app-path <path>`        | Yes      | `.app`, `.app.zip`, `.zip`, `.apk`, or `.apks`                         |
| `--project <projectId>`    | Yes      | No environment-variable fallback                                       |
| `--remove-after <seconds>` | No       | Auto-delete after N seconds. Minimum `60`, maximum `604800` (7 days)   |
| `--output <path>`          | No       | Writes the result JSON. A directory writes `ta-app-upload-<date>.json` |
| `--json`                   | No       | Also prints the result JSON to stdout                                  |

The written JSON contains `app` and `uploadedAppId`:

```json
{
  "app": { "id": "app_123", "platform": "ios", "filename": "MyApp.app.zip" },
  "uploadedAppId": "app_123"
}
```

### `ci`

| Flag                          | Required | Notes                                                                  |
| ----------------------------- | -------- | ---------------------------------------------------------------------- |
| `--group <groupId>`           | Yes      | The group whose tests should run                                       |
| `--project <projectId>`       | No       | Required when using `--delete-app-after-run`                           |
| `--platform <platform>`       | No       | `web`, `ios`, or `android`. Set it explicitly for mobile runs          |
| `--app-id <appId>`            | No       | The `uploadedAppId` from `upload-app`                                  |
| `--commit-sha <sha>`          | No       | Pass `$BITRISE_GIT_COMMIT` to enable GitHub check reporting            |
| `--pr-number <number>`        | No       | Pass `$BITRISE_PULL_REQUEST` on pull request builds                    |
| `--delete-app-after-run`      | No       | Requires both `--app-id` and `--project`. Skipped if the run timed out |
| `--timeout <ms>`              | No       | Defaults to `1800000` (30 minutes)                                     |
| `--poll-interval-seconds <s>` | No       | Defaults to `10`                                                       |
| `--output <path>`             | No       | Writes the result JSON envelope                                        |

`ci` always polls until every run reaches a terminal state or the timeout elapses; there is no separate wait flag.

### Exit codes

| Exit code | Meaning                                                                |
| --------- | ---------------------------------------------------------------------- |
| `0`       | Every run passed                                                       |
| `1`       | At least one run failed or was cancelled, or the wait timed out        |
| `2`       | The command itself errored — missing API key, invalid flags, bad input |

Bitrise fails the Step, and therefore the build, on any non-zero exit code.

## Reporting results back to your Git provider

TesterArmy reports through **GitHub**, not through Bitrise. Passing `--commit-sha` and `--pr-number` only produces a GitHub check and PR comment when the TesterArmy project is connected to the GitHub repository containing that commit — configure it on the project's **Integrations** tab. See [Connect GitHub](/integrations/github).

For repositories hosted on GitLab or Bitbucket, the Bitrise build status is the gate and full evidence lives in the TesterArmy dashboard. TesterArmy does not post GitLab merge request or Bitbucket pull request statuses.

## Keeping storage under control

Each project has a 2 GB storage limit, and CI uploads accumulate quickly. Use both mechanisms together:

* `--remove-after 3600` on `upload-app` is the safety net. TesterArmy removes the upload even if the build is aborted or the Step never reaches cleanup.
* `--delete-app-after-run` on `ci` deletes the app as soon as the runs finish. It is skipped when the run timed out or the runs are not terminal, so the app stays available for debugging.

See [App Uploads](/mobile/app-uploads) for the storage limits and auto-delete behavior.

## Troubleshooting

### `Missing API key. Run testerarmy auth first or set TESTERARMY_API_KEY.`

The Step exits with code `2`. The secret is either not defined or not exposed to this Workflow. Check the **Secrets** tab, and remember that secrets are not available to builds triggered by pull requests from forks unless you explicitly expose them.

### `npx: command not found`

Node is not on `$PATH`. Add the `tools: nodejs` entry shown above, or add a `dependency-installer@1` Step before the Script Step.

### The upload is rejected as an unsupported format

You are passing an `.ipa`, `.aab`, or `.xapk`. Switch the iOS build to `xcode-build-for-simulator`, or set `build_type: apk` on `android-build`.

### `$BITRISE_APP_DIR_PATH` is empty

The Script Step runs before the build Step, or the build Step failed without failing the build. Confirm `xcode-build-for-simulator` appears earlier in the Workflow. In a Pipeline, confirm `pull-intermediate-files` lists the correct `artifact_sources` and that the build Workflow shared the path with `deploy-to-bitrise-io`.

### The build passes but no tests ran

`--group` points at a group with no mobile tests in it. Groups containing only web tests produce an empty run set.

### The Step fails with `--delete-app-after-run` but the tests passed

`--delete-app-after-run` requires both `--app-id` and `--project`. If the delete request itself fails, the CLI rethrows and exits `2` even though the runs passed. Drop the flag and rely on `--remove-after` if your API key lacks delete permission.

## Related docs

* [App Uploads](/mobile/app-uploads)
* [Mobile Testing Overview](/mobile/overview)
* [CLI Reference](/cli)
* [API Keys](/auth/api-keys)
* [Organize Tests into Groups](/run/groups)