Running Many iOS Jobs in Parallel on GitHub Actions

Fanning many iOS jobs out on GitHub Actions clears three limits: the workflow concurrency key, account job ceilings, and the macOS fleet quota.

Last verified:

Running many iOS jobs in parallel on GitHub Actions comes down to three separate limits: the concurrency key written into the workflow, the account-level job ceiling attached to your GitHub plan, and the macOS capacity behind whatever label runs-on points at. Clear them in that order, then size the fan-out to the macOS concurrency quota your organization actually holds and shard the test suite to match that width.

This guide separates the three limits, shows the workflow shape that builds once and fans xcodebuild shards across warp-macos labels, and works a wall-clock model that respects a quota instead of assuming infinite macOS machines.

Diagnosis

An iOS matrix that refuses to widen is stalled by one of three mechanisms. They produce different symptoms, and the fix for one does nothing for the other two.

1. The workflow concurrency key

The concurrency key is a throttle you wrote yourself. A group holds one running item and one pending item, so a third arrival evicts the pending one. With cancel-in-progress: true, the arriving run also kills the run currently executing.

The tell is a "Waiting" banner naming a group, or a queued run flipping to "Canceled" the moment a teammate pushes. The key never starts work sooner, so it can only be responsible for parked and canceled runs. If shards inside a single run are queuing, look elsewhere.

A common accident on iOS repositories: a bare concurrency: ios pasted into the test workflow, the nightly regression workflow, and the TestFlight workflow forces all three into a single lane, and a release build parks the pull request suite.

2. GitHub account-level job ceilings

GitHub-hosted runners draw from shared pools with a fixed ceiling on concurrent jobs per account, and macOS carries a much lower sub-ceiling than the account total. Figures from GitHub's usage limits reference, checked on 2026-08-13:

GitHub planConcurrent jobsConcurrent macOS jobs
Free205
Pro405
Team605
Enterprise50050

Five macOS slots for an entire account is what most iOS teams are actually hitting. A 24-shard suite on macos-latest executes in 5 waves whatever the matrix says, and every other repository in the organization waits behind it.

Two details matter when you plan a fan-out. The ceiling is account wide rather than per repository, so one team's release day sets everyone else's queue. And jobs targeting self-hosted or managed labels, including warp-macos labels, sit outside these ceilings entirely; the repository-level cap of 256 jobs per matrix still applies to them.

3. macOS fleet capacity

Once jobs leave the GitHub-hosted pool, the constraint becomes how many macOS machines the label can produce at once. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the concurrency picture differs by platform. The cloud runners documentation scopes unlimited concurrency to generally available features on Linux and Windows runners.

macOS has its own rule, and it is worth stating plainly before you plan a 60-way matrix. Since July 27, 2026, concurrency on the WarpBuild macOS fleet is governed by per-organization quotas, and jobs beyond the quota wait for capacity to free up. If your use case requires high concurrency on macOS, contact [email protected] to have the quota raised.

So the honest planning input is a number: the macOS concurrency quota held by your organization. Everything below sizes the matrix against that number rather than pretending it does not exist.

Measure before changing anything. The Queue Timings report breaks queue wait down per runner label and stack at P75 and P90, with a daily chart and CSV export, and the Jobs report puts queue time next to duration, CPU, and memory for each repository, workflow, and job name. Both are part of WarpBuild CI observability and are documented in the reports documentation. A P90 on warp-macos-26-arm64-6x that climbs only during the merge rush points at the quota. A P90 that stays flat while shard duration grows points at the build itself.

Fix

Work the fan-out in four moves, cheapest first.

Scope the concurrency key per workflow and per ref. Build the group name from ${{ github.workflow }}-${{ github.ref }} so the test suite, the nightly job, and the release workflow stop sharing a lane. Cancel on pull request branches and let main finish.

Build once, then fan out the test phase. xcodebuild build-for-testing produces an .xctestrun bundle and compiled products. Every shard afterwards runs test-without-building against that bundle. One compile is spent on a wide machine, and the shards are cheap simulator work on smaller machines. Without this split, a 24-shard matrix compiles the app 24 times and burns the quota on repeated compilation.

