Running iOS Simulator Tests on GitHub Actions

Run iOS simulator tests on GitHub Actions with xcodebuild on a macOS runner. Pin the image that carries your runtime, then shard the suite at $0.08 per minute.

Last verified:

iOS simulator tests run on GitHub Actions through xcodebuild test on a macOS runner, and two decisions set both the wall clock and the invoice: which simulator runtime the runner image carries, and how the suite is divided across jobs. On WarpBuild you put a warp-macos- label in runs-on, pin the image that ships the runtime your destinations name, build once with build-for-testing, then run test-without-building in a matrix at $0.08 per minute for the 6 vCPU size.

Diagnosis

Simulator jobs fail or run long for four reasons, and they are easy to tell apart from the log.

The runtime is missing from the image. A destination specifier such as platform=iOS Simulator,name=iPhone 17,OS=27.0 resolves only when that runtime is installed. When it is absent, xcodebuild exits with "Unable to find a destination matching the provided destination specifier" and lists the destinations it did find. Teams then paper over it by calling xcodebuild -downloadPlatform iOS inside the job, which works and adds several billed minutes to every single run. The correct fix is choosing a runner image that already carries the runtime.

Simulator boot time is billed time. A cold xcrun simctl boot runs for a real fraction of a minute before the first test method executes, and a first-run device also creates its data container. A job that boots three destinations pays that cost three times. Multiply by a matrix and it stops being noise: five jobs booting one simulator each spend roughly five minutes a run on boot alone, which is 500 minutes a month at 100 runs.

The machine bounds parallel destinations, whatever the flag asks for. -parallel-testing-enabled YES clones the simulator once per worker, and every clone is a full set of processes with its own memory. Asking for six workers on a 6 vCPU, 22GB runner produces a failure that reads as flakiness: tests that pass locally time out in the job, and the log carries "Lost connection to the test manager" or a testmanagerd restart. The usual cause is an oversubscribed runner rather than a bad test.

Shards are imbalanced. The usual matrix splits test classes alphabetically or by target, which produces one shard carrying the slow UI tests and three that finish early. Wall clock is set by the slowest shard, so an imbalanced split buys a fraction of the improvement the shard count suggests, while the invoice charges for every shard including the ones that idle at the end. Per-job fixed overhead makes this worse: checkout, artifact download, and boot are repaid once per shard.

One more pattern shows up in almost every repository that adopts a matrix: each shard runs xcodebuild test, which compiles the whole test bundle again. Four shards then compile the app four times, and compilation is the most expensive part of the job.

Fix

The repair is four changes to the workflow and one change to the runner label.

Pin the image that carries your runtime. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the macOS catalog offers multiple sizes and configurations per chip. The macOS 26 labels ship Xcode 27.0 (build 27A5194q) with these bundled simulator runtimes:

PlatformSimulator runtimeCarried by
iOS27.0 (24A5355p)warp-macos-26-arm64-6x, warp-macos-26-arm64-12x
tvOS27.0 (24J5289o)warp-macos-26-arm64-6x, warp-macos-26-arm64-12x
watchOS27.0 (24R5289n)warp-macos-26-arm64-6x, warp-macos-26-arm64-12x
visionOS27.0 (24M5291p)warp-macos-26-arm64-6x, warp-macos-26-arm64-12x

Runtime and build identifiers come from the WarpBuild cloud runners documentation, checked on 2026-08-13. Xcode 27.0 is a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. The warp-macos-latest-arm64-6x alias resolves to the macOS 15 image in step with GitHub's own macos-latest pointer, so a job that needs the 27.0 runtimes names the macOS 26 label explicitly. The rest of the tooling on each image matches its upstream GitHub image, listed in the preinstalled software documentation.

Build once and test many times. Run xcodebuild build-for-testing in a single job, upload Build/Products and the generated .xctestrun file as an artifact, then have every shard run test-without-building. Compilation happens once per run instead of once per shard, which is the single largest saving available in a sharded layout.

Boot the destination once per job and wait for it properly. xcrun simctl bootstatus "iPhone 17" -b blocks until the device is actually ready, which removes the retry loops teams add when the first test starts against a half-booted simulator.

Set parallelism to the size of the machine. Two workers on the 6 vCPU, 22GB label and four on the 12 vCPU, 44GB label is a reasonable starting point, then adjust from the runner metrics. WarpBuild CI observability reports per-job CPU and memory utilization, which is how you tell a job that needed the wider machine from one that idled, and the Action Debugger gives an interactive session on a runner when a simulator failure reproduces only in the job.

Balance shards by measured duration. Sort test classes by their historical time from the result bundle and deal them into shards round-robin, longest first, rather than splitting alphabetically. Feed the list to -only-testing: arguments per shard.

