Fastlane Lanes on GitHub Actions
Run fastlane lanes on GitHub Actions macOS runners with cached bundler and CocoaPods installs, ephemeral signing keychains, and warp- label sizing.
Last verified:
Fastlane runs on GitHub Actions the same way it runs on a laptop: bundle exec fastlane <lane> inside a job whose runs-on points at a macOS runner. On WarpBuild that label is a warp-macos- label, and the macOS fleet ships in two configurations, 6 vCPU with 22GB of memory at $0.08 per minute and 12 vCPU with 44GB at $0.16 per minute.
This page covers the workflow YAML for a cached test lane and a signed release lane, how to pick between the two macOS configurations, the four things that dominate fastlane wall clock time on GitHub Actions, and the arithmetic against GitHub-hosted macOS list prices.
Overview
A fastlane repository usually ends up with two lane shapes on GitHub Actions.
The first is a feedback lane on every pull request push. It calls scan for unit and UI tests, sometimes swiftlint, and never touches a signing identity. The second is a release lane on merge to the main branch. It calls match or an equivalent certificate step, then gym to archive and export an IPA, then pilot or deliver to upload.
Both need macOS, because xcodebuild, the simulator runtimes, and the security keychain toolchain that fastlane drives only exist there. The macOS fleet runs on Apple Silicon with ARM64 architecture in multiple sizes and configurations per chip. A test lane and a release lane can therefore sit on different labels inside one workflow file.
macOS runner catalog
| Runner label | macOS | vCPU | Memory | Storage | Per-minute | Alias |
|---|---|---|---|---|---|---|
warp-macos-26-arm64-6x | macOS 26 | 6 | 22GB | 120GB SSD | $0.08 | |
warp-macos-26-arm64-12x | macOS 26 | 12 | 44GB | 270GB SSD | $0.16 | |
warp-macos-15-arm64-6x | macOS 15 | 6 | 22GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x |
warp-macos-15-arm64-12x | macOS 15 | 12 | 44GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x |
warp-macos-14-arm64-6x | macOS 14 | 6 | 22GB | 120GB SSD | $0.08 |
Source: the WarpBuild cloud runners documentation, verified 2026-08-13. The latest aliases track macOS 15, in sync with GitHub's macos-latest tag. macOS 13 runners were removed on June 8, 2026, so a Fastfile still pinned to a macOS 13 label needs to move.
The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes (build 27A5194q, with iOS, tvOS, watchOS, and visionOS 27.0 runtimes) while GitHub's upstream macOS 27 runner image is in beta. A dedicated macOS 27 image follows once that upstream image is released. A lane that pins a specific Xcode through DEVELOPER_DIR or xcversion should therefore state the version it expects rather than trusting the image default.
Three platform facts shape how a fastlane pipeline is laid out.
Every job gets a fresh virtual machine, and that VM's storage is destroyed when the runner terminates. Gems, pods, DerivedData, and keychains all start empty on every run unless the workflow restores them. The isolation model is described in the runner security documentation.
macOS runners do not support nested virtualization and cannot run Docker, so any containerized step, such as a backend fixture or a screenshot service, belongs on a Linux label in a separate job.
Concurrency on the macOS fleet is governed by per-organization quotas as of July 27, 2026. Jobs beyond the quota wait for capacity, which matters for a fastlane scan matrix that fans out over device names.
Configuration
Point runs-on at a warp-macos- label and keep the rest of the workflow standard, because the images carry the same tooling as GitHub-hosted macOS runners. The workflow below hoists bundle install and pod install out of the lane into their own cached steps, so the lane itself runs the build and nothing else, and the install cost shows up as its own line in the job timeline.
name: fastlane
on:
pull_request:
push:
branches: [main]
concurrency:
group: fastlane-${{ github.ref }}
cancel-in-progress: true
env:
DEVELOPER_DIR: /Applications/Xcode_${{ vars.XCODE_VERSION }}.app/Contents/Developer
FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_HIDE_CHANGELOG: "1"
LC_ALL: en_US.UTF-8
LANG: en_US.UTF-8
jobs:
test:
runs-on: warp-macos-15-arm64-6x
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Cache bundler gems
uses: actions/cache@v4
with:
path: vendor/bundle
key: gems-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }}
restore-keys: gems-${{ runner.os }}-
- name: Install gems
run: |
bundle config set --local path vendor/bundle
bundle config set --local deployment true
bundle install --jobs 4
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
Pods
~/Library/Caches/CocoaPods
key: pods-${{ runner.os }}-${{ hashFiles('Podfile.lock') }}
restore-keys: pods-${{ runner.os }}-
- name: Install pods
run: bundle exec pod install --deployment
- name: Run the test lane
run: bundle exec fastlane ios test
- uses: actions/upload-artifact@v4
if: always()
with:
name: fastlane-test-output
path: fastlane/test_output
beta:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: warp-macos-26-arm64-12x
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Cache bundler gems
uses: actions/cache@v4
with:
path: vendor/bundle
key: gems-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }}
restore-keys: gems-${{ runner.os }}-
- name: Install gems
run: |
bundle config set --local path vendor/bundle
bundle config set --local deployment true
bundle install --jobs 4
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
Pods
~/Library/Caches/CocoaPods
key: pods-${{ runner.os }}-${{ hashFiles('Podfile.lock') }}
restore-keys: pods-${{ runner.os }}-
- name: Install pods
run: bundle exec pod install --deployment
- name: Run the release lane
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_P8_BASE64 }}
run: bundle exec fastlane ios betaThe Fastfile that pairs with it keeps each lane narrow.
default_platform(:ios)
platform :ios do
desc "Build and run the unit and UI test suites"
lane :test do
scan(
scheme: "Kitchen",
device: "iPhone 17",
derived_data_path: "DerivedData",
result_bundle: true,
clean: false
)
end
desc "Sign, archive, and upload a TestFlight build"
lane :beta do
setup_ci
key = app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"],
is_key_content_base64: true
)
match(type: "appstore", readonly: true, api_key: key)
gym(
scheme: "Kitchen",
configuration: "Release",
export_method: "app-store",
derived_data_path: "DerivedData"
)
pilot(api_key: key, skip_waiting_for_build_processing: true)
end
endFive details in those files carry most of the value.
bundle config set --local path vendor/bundle puts the gems inside the workspace, which is what makes vendor/bundle a cacheable path. Without it the gems land under the system Ruby prefix and the cache entry restores nothing useful.
bundle config set --local deployment true makes bundle install fail when Gemfile.lock is out of date instead of silently resolving a different fastlane version on the runner than the one a developer ran locally.
The CocoaPods cache stores Pods and ~/Library/Caches/CocoaPods together. The first is the integrated output, the second is the downloaded pod sources, and caching only one of them leaves half the work to redo.
setup_ci is the line that makes signing work on an ephemeral machine. It creates a temporary keychain for the job and points fastlane at it, so match can install the certificate without touching a login keychain that does not exist on a headless runner. There is no cleanup step to write, because the VM and its storage are destroyed when the job ends.
readonly: true on match stops a release job from minting new certificates when a lookup fails. Keep certificate creation in a lane a person runs deliberately, so a merge can never issue one.
Moving an existing fastlane setup across is a label change. Swap macos-latest for warp-macos-15-arm64-6x and the Fastfile stays untouched.
Sizing
| Configuration | vCPU | Memory | Storage | Per-minute | Fits |
|---|---|---|---|---|---|
6x (warp-macos-15-arm64-6x, warp-macos-26-arm64-6x, warp-macos-14-arm64-6x) | 6 | 22GB | 120GB SSD | $0.08 | scan on one or two destinations, lint lanes, bundle install and pod install |
12x (warp-macos-15-arm64-12x, warp-macos-26-arm64-12x) | 12 | 44GB | 270GB SSD | $0.16 | gym release archives, wide scan device matrices, large DerivedData |
Start the test lane on 6x. scan spends much of its wall clock booting a simulator, installing the test host, and running tests serially inside one destination, and none of that shortens with more cores.
Put the release lane on 12x. gym runs a Release build with whole-module optimization, produces dSYMs, and then exports the IPA, which is the one part of a fastlane pipeline with enough parallel compile work to use the extra cores.
Storage decides more than people expect. A 120GB SSD holds Xcode, the bundled simulator runtimes, the checkout, vendor/bundle, Pods, DerivedData, and, in a release lane, an .xcarchive plus the exported IPA. A large app with several pods and a fat DerivedData tree can run that disk close to full, and the 12x configuration carries 270GB.
At $0.16 against $0.08 per minute, the larger label lowers the cost of a job only when it cuts that job's wall clock by more than half. For a release lane that runs a handful of times a day, wall clock usually matters more than the per-job cost. For a test lane on every push, the per-job cost dominates.
Worked cost model
Take a six-engineer iOS team on a 22 weekday month.
Test lane: 40 pull request pushes per weekday, 9 minutes each, on warp-macos-15-arm64-6x.
- 40 pushes x 22 days = 880 jobs
- 880 jobs x 9 minutes = 7,920 minutes
- 7,920 minutes x $0.08 = $633.60
Release lane: 5 merges to the main branch per weekday, 19 minutes each, on warp-macos-26-arm64-12x.
- 5 merges x 22 days = 110 jobs
- 110 jobs x 19 minutes = 2,090 minutes
- 2,090 minutes x $0.16 = $334.40
Monthly total: $633.60 + $334.40 = $968.00.
That total is the whole bill for runner time.
Against GitHub-hosted macOS list prices
GitHub publishes its per-minute rates in the GitHub Actions minute multipliers reference. The rows below were checked on 2026-08-13.
| Runner | Architecture | Shape | Per-minute rate |
|---|---|---|---|
GitHub-hosted macOS standard (actions_macos) | arm64 or x64 | 3 or 4 cores | $0.062 |
GitHub-hosted macOS larger, arm64 (macos_xl) | arm64 | 5 vCPU, 14 GB | $0.102 |
GitHub-hosted macOS larger, x64 (macos_l) | x64 | 12 cores | $0.077 |
warp-macos-15-arm64-6x | arm64 | 6 vCPU, 22GB | $0.080 |
warp-macos-15-arm64-12x | arm64 | 12 vCPU, 44GB | $0.160 |
The like-for-like row is the arm64 larger runner: warp-macos-latest-arm64-6x (6 vCPU, 22 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13. The gap is $0.022 per minute, on one more vCPU and 8 GB more memory.
Applied to the test lane above: 7,920 minutes x $0.102 = $807.84 on the GitHub-hosted arm64 larger runner against $633.60 on warp-macos-15-arm64-6x, a difference of $174.24 per month on that lane alone. Note that GitHub rounds each job up to the nearest whole minute, which weighs on repositories running many short lanes.
Bottlenecks
Four things dominate fastlane wall clock time on GitHub Actions, and the compiler is only one of them.
Gem installs on every run
Fastlane arrives as a gem, and a typical Gemfile pulls in fastlane, cocoapods, xcpretty, and a handful of plugins. Resolving and building those native extensions from scratch is a fixed tax paid on every job, because the runner storage is deleted when the runner terminates.
Cache vendor/bundle keyed on Gemfile.lock. The key is exact, so a lockfile change misses cleanly and a restore key prefix is enough of a fallback. Keep FASTLANE_SKIP_UPDATE_CHECK set so the lane does not spend a network round trip checking RubyGems for a newer fastlane on every invocation.
CocoaPods resolution
pod install does two separate things: it resolves the dependency graph against the specs repositories, and it downloads and integrates each pod. The resolution step is the slow half on a cold machine, because CocoaPods has to fetch specs before it can resolve anything.
Cache Pods and ~/Library/Caches/CocoaPods together, keyed on Podfile.lock, and run pod install --deployment so the job fails loudly when the lockfile and the manifest disagree instead of quietly re-resolving. Commit Podfile.lock, which most teams already do, or the cache key changes on every run and the cache never helps.
Keychain and signing setup
Signing failures look like build failures and land late, after the archive has already compiled. The pattern that survives on ephemeral runners is the one in the Fastfile above: setup_ci first, match in readonly mode second, gym third.
setup_ci exists because the runner has no unlocked login keychain and no interactive user. It creates a temporary keychain, unlocks it, and sets a partition list so codesign reads the private key instead of blocking on an authorization prompt that no one can answer. A lane that calls match without setup_ci on a headless macOS runner hangs until the job timeout fires.
The ephemeral model removes the other half of the problem. Each job gets a fresh VM and its storage is destroyed when the runner terminates, so a certificate imported for one job cannot leak into the next, and there is no stale keychain to clean up between runs. Credential handling is described in the runner security documentation.
Archive and export time
gym is the longest step in a release lane. It runs a Release configuration build with whole-module optimization, generates dSYMs, and then runs -exportArchive to produce the IPA, and the export step alone can take minutes on a large app because it re-signs every embedded framework and extension.
Two levers help. Set derived_data_path so the archive reuses build products across steps in the same job rather than writing to a random path per invocation. And keep the release lane off the pull request trigger entirely, so the expensive path runs on merges rather than on every push.
When a lane fails on the runner and not on a laptop, the Action Debugger pauses the workflow and opens an SSH session on the runner machine, which is the shortest route to inspecting a keychain, a provisioning profile, or a Podfile.lock in place.
Proof
Public repositories running warp- labels are readable evidence for the macOS half of this setup. manaflow-ai/cmux shards its app-host XCTest suite across warp-macos-15-arm64-6x, the same label the test job above uses (checked on 2026-08-13).
The migration cost is a label. The macOS images carry the same tooling as GitHub-hosted macOS runners, so fastlane, bundler, pod, xcodebuild, and security behave the same way and marketplace actions keep working. Two parts of the product surface earn their keep here: CI observability reports right-sizing recommendations per repository, workflow, job, and instance type from measured CPU and memory utilization, which settles the 6x against 12x question faster than guessing, and the Action Debugger opens a shell on a runner when a signing failure will not reproduce locally.
Rates, sizes, and labels for every platform are on the WarpBuild pricing page, and the full macOS lineup sits on the macOS runners hub. For the wider iOS pipeline around the lanes, see iOS builds on GitHub Actions. For the certificate and profile mechanics underneath match, see code signing iOS builds on GitHub Actions, and for the compile side of the archive lane, see cutting Xcode build times on GitHub Actions.
FAQ
Which runner label should a fastlane lane use?
Use warp-macos-15-arm64-6x (6 vCPU, 22GB, 120GB SSD, $0.08 per minute) for a test or lint lane, and warp-macos-26-arm64-12x (12 vCPU, 44GB, 270GB SSD, $0.16 per minute) for a lane that runs gym and uploads a build. Both labels go straight into runs-on.
How do I stop bundle install and pod install from running from scratch every job?
Cache vendor/bundle keyed on Gemfile.lock and cache Pods plus ~/Library/Caches/CocoaPods keyed on Podfile.lock with actions/cache@v4. Every job starts on a fresh VM whose storage is destroyed when the runner terminates, so without those caches both installs resolve and download on every run.
Does fastlane match or a signing keychain work on an ephemeral runner?
Yes. Call setup_ci at the top of the release lane so fastlane creates a temporary keychain for the job, then call match in readonly mode. The runner VM and its storage are destroyed when the job finishes, so nothing is left behind.
What does a fastlane minute cost against GitHub-hosted macOS runners?
warp-macos-15-arm64-6x is $0.08 per minute. GitHub lists its arm64 macOS larger runner at $0.102 per minute and its standard 3-core or 4-core macOS runner at $0.062 per minute, checked on 2026-08-13. On the like-for-like Apple Silicon larger runner the difference is $0.022 per minute.
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.