This is the full developer documentation for Marathon
# Marathon documentation
> Cloud testing infrastructure and mobile development tools.
[Marathon CloudRun your mobile test suite on cloud infrastructure. Marathon provisions devices, distributes tests, and delivers results, targeting 15 minutes per run.Get started →](/cloud/getting-started/overview/)[EmuAndroid emulator and device companion. GPU-accelerated mirroring, network inspection, accessibility auditing, and MCP server for AI agents.Get started →](/emu/getting-started/installation/)
Running the open-source runner yourself? See the [Marathon OSS documentation](https://marathonlabs.github.io/marathon/).
# Pricing
> Understand the Marathon Cloud pay-as-you-go pricing model.
Marathon Cloud uses a transparent, pay-as-you-go pricing model based on the actual time your tests spend running on our virtual devices. There are no upfront costs or subscription fees.
## Tiers
[Section titled “Tiers”](#tiers)
| Tier | Price | Features |
| -------------- | ----------------------------- | -------------------------------------------- |
| **Starter** | Free | 50 free device hours on signup. |
| **Cloud** | $2/hr (Android) / $3/hr (iOS) | Pay-as-you-go, billed monthly. |
| **Enterprise** | Custom | Dedicated support and custom configurations. |
## Billing rates
[Section titled “Billing rates”](#billing-rates)
Marathon Cloud charges based on the platform used:
* **Android**: $2.00 per hour per virtual device.
* **iOS**: $3.00 per hour per virtual device.
Billing is calculated with second-level precision, ensuring you only pay for the resources you consume.
## How time is measured
[Section titled “How time is measured”](#how-time-is-measured)
Billing starts when the first test begins execution on a virtual device. Billing ends when the last test on that device completes. Time between batches on a device is billed.
### What is included
[Section titled “What is included”](#what-is-included)
* Actual test execution time.
* Time spent running test hooks (e.g., `setUp`, `tearDown`).
* Time spent capturing screenshots, logs, and videos during the test.
### What is excluded
[Section titled “What is excluded”](#what-is-excluded)
* Device provisioning and boot time.
* Application installation and uninstallation.
* Time spent uploading test results and artifacts to the cloud.
## Pricing example
[Section titled “Pricing example”](#pricing-example)
Consider a test suite that takes 120 minutes of total execution time. Marathon Cloud aims to finish every suite in 15 minutes.
### Android run
[Section titled “Android run”](#android-run)
1. **Calculate device count**: 120 minutes / 15 minutes = 8 devices.
2. **Calculate total device hours**: 8 devices × 0.25 hours (15 minutes) = 2 hours.
3. **Calculate total cost**: 2 hours × $2.00/hour = **$4.00**.
### iOS run
[Section titled “iOS run”](#ios-run)
1. **Calculate device count**: 120 minutes / 15 minutes = 8 devices.
2. **Calculate total device hours**: 8 devices × 0.25 hours (15 minutes) = 2 hours.
3. **Calculate total cost**: 2 hours × $3.00/hour = **$6.00**.
In these scenarios, you pay a small fee to have your entire test suite completed in 15 minutes.
## Billing and payments
[Section titled “Billing and payments”](#billing-and-payments)
### Payment methods
[Section titled “Payment methods”](#payment-methods)
Marathon Cloud accepts all major credit cards through our secure payment processor. Manage payment methods and view invoices from the user menu → **Profile** → **Go to Stripe portal** in the [Marathon Cloud console](https://cloud.marathonlabs.io/profile).
### Invoicing
[Section titled “Invoicing”](#invoicing)
Invoices are generated at the end of each billing cycle. Each invoice provides a detailed breakdown of your usage, including the number of test runs, total device hours, and the resulting charges.
## Cost optimization
[Section titled “Cost optimization”](#cost-optimization)
### Parallelization
[Section titled “Parallelization”](#parallelization)
Marathon Cloud automatically optimizes your test distribution to meet the 15-minute target. This ensures you get the fastest possible feedback without manually managing device counts.
### Flakiness reduction
[Section titled “Flakiness reduction”](#flakiness-reduction)
By using the built-in retry mechanism, Marathon Cloud prevents unnecessary costs associated with manual re-runs of flaky test suites. You only pay for the automated retries that the platform manages.
Note
Refer to the [retries](/cloud/configuration/retries/) guide to learn how Marathon Cloud handles test failures efficiently.
# SAML SSO
> Verify your email domain and configure SAML 2.0 single sign-on for your Marathon Cloud team.
Marathon Cloud supports SAML 2.0 single sign-on with any identity provider (IdP) that issues SAML assertions: Okta, Microsoft Entra ID, Google Workspace, OneLogin, and others. SSO is configured per verified email domain. Users whose email is on that domain sign in through your IdP and are provisioned into your team automatically.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* The **Owner** role in your Marathon Cloud team. The **Domains & SAML** page is only shown to owners.
* Admin access to your IdP.
* Access to DNS for the email domain.
## Verify your domain
[Section titled “Verify your domain”](#verify-your-domain)
1. Open the user menu in the top-right corner of the console and choose **Domains & SAML** (`/domains`).
2. Click **Add domain**, enter the domain (for example `example.com`), and click **Add domain**.
3. The verification page shows the DNS record to create at your registrar:
| Field | Value |
| -------------------------------- | ------------------------------------------------ |
| Type | `TXT` |
| Alias or host name | `@` |
| Destination or points to address | `MARATHON=` |
| TTL | `3600` |
4. After the record propagates, click **Verify**. If the page reports **TXT record not found**, wait for propagation and try again. Marathon Cloud also re-checks unverified domains hourly.
## Configure SAML
[Section titled “Configure SAML”](#configure-saml)
1. From the verified domain, click **Open SAML configuration**, or use **SAML configuration** on the domains list.
2. ### Create the application in your IdP
[Section titled “Create the application in your IdP”](#create-the-application-in-your-idp)
The page shows the **ACS URL** to use in your IdP:
```text
https://cloud.marathonlabs.io/api/v2/saml/acs?domain=
```
Use the same URL as the **Entity ID** / **Audience** in your IdP. Both SP-initiated and IdP-initiated sign-in are supported.
3. ### Map attributes
[Section titled “Map attributes”](#map-attributes)
Configure the assertion to include these attribute names exactly:
| Attribute | Required | Description |
| ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email` | Yes | The user’s email. Must be on the verified domain. |
| `first_name` | No | Set on the user’s first sign-in. |
| `last_name` | No | Set on the user’s first sign-in. |
| `role` | No | Team role, applied on every sign-in: `owner`, `editor`, or `reader`. The prefixed forms `marathon_owner`, `marathon_editor`, `marathon_reader` are also accepted. Any other value, or no value, leaves an existing user’s role unchanged and creates new users as `reader`. |
Microsoft Entra ID’s default claim URIs (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email`, `.../first_name`, `.../last_name`, `.../role`) are accepted as fallbacks.
4. ### Enter IdP details
[Section titled “Enter IdP details”](#enter-idp-details)
Either **Upload IdP Metadata** (the XML your IdP exports), or fill in the fields manually:
* **SAML SSO URL**: your IdP’s sign-in endpoint (HTTP-Redirect binding).
* **Entity ID**: your IdP’s issuer.
* **Public certificate**: the IdP’s X.509 signing certificate, PEM text.
5. ### Test and save
[Section titled “Test and save”](#test-and-save)
Click **Test configuration**. A **Saml configuration test successful** message means the values parse and a service-provider configuration can be built; it does not perform a sign-in. Click **Save**.
6. ### Sign in
[Section titled “Sign in”](#sign-in)
On the sign-in page choose **Use single sign-on**, enter an email on the verified domain, and click **Log in**. You are redirected to your IdP and back with a Marathon Cloud session.
## How users are provisioned
[Section titled “How users are provisioned”](#how-users-are-provisioned)
Accounts are created just-in-time on first SSO sign-in with the email, name, and role from the assertion, and added to the team that owns the verified domain. On later sign-ins only `role` is re-applied; name changes in your IdP are not synced.
Users with a matching domain can still use the SSO page even if they were originally invited with a password.
## Removing access
[Section titled “Removing access”](#removing-access)
Disable or unassign the user in your IdP. They cannot start a new session afterwards; an existing session lasts until it expires. To revoke immediately, also remove them from the team on the **Users** page.
Caution
Keep at least one owner who can sign in with email and password, so an IdP outage or misconfiguration cannot lock the team out.
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
### `SAML_DOMAIN_NOT_CONFIGURED` on sign-in
[Section titled “SAML\_DOMAIN\_NOT\_CONFIGURED on sign-in”](#saml_domain_not_configured-on-sign-in)
The domain is not verified, or one of SSO URL, Entity ID, or certificate is empty. All four must be present for SSO to activate.
### `EMAIL_NOT_PROVIDED`
[Section titled “EMAIL\_NOT\_PROVIDED”](#email_not_provided)
The assertion has no `email` attribute (or Microsoft `.../claims/email`). Check the attribute mapping in your IdP.
### Names or roles are blank
[Section titled “Names or roles are blank”](#names-or-roles-are-blank)
Attribute names are case-sensitive and use underscores: `first_name`, `last_name`, `role`. `firstName` / `givenName` style names are not read.
### Sign-in loops or signature errors
[Section titled “Sign-in loops or signature errors”](#sign-in-loops-or-signature-errors)
The certificate saved in Marathon Cloud does not match the one your IdP signs with. Re-paste the current signing certificate; rotate it before the old one expires.
# Android setup
> Configure and run Android UI tests on Marathon Cloud.
Marathon Cloud supports native Android testing frameworks and cross-platform solutions. You can run tests written in Espresso, UIAutomator, Kakao, Kaspresso, and Flutter (via Patrol).
## Quick start
[Section titled “Quick start”](#quick-start)
```bash
marathon-cloud run android \
--api-key $MARATHON_CLOUD_API_KEY \
--application app-debug.apk \
--test-application app-debug-androidTest.apk
```
## Supported frameworks
[Section titled “Supported frameworks”](#supported-frameworks)
Marathon Cloud executes standard Android instrumentation tests. The following frameworks are fully supported:
* **Espresso**: The standard framework for Android UI testing.
* **UIAutomator**: For cross-app functional UI testing.
* **Kakao/Kaspresso**: DSL wrappers around Espresso for better readability and stability.
* **Cucumber**: For behavior-driven development.
* **Flutter (Patrol)**: For testing Flutter applications with native features.
## Build your test APKs
[Section titled “Build your test APKs”](#build-your-test-apks)
You must provide two APK files: the application under test and the instrumentation test APK.
1. Open your terminal in the root of your Android project.
2. Run the Gradle assembly tasks for both the app and the test app.
```bash
./gradlew :app:assembleDebug :app:assembleDebugAndroidTest
```
3. Locate the generated APKs in the build output directory. Typically, these are found at:
* `app/build/outputs/apk/debug/app-debug.apk`
* `app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk`
## Run tests
[Section titled “Run tests”](#run-tests)
```bash
marathon-cloud run android \
--api-key YOUR_API_KEY \
--application path/to/app-debug.apk \
--test-application path/to/app-debug-androidTest.apk \
--os-version 15 \
--system-image google_apis
```
The examples below omit `--api-key`; the CLI reads it from `MARATHON_CLOUD_API_KEY` when the flag is absent.
### Device selection
[Section titled “Device selection”](#device-selection)
| Parameter | Description |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--os-version` | Android version: `8`, `8.1`, `9`, `10`, `11`, `12`, `13`, `14`, `15`, `16`, `17`. |
| `--system-image` | `default`, `google_apis`, or `google_apis_playstore`. |
| `--arch` | `amd64` (default) or `arm64`. Pick `arm64` when the app ships native libraries without an `x86_64` ABI or you want to match production hardware. |
| `--device` | Device type ID from `marathon-cloud devices android`. Use `watch` or `tv` for those form factors; omit for a phone. See [supported devices](/cloud/reference/supported-devices/). |
Not every combination is valid, and the CLI rejects invalid ones before uploading:
* Android `15`, `16`, `17` require `google_apis` or `google_apis_playstore`.
* Android `8` to `9` require `google_apis`.
* `watch` requires `google_apis` and Android `11`, `13`, or `15`; `tv` requires `google_apis` and Android `10` to `14`.
* `arm64` requires Android `9` or later. `default` images are `arm64` only; use `google_apis` or `google_apis_playstore` for `amd64`.
`--flavor` is deprecated; only native instrumentation tests are supported and the flag is ignored.
## Advanced configuration
[Section titled “Advanced configuration”](#advanced-configuration)
### Multi-module and library testing
[Section titled “Multi-module and library testing”](#multi-module-and-library-testing)
`--application-bundle` replaces `--application` + `--test-application` for multi-module projects and can be repeated to run several modules in one run. `--library-bundle` is for library modules that have a test APK but no app APK.
```bash
marathon-cloud run android \
--application-bundle app/build/outputs/apk/debug/app-debug.apk,app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--application-bundle feature/build/outputs/apk/debug/feature-debug.apk,feature/build/outputs/apk/androidTest/debug/feature-debug-androidTest.apk
```
```bash
marathon-cloud run android \
--library-bundle mylib/build/outputs/apk/androidTest/debug/mylib-debug-androidTest.apk
```
### Device features and sensors
[Section titled “Device features and sensors”](#device-features-and-sensors)
Camera emulation requires a `google_apis` or `google_apis_playstore` image. `--mock-location` is a switch and takes no value.
```bash
marathon-cloud run android \
--application app-debug.apk \
--test-application app-debug-androidTest.apk \
--os-version 15 \
--system-image google_apis \
--mock-location \
--front-camera emulated \
--back-camera virtualscene
```
### Instrumentation arguments and pulled files
[Section titled “Instrumentation arguments and pulled files”](#instrumentation-arguments-and-pulled-files)
`--instrumentation-arg` is repeatable and passed through to the instrumentation runner. `--pull-files` copies files off the device after each batch into `device-files.tar` in the artifacts; the path is relative to the chosen root.
```bash
marathon-cloud run android \
--application app-debug.apk \
--test-application app-debug-androidTest.apk \
--code-coverage \
--instrumentation-arg "debug=true" \
--instrumentation-arg "clearPackageData=true" \
--pull-files "EXTERNAL_STORAGE:screenshots" \
--pull-files "APP_DATA:files/reports"
```
`ROOT` is `EXTERNAL_STORAGE` (`/sdcard`) or `APP_DATA` (the app’s private data directory). Files with the same path from different devices overwrite each other in the archive.
### Isolated execution
[Section titled “Isolated execution”](#isolated-execution)
`--isolated` puts every test in its own batch so no test shares a batch with another. Use it to confirm whether a failure depends on tests that ran before it; it makes the run longer and more expensive. See [batching](/cloud/configuration/batching/).
```bash
marathon-cloud run android \
--application app-debug.apk \
--test-application app-debug-androidTest.apk \
--isolated
```
### Limiting parallelism
[Section titled “Limiting parallelism”](#limiting-parallelism)
Marathon Cloud sizes the device pool to finish in about 15 minutes, which can mean many devices hitting your backend at once. If your tests share fixtures or your staging backend cannot take the load, cap the pool:
```bash
marathon-cloud run android \
--application app-debug.apk \
--test-application app-debug-androidTest.apk \
--concurrency-limit 4
```
Treat this as a stopgap; see [limiting concurrency](/cloud/configuration/batching/#limiting-concurrency).
### Gating CI
[Section titled “Gating CI”](#gating-ci)
The command exits `1` if any test fails and `0` otherwise. Add `--result-file result.json` for a machine-readable summary and `--output ./results` to download JUnit XML and videos. See [exit codes](/cloud/cli/parameters/#exit-codes).
Note
Refer to the [CLI parameters](/cloud/cli/parameters/) page for a full list of available options.
# CI/CD integration
> Run Marathon Cloud on every pull request from GitHub Actions, CircleCI, Bitrise, or any Docker-capable CI, and gate the pipeline on the result.
Marathon Cloud ships an official GitHub Action, a Bitrise step, and a Docker image. All of them wrap the same CLI, so anything you can do from a terminal you can do from CI. Working examples live in the [MarathonLabs/samples](https://github.com/MarathonLabs/samples) repository.
Examples pin CLI 1.0.68, `action-test` 1.0.21, `setup-marathon-cloud` 2.0.2, and the Bitrise step 1.0.1.
## Quick start
[Section titled “Quick start”](#quick-start)
A complete GitHub Actions job: build, run on Marathon Cloud, publish JUnit, fail the check on test failures.
```yaml
name: ui-tests
on: [pull_request]
jobs:
marathon:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- run: ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest
- name: Run tests on Marathon Cloud
uses: MarathonLabs/action-test@1.0.21
with:
version: 1.0.68
apiKey: ${{ secrets.MARATHON_CLOUD_API_KEY }}
platform: Android
application: app/build/outputs/apk/debug/app-debug.apk
testApplication: app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
osVersion: 15
systemImage: google_apis
output: marathon
link: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
branch: ${{ github.head_ref }}
- name: Publish JUnit
if: always()
uses: mikepenz/action-junit-report@v5
with:
report_paths: marathon/tests/omni/marathon_junit_report.xml
```
The Marathon step exits non-zero when a test fails, so the job goes red without extra wiring. `if: always()` on the publish step ensures the report is uploaded either way.
## Gate your pipeline on results
[Section titled “Gate your pipeline on results”](#gate-your-pipeline-on-results)
The CLI, and every integration built on it, exits `1` when any test fails and `0` when all tests pass.
For richer gating, set `resultFile` and read the JSON in a later step:
```yaml
- uses: MarathonLabs/action-test@1.0.21
with:
version: 1.0.68
apiKey: ${{ secrets.MARATHON_CLOUD_API_KEY }}
platform: Android
application: app-debug.apk
testApplication: app-debug-androidTest.apk
osVersion: 15
systemImage: google_apis
resultFile: marathon-result.json
ignoreTestFailures: true
- name: Summarize
run: |
jq -r '"\(.passed) passed, \(.failed) failed, \(.ignored) ignored — \(.report)"' marathon-result.json >> "$GITHUB_STEP_SUMMARY"
test "$(jq -r .state marathon-result.json)" = "passed"
```
`ignoreTestFailures: true` keeps the run step green so the summary step executes; the final `test` restores the red check. See [result file](/cloud/cli/parameters/#result-file) for the schema.
## Supported platforms
[Section titled “Supported platforms”](#supported-platforms)
* GitHub Actions
**`MarathonLabs/action-test`** installs the CLI and runs it in one step. `version` (the CLI version to install) and `apiKey` are required alongside your run inputs.
**Android**
```yaml
- uses: MarathonLabs/action-test@1.0.21
with:
version: 1.0.68
apiKey: ${{ secrets.MARATHON_CLOUD_API_KEY }}
platform: Android
application: app/build/outputs/apk/debug/app-debug.apk
testApplication: app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
osVersion: 15
systemImage: google_apis
output: marathon
```
**iOS**
```yaml
- uses: MarathonLabs/action-test@1.0.21
with:
version: 1.0.68
apiKey: ${{ secrets.MARATHON_CLOUD_API_KEY }}
platform: iOS
application: build/Build/Products/Debug-iphonesimulator/YourApp.app
testApplication: build/Build/Products/Debug-iphonesimulator/YourAppUITests-Runner.app
osVersion: 18.4
output: marathon
```
**Maestro**
```yaml
- uses: MarathonLabs/action-test@1.0.21
with:
version: 1.0.68
apiKey: ${{ secrets.MARATHON_CLOUD_API_KEY }}
platform: Maestro/Android
application: app-debug.apk
testApplication: flows/
flows: flows/smoke/
output: marathon
```
**Inputs**
| Input | Description |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `version` | CLI version to install. Required. |
| `apiKey` | Marathon Cloud API token. Required. |
| `platform` | `Android`, `iOS`, `Maestro/Android`, or `Maestro/iOS`. Required. |
| `application` | Application binary. Required. |
| `testApplication` | Test binary, or flows directory for Maestro. Required. |
| `osVersion`, `device`, `systemImage` | Device selection. Android 15+ needs `systemImage: google_apis` or `google_apis_playstore`. |
| `output`, `outputGlob` | Download artifacts into `output`, optionally filtered by glob. |
| `resultFile` | Write the [result file](/cloud/cli/parameters/#result-file). |
| `name`, `link`, `branch`, `project` | Run metadata. |
| `wait`, `ignoreTestFailures` | Exit behaviour. |
| `filterFile`, `isolated`, `analyticsReadOnly` | Execution control. |
| `retryQuotaTestUncompleted`, `retryQuotaTestPreventive`, `retryQuotaTestReactive`, `noRetries` | [Retries](/cloud/configuration/retries/). |
| `pullFiles` | Android: files to pull from the device. |
| `grantedPermission`, `xctestrunEnv`, `xctestrunTestEnv`, `xctestplanFilterFile`, `xctestplanTargetName` | iOS. |
| `maestroEnv`, `flows` | Maestro. |
Flags without an input (`--concurrency-limit`, `--code-coverage`, `--batch-isolation`, `--test-timeout-*`, `--application-bundle`, `--library-bundle`, `--instrumentation-arg`) are available by installing the CLI with `MarathonLabs/setup-marathon-cloud` and calling `marathon-cloud` from a `run:` step:
```yaml
- uses: MarathonLabs/setup-marathon-cloud@2.0.2
with:
version: 1.0.68
- run: |
marathon-cloud run android \
--application app-debug.apk \
--test-application app-debug-androidTest.apk \
--os-version 15 \
--system-image google_apis \
--concurrency-limit 4 \
--output marathon \
--no-progress-bars
env:
MARATHON_CLOUD_API_KEY: ${{ secrets.MARATHON_CLOUD_API_KEY }}
```
* CircleCI
Run the CLI from the official Docker image with the Docker executor.
```yaml
version: 2.1
jobs:
ui-tests:
docker:
- image: marathonlabs/marathon-cloud:1.0.68
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Run tests on Marathon Cloud
command: |
marathon-cloud run android \
--application app/build/outputs/apk/debug/app-debug.apk \
--test-application app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--os-version 15 \
--system-image google_apis \
--output marathon \
--link "$CIRCLE_BUILD_URL" \
--branch "$CIRCLE_BRANCH" \
--no-progress-bars
- store_test_results:
path: marathon/tests/omni
- store_artifacts:
path: marathon
workflows:
test:
jobs:
- build
- ui-tests:
requires: [build]
```
Set `MARATHON_CLOUD_API_KEY` in a CircleCI context or project environment variable; the CLI reads it without a flag. The `build` job should persist the APKs to the workspace.
Caution
The `marathonlabs/marathon-cloud-orb` orb is pinned to CLI 0.3.0 and uses the legacy flag syntax. Do not use it with the current platform.
* Bitrise
**`run-tests-using-marathon-cloud`** is in the official Bitrise StepLib. On Linux stacks it runs the Docker image; on macOS stacks it installs the CLI from Homebrew.
```yaml
- run-tests-using-marathon-cloud@1.0.1:
inputs:
- api_key: $MARATHON_CLOUD_API_KEY
- platform: Android
- application: $BITRISE_APK_PATH
- test_application: $BITRISE_TEST_APK_PATH
- os_version: 15
- system_image: google_apis
- output: $BITRISE_DEPLOY_DIR/marathon
```
**Inputs**: `api_key`, `platform` (`Android` or `iOS`), `application`, `test_application`, `os_version`, `system_image`, `device`, `run_name`, `link`, `output`, `filter_file`, `isolated`, `ignore_test_failures`, `xctestplan_filter_file`, `xctestplan_target_name`, `docker_image`.
Point the **Deploy to Bitrise.io** step’s test results at `$BITRISE_DEPLOY_DIR/marathon/tests/omni`.
* Docker
**`marathonlabs/marathon-cloud`** on Docker Hub, tagged `latest` and per CLI version. The image’s working directory is `/work`; mount your build output there.
```bash
docker run --rm \
-v "$(pwd)":/work \
-e MARATHON_CLOUD_API_KEY \
marathonlabs/marathon-cloud:1.0.68 \
run android \
--application app-debug.apk \
--test-application app-debug-androidTest.apk \
--os-version 15 \
--system-image google_apis \
--output marathon \
--no-progress-bars
```
This is the path for GitLab CI, Jenkins, Buildkite, and any other Docker-capable runner. Pin the version tag rather than `latest`.
## Best practices
[Section titled “Best practices”](#best-practices)
### Keep secrets out of config
[Section titled “Keep secrets out of config”](#keep-secrets-out-of-config)
Store the API token in your CI’s secret store and expose it as `MARATHON_CLOUD_API_KEY`. Never commit it. See [API keys](/cloud/getting-started/api-key/).
### Link runs back to CI
[Section titled “Link runs back to CI”](#link-runs-back-to-ci)
Set `link` to the CI job or pull request URL, and `branch` and `name`, so a run in the console can be traced to the job that produced it.
### Publish JUnit
[Section titled “Publish JUnit”](#publish-junit)
Set `output` and point your CI’s test-report publisher at `