Two operational notes. Jobs that need no Xcode at all, such as SwiftLint, changelog checks, and App Store metadata validation, belong on a Linux label. And macOS fan-out has a ceiling: generally available Linux and Windows runners do not have plan-level concurrency caps for Linux and Windows runners, while concurrency on the macOS fleet has been governed by per-organization quotas since July 27, 2026, with jobs beyond the quota waiting for capacity. If a release week needs a wider matrix than the quota allows, write to [email protected] first.

Configuration

The build job compiles the test bundle once on the macOS 26 image and publishes the products.

name: ios-tests

on:
  pull_request:
    branches: [main]

concurrency:
  group: ios-tests-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build-for-testing:
    runs-on: warp-macos-26-arm64-12x
    steps:
      - uses: actions/checkout@v4

      - name: Record the toolchain and available runtimes
        run: |
          xcodebuild -version
          xcrun simctl list runtimes
          xcodebuild -showdestinations -scheme AppTests | head -40

      - name: Restore SPM checkouts
        uses: actions/cache@v4
        with:
          path: ~/Library/Caches/org.swift.swiftpm
          key: spm-${{ hashFiles('**/Package.resolved') }}

      - name: Build for testing
        run: |
          xcodebuild build-for-testing \
            -scheme AppTests \
            -destination "platform=iOS Simulator,name=iPhone 17,OS=27.0" \
            -derivedDataPath DerivedData \
            CODE_SIGNING_ALLOWED=NO

      - uses: actions/upload-artifact@v4
        with:
          name: test-products
          path: |
            DerivedData/Build/Products
          retention-days: 1

