How to implement CI with fastlane in SwiftUI
Add fastlane to a Gemfile, run fastlane match init to centralise code-signing, then define test, beta, and release lanes in your Fastfile.
Your CI provider calls bundle exec fastlane beta and the rest is automated.
# Gemfile
source "https://rubygems.org"
gem "fastlane"
# Fastfile (minimal)
default_platform(:ios)
platform :ios do
lane :beta do
match(type: "appstore")
build_app(scheme: "MyApp")
upload_to_testflight
end
end
Full implementation
The setup below covers four files: a Gemfile that pins the fastlane version, a Matchfile that stores code-signing settings, a production-ready Fastfile with test / beta / release lanes, and a GitHub Actions workflow that ties them together.
Each lane is independently callable locally (bundle exec fastlane test) or from CI, so your machine and the build server stay in sync.
Match uses App Store Connect API keys (keyfile or environment variables) so no Apple ID passwords are stored anywhere.
Gemfile
source "https://rubygems.org"
gem "fastlane", "~> 2.225"
fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates")
storage_mode("git") # or "google_cloud" / "s3"
type("appstore") # default; overridden per lane
app_identifier(["com.example.MyApp"])
username("ci@example.com") # optional when using API key
fastlane/Fastfile
default_platform(:ios)
# -- Shared helpers ----------------------------------------------------------
def asc_api_key
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"], # base-64 encoded .p8
is_key_content_base64: true
)
end
# ---------------------------------------------------------------------------
platform :ios do
before_all do
# Ensure working tree is clean on CI; skip locally
ensure_git_status_clean if ENV["CI"]
end
# Run tests without signing
lane :test do
run_tests(
scheme: "MyApp",
device: "iPhone 16 Pro",
code_coverage: true,
result_bundle: true,
output_directory: "fastlane/test_output",
xcargs: "-maximum-parallel-testing-workers 4"
)
end
# Build + upload to TestFlight
lane :beta do
api_key = asc_api_key
match(
type: "appstore",
api_key: api_key,
readonly: ENV["CI"] ? true : false # CI reads; local can refresh
)
increment_build_number(
build_number: latest_testflight_build_number(api_key: api_key) + 1
)
build_app(
scheme: "MyApp",
configuration: "Release",
export_method: "app-store",
output_directory: "build"
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true,
changelog: last_git_commit[:message]
)
clean_build_artifacts
end
# Promote latest TestFlight build to App Store
lane :release do
api_key = asc_api_key
deliver(
api_key: api_key,
submit_for_review: true,
automatic_release: false,
force: true, # skip HTML preview
precheck_include_in_app_purchases: false
)
end
# Refresh / create certs + profiles locally
lane :certs do
match(type: "development", api_key: asc_api_key)
match(type: "appstore", api_key: asc_api_key)
end
after_all do |lane|
# Optionally notify Slack / Discord on success
# slack(message: "#{lane} succeeded ✓")
end
error do |lane, exception|
# slack(message: "#{lane} failed: #{exception.message}", success: false)
UI.error("Lane #{lane} failed: #{exception.message}")
end
end
.github/workflows/ios-ci.yml
name: iOS CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16
run: sudo xcode-select -s /Applications/Xcode_16.app
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
- name: Run tests
run: bundle exec fastlane test
beta:
needs: test
runs-on: macos-15
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16
run: sudo xcode-select -s /Applications/Xcode_16.app
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
- name: Deploy to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
GIT_SSH_KEY: ${{ secrets.GIT_SSH_KEY }}
run: bundle exec fastlane beta
How it works
-
App Store Connect API key (
asc_api_keyhelper) — Instead of an Apple ID and password, the helper reads three environment variables (ASC_KEY_ID,ASC_ISSUER_ID,ASC_KEY_CONTENT) and returns a reusable key object that fastlane actions accept directly. The.p8file is base-64 encoded and stored as a CI secret — no file on disk needed. -
match in
readonlymode on CI — Thereadonly: ENV["CI"] ? true : falseguard prevents CI from accidentally creating or overwriting certificates. Certificates live in the privateios-certificatesrepo, encrypted at rest withMATCH_PASSWORD. Locally you runbundle exec fastlane certsto provision a new machine. -
Auto-incrementing build number —
latest_testflight_build_number + 1queries the current highest build number from App Store Connect and bumps it automatically, so you never manually editCURRENT_PROJECT_VERSIONin Xcode. -
Two-job GitHub Actions pipeline — The
testjob runs on every push and PR; thebetajob runs only on merges tomainand requires thetestjob to pass first (needs: test). This prevents broken builds reaching TestFlight. -
Artifact cleanup (
clean_build_artifacts) — Removes the signed.ipaand.dSYMfrom the runner after upload, preventing accidental exposure of signed binaries in CI logs or artifact storage.
Variants
Using S3 instead of a git repo for match
# fastlane/Matchfile — S3 variant
storage_mode("s3")
s3_bucket("my-company-ios-certs")
s3_region("us-east-1")
# s3_access_key and s3_secret_access_key are read from env:
# MATCH_S3_ACCESS_KEY / MATCH_S3_SECRET_ACCESS_KEY
# Or use an IAM role on your CI runner — no keys needed at all.
app_identifier(["com.example.MyApp", "com.example.MyApp.NotificationExtension"])
type("appstore")
S3 storage is preferred for teams already on AWS — bucket access policies replace SSH key management, and versioning keeps a full history of every certificate rotation.
Running Sonar / SwiftLint as a pre-flight lane
lane :lint do
swiftlint(
mode: :lint,
config_file: ".swiftlint.yml",
raise_if_swiftlint_error: true,
strict: true
)
end
# Then in your CI workflow add a step before `test`:
# run: bundle exec fastlane lint
Keeping lint as its own lane means it runs in under 30 seconds without spinning up a simulator, making PR feedback fast. Hook it into a before_all block if you want it gating every lane.
Common pitfalls
-
macOS 15 / Xcode 16 path changes. Always pin the Xcode version explicitly with
sudo xcode-select -s /Applications/Xcode_16.appin CI. The defaultmacos-15runner ships with multiple Xcode versions and the default can change between GitHub Actions image updates, silently breaking your build number resolution. -
Match password mismatch. If a teammate re-runs
match initwith a differentMATCH_PASSWORD, the certificates repo gets re-encrypted and CI immediately breaks. StoreMATCH_PASSWORDin a shared secrets manager (1Password Secrets Automation, AWS Secrets Manager, etc.) rather than letting individuals set it. -
Parallel testing and simulator resource contention. Setting
-maximum-parallel-testing-workerstoo high on a shared CI runner causes flaky tests. Start with 2 workers and raise only after confirming stable pass rates. On GitHub-hosted runners with 3-core machines, 2 is usually the sweet spot.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement CI with fastlane for an iOS 17+ SwiftUI app. Use fastlane/match with App Store Connect API key auth. Create lanes: test, beta (TestFlight), release, certs. Add a GitHub Actions workflow (macos-15, Xcode 16). Include MATCH_PASSWORD and ASC_KEY_CONTENT as env vars. Add README comments explaining each secret needed.
In the Soarias Build phase, running this prompt wires your fastlane setup into the same project scaffold that Soarias generates — so your CI pipeline is ready before you write your first SwiftUI view.
Related
FAQ
Does this work on iOS 16?
IPHONEOS_DEPLOYMENT_TARGET in your Xcode project controls the minimum supported iOS version. You can target iOS 16 (or lower) while using the exact same Fastfile; just update your project's deployment target.
Can I use fastlane match with a team that has multiple app targets (app + extensions)?
app_identifier in your Matchfile — e.g. ["com.example.MyApp", "com.example.MyApp.ShareExtension", "com.example.MyApp.WidgetExtension"]. Match will fetch or create a separate provisioning profile for each bundle ID and install all of them. Make sure each extension's bundle ID also exists in App Store Connect before the first run.
What's the UIKit / Xcode-native equivalent?
ci_post_clone.sh hooks. The tradeoff: Xcode Cloud is tied to Apple's infrastructure (limited free compute hours, no on-prem), whereas fastlane runs on any macOS CI runner and gives you full control over each step. Many teams use both: fastlane locally and on self-hosted runners, Xcode Cloud for nightly distribution builds.
Last reviewed: 2026-05-11 by the Soarias team.