Size the matrix to the quota you hold. Set max-parallel to the macOS concurrency quota for your organization so the matrix width and the available capacity agree. Jobs above the quota wait for capacity, which shows up as queue time rather than as an error, so a matrix wider than the quota looks fast in the YAML and slow in the report.

Get everything that does not need Xcode off macOS. SwiftLint, plist and JSON validation, changelog rules, metadata checks, and API contract tests run on warp-ubuntu-latest-arm64-4x at $0.006 per minute. Each of those jobs moved off macOS returns a macOS slot to the simulator shards, which widens the effective fan-out without touching the quota.

Then re-read the Queue Timings report for the macOS labels. Queue wait per wave is the number the model in the next section consumes.

Configuration

The workflow below compiles once on the 12 vCPU macOS label and fans 24 test shards across the 6 vCPU label, with fail-fast disabled and the shard index wired through to xcodebuild.

name: ios-tests
on:
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

env:
  DESTINATION: "platform=iOS Simulator,name=iPhone 17 Pro,OS=27.0"

jobs:
  build-for-testing:
    runs-on: warp-macos-26-arm64-12x
    steps:
      - uses: actions/checkout@v4
      - run: |
          xcodebuild build-for-testing \
            -scheme AppTests \
            -destination "$DESTINATION" \
            -derivedDataPath DerivedData
      - uses: actions/upload-artifact@v4
        with:
          name: xctestrun-bundle
          path: DerivedData/Build/Products
          retention-days: 1

  test:
    needs: build-for-testing
    runs-on: warp-macos-26-arm64-6x
    strategy:
      fail-fast: false
      max-parallel: 12
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
                13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: xctestrun-bundle
          path: DerivedData/Build/Products
      - name: Run shard ${{ matrix.shard }} of 24
        run: |
          ONLY_TESTING=$(sed 's/^/-only-testing:/' \
            "shards/shard-${{ matrix.shard }}.txt" | tr '\n' ' ')
          xcodebuild test-without-building \
            -xctestrun DerivedData/Build/Products/AppTests.xctestrun \
            -destination "$DESTINATION" \
            -resultBundlePath "results-${{ matrix.shard }}.xcresult" \
            $ONLY_TESTING
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: results-${{ matrix.shard }}
          path: results-${{ matrix.shard }}.xcresult

What each piece is doing:

  • fail-fast: false keeps the other 23 shards running when one fails, so a single flaky UI test reports next to real failures instead of hiding them.
  • max-parallel: 12 holds the fan-out at the organization macOS quota. Raise it only after support raises the quota; a wider matrix against a fixed quota converts into queue time.
  • The shard index reaches xcodebuild through a checked-in shards/shard-N.txt file listing Target/Class entries, one per line, converted into -only-testing: arguments. Keeping the split in files makes it reviewable and keeps slow suites balanced by hand where timing data is not available.
  • if: always() on the result upload preserves .xcresult bundles from failing shards, which is where the failure diagnosis actually lives.
  • 24 shards sits well under GitHub's cap of 256 jobs per matrix, checked on 2026-08-13 against GitHub's usage limits reference.

The macOS catalog carries multiple sizes and configurations per chip, so the compile job and the shard jobs pick different machines by label. Rates and specifications come from the cloud runners documentation, re-checked on 2026-08-13:

Runner labelmacOSvCPUMemoryStorageRate per minute
warp-macos-26-arm64-6x26622GB120GB SSD$0.08
warp-macos-26-arm64-12x261244GB270GB SSD$0.16
warp-macos-15-arm64-6x15622GB120GB SSD$0.08
warp-macos-15-arm64-12x151244GB270GB SSD$0.16
warp-macos-14-arm64-6x14622GB120GB SSD$0.08

The macOS 15 labels also answer to the aliases warp-macos-latest-arm64-6x and warp-macos-latest-arm64-12x. The OS=27.0 destination in the workflow above requires the macOS 26 image, which ships the Xcode 27.0 SDKs with iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes while GitHub's upstream macOS 27 runner image is in beta; a dedicated macOS 27 image follows once that image is released. Pin the destination OS to a runtime the image actually carries, since a missing runtime fails every shard at once. Full label details are on the macOS runners page, and the simulator setup itself is covered in the guide to iOS simulator tests on GitHub Actions.