The shard job downloads those products, boots one simulator, and runs its slice with parallel destinations enabled.

  test:
    needs: build-for-testing
    runs-on: warp-macos-26-arm64-6x
    strategy:
      fail-fast: false
      matrix:
        shard:
          - "-only-testing:AppTests/CheckoutTests -only-testing:AppTests/CartTests"
          - "-only-testing:AppTests/AccountTests -only-testing:AppTests/SearchTests"
          - "-only-testing:AppUITests/OnboardingUITests"
          - "-only-testing:AppUITests/PurchaseUITests"
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: test-products
          path: DerivedData/Build/Products

      - name: Boot the destination once
        run: |
          xcrun simctl boot "iPhone 17" || true
          xcrun simctl bootstatus "iPhone 17" -b

      - name: Run this shard
        run: |
          xcodebuild test-without-building \
            -xctestrun DerivedData/Build/Products/*.xctestrun \
            -destination "platform=iOS Simulator,name=iPhone 17,OS=27.0" \
            -parallel-testing-enabled YES \
            -maximum-concurrent-test-simulator-destinations 2 \
            -resultBundlePath Shard.xcresult \
            ${{ matrix.shard }}

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: results-${{ strategy.job-index }}
          path: Shard.xcresult

Three details decide whether this runs clean. The .xctestrun file has to come from the same Xcode as the shard job, so the build job and the shard jobs stay on the same macOS image family. -maximum-concurrent-test-simulator-destinations bounds in-machine cloning and is the knob to lower first when tests time out. And runner storage is ephemeral, so anything a later job needs travels as an artifact.

Cost or Time Model

macOS rates from the WarpBuild pricing page and the runner catalog, checked on 2026-08-13:

LabelmacOS imagevCPUMemoryStorageRate per minute
warp-macos-26-arm64-6xmacOS 26622GB120GB SSD$0.08
warp-macos-26-arm64-12xmacOS 261244GB270GB SSD$0.16
warp-macos-15-arm64-6xmacOS 15622GB120GB SSD$0.08
warp-macos-15-arm64-12xmacOS 151244GB270GB SSD$0.16
warp-macos-14-arm64-6xmacOS 14622GB120GB SSD$0.08

Assumptions

  • One iOS repository, 300 pull request runs per month, which is 15 runs a day across 20 working days.
  • The suite takes 40 minutes of test execution when it runs on one simulator.
  • Fixed work per run: 1 minute of checkout and package resolution, 6 minutes of compilation, 1 minute of simulator boot.
  • Fixed work per shard: 1 minute of checkout, 1 minute of artifact download, 1 minute of simulator boot.
  • Four parallel workers inside one machine finish the 40 test minutes in 14 minutes rather than 10, because the clones share one disk and one CPU package.
  • Billing is per minute of job time. Cancelled runs, reruns, and artifact storage are excluded so the runner minute is the only variable.

Three layouts

LayoutLabelBilled minutes per runWall clock per runCost per run300 runs per month
Serialized, one jobwarp-macos-15-arm64-6x4848 minutes$3.84$1,152.00
Build once plus 4 shardswarp-macos-15-arm64-6x6021 minutes$4.80$1,440.00
One job, 4 in-machine workerswarp-macos-26-arm64-12x2222 minutes$3.52$1,056.00

The arithmetic behind each row:

  • Serialized: 1 + 6 + 1 + 40 = 48 minutes, all of it on the critical path. 48 x $0.08 = $3.84, and 300 x $3.84 = $1,152.00.
  • Sharded: the build job is 1 + 6 + 1 upload = 8 minutes, and each of the four shards is 1 + 1 + 1 + 10 = 13 minutes. Billed total 8 + (4 x 13) = 60 minutes at $4.80 a run, wall clock 8 + 13 = 21 minutes. 300 x $4.80 = $1,440.00.
  • In-machine workers: 1 + 6 + 1 + 14 = 22 minutes on the 12 vCPU label. 22 x $0.16 = $3.52, and 300 x $3.52 = $1,056.00.

Sharding across jobs cuts 27 minutes off every pull request and adds $288.00 a month against the serialized row. Running the workers inside one wider machine lands within a minute of sharding's wall clock, 22 versus 21, for $384.00 a month less than it, which makes it the first thing to measure when the suite targets a single destination. Sharding across jobs wins when the shards target different runtimes, because one job cannot boot iOS and tvOS destinations in parallel efficiently on a 6 vCPU machine.

Now the imbalance tax. Keep the sharded row and split the 40 test minutes as 16, 12, 8, and 4 instead of evenly. The longest shard job becomes 3 + 16 = 19 minutes, wall clock becomes 8 + 19 = 27 minutes, and billed minutes stay at 60. An imbalanced split costs 6 minutes of feedback time per run and nothing on the invoice, which is why shard balancing is worth automating from result bundle timings rather than guessing.

Against GitHub-hosted list prices

warp-macos-latest-arm64-6x (6 vCPU, 22GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14GB), which is 22 percent lower list price with one more vCPU and 8GB more memory. GitHub list price read from the GitHub Actions per-minute rates, checked on 2026-08-13.

Carried through the sharded row, 300 runs at 60 billed minutes is 18,000 macOS minutes a month: $1,836.00 at GitHub's list rate against $1,440.00 at the WarpBuild rate, a difference of $396.00 a month at identical workflow shape.

SSO is available for a flat $250 per month, whatever the user count, and it does not change the per-minute rates.

Once the workflow is live, replace the model with measured numbers. The reports view and reporting API expose runner label, execution time, billed time, and cost per job, so a script can rebalance the matrix from measured shard timings and costs.

For the full macOS catalog and regional detail, start at WarpBuild macOS runners for GitHub Actions. For image-by-image toolchain coverage, see Xcode versions and simulator runtimes per macOS image. For the compilation half of the problem, see cut Xcode build times on GitHub Actions, and for fan-out beyond the test job, see run iOS jobs in parallel on GitHub Actions.

FAQ

Why does xcodebuild fail with "Unable to find a destination matching the provided destination specifier"?

The simulator runtime named in the destination is missing from the image the runner booted. Print xcrun simctl list runtimes and xcodebuild -showdestinations -scheme YourScheme in the job, then pin a label whose image carries the runtime. On WarpBuild, warp-macos-26-arm64-6x and warp-macos-26-arm64-12x ship the iOS 27.0 runtime with Xcode 27.0. Downloading a runtime at job time with xcodebuild -downloadPlatform iOS works and adds billed minutes to every run.

Which simulator runtimes ship on the WarpBuild macOS 26 image?

iOS 27.0 (24A5355p), tvOS 27.0 (24J5289o), watchOS 27.0 (24R5289n), and visionOS 27.0 (24M5291p). They are bundled with Xcode 27.0 (build 27A5194q) on the warp-macos-26-arm64-6x and warp-macos-26-arm64-12x labels, on top of the Xcode versions in the upstream GitHub macOS 26 image.

Does sharding a simulator suite across a matrix reduce the bill?

No. Sharding reduces wall clock and increases billed minutes, because every shard repays checkout, artifact download, and simulator boot. In the model on this page a 48-minute serialized job becomes 21 minutes of wall clock and 60 billed minutes across five jobs, which moves the run from $3.84 to $4.80 at $0.08 per minute.

How many simulator destinations can run in parallel on one macOS runner?

Each parallel worker is a cloned simulator with its own processes, so memory and vCPU set the ceiling. Start with two workers on the 6 vCPU, 22GB label and four on the 12 vCPU, 44GB label, controlled by -parallel-testing-enabled YES and -maximum-concurrent-test-simulator-destinations. Across jobs, macOS concurrency is governed by per-organization quotas, so a very wide matrix can queue.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.