Cost or Time Model

Fan-out moves wall clock and leaves billed minutes where they were. Here is the arithmetic for the suite above.

Assumptions, stated up front: 24 shards, each 9 minutes of simulator time on warp-macos-26-arm64-6x; one 6-minute build-for-testing job on warp-macos-26-arm64-12x; uniform shards with no retries; per-minute billing. Shard compute is 24 x 9 = 216 macOS job-minutes per run in every scenario below.

Queue wait comes from the Queue Timings report for the macOS labels. This model uses P75 of 0.7 minutes and P90 of 1.6 minutes per job as placeholders; export your own rows and substitute them. Each wave of shards pays that wait once.

Fan-out widthWavesWall clock at P75Wall clock at P90Shard minutes billed
5 concurrent macOS jobs548.5 min53.0 min216
12 concurrent macOS jobs219.4 min21.2 min216
24 concurrent macOS jobs19.7 min10.6 min216

The arithmetic per row is waves x (9 + queue wait): 5 x 9.7 = 48.5 and 5 x 10.6 = 53.0 at width 5, and 2 x 9.7 = 19.4 and 2 x 10.6 = 21.2 at width 12. Widening from 5 to 12 returns 29.1 minutes of wall clock per run at P75. Widening from 12 to 24, which needs a quota raise through support, returns 9.7 more. Billed shard minutes stay at 216 throughout, so wall-clock gains here cost nothing extra in minutes.

Now price those minutes. 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): 22 percent lower list price, and the WarpBuild shape carries one more vCPU and 8GB more memory. GitHub list price checked on 2026-08-13 against the GitHub Actions per-minute rates.

Carry that through a month at 200 runs of the suite:

Line itemMinutes per monthRateMonthly cost
Shards on warp-macos-26-arm64-6x43,200$0.08$3,456.00
Same shard minutes at GitHub's macOS ARM64 list rate43,200$0.102$4,406.40
Compile job on warp-macos-26-arm64-12x1,200$0.16$192.00
Lint and metadata jobs on warp-ubuntu-latest-arm64-4x1,600$0.006$9.60

Shard minutes: 24 x 9 x 200 = 43,200. Compile minutes: 6 x 200 = 1,200. The computed difference on the shard line is $950.40 per month at this volume, before counting the wall clock returned by the wider fan-out.

SSO costs a flat $250 per month, whatever the user count, and it is the one line on this model that does not scale with the matrix. Full per-minute rates by runner type are on the pricing page.

If queue wait rather than shard duration turns out to be the dominant term, the guide to GitHub Actions queue times covers the rest of the causes, and the answer page on how many GitHub Actions jobs can run at once has the short version of the ceilings above.

FAQ

How many iOS jobs can run in parallel on GitHub Actions?

On GitHub-hosted macOS runners, the ceiling is 5 concurrent macOS jobs on Free, Pro, and Team plans and 50 on Enterprise, from GitHub's usage limits reference, checked on 2026-08-13. On WarpBuild macOS labels, concurrency is governed by a per-organization quota set for your account since July 27, 2026, and jobs above it wait for capacity.

Does the concurrency key control how many shards run at once?

No. The concurrency key holds one running item and one pending item per group, so it cancels and parks whole runs. Shard width inside one run is set by the matrix, by max-parallel, and by how much macOS capacity is available to your account.

What is the largest matrix I can use for an iOS test suite?

A matrix generates at most 256 jobs per workflow run, checked on 2026-08-13 against GitHub's usage limits reference. Past that, split the suite across workflows or collapse a matrix dimension such as the simulator list.

How do I confirm the fan-out actually got faster?

Read the Queue Timings report for your macOS runner labels before and after the change. It reports P75 and P90 queue wait per label and stack with CSV export, so you can pair queue wait with shard duration and rebuild the wall-clock model with your own numbers.

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.