How to Set Up TestFlight Deployment in SwiftUI
Use fastlane with the pilot action (upload_to_testflight) to archive your SwiftUI app and push it to TestFlight in a single terminal command. Pair it with match for zero-friction code signing.
# Gemfile
source "https://rubygems.org"
gem "fastlane"
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
lane :beta do
match(type: "appstore")
build_app(scheme: "MyApp")
upload_to_testflight(skip_waiting_for_build_processing: true)
end
end
# Run from your project root:
# $ fastlane beta
Full implementation
A production-grade TestFlight lane combines match for code signing, increment_build_number for automatic versioning, build_app for archiving, and upload_to_testflight (the pilot action) to ship the binary. Environment variables keep credentials out of source control, making this safe to run in CI as well as locally.
# ── Gemfile ────────────────────────────────────────────────
source "https://rubygems.org"
gem "fastlane"
# ── fastlane/Appfile ────────────────────────────────────────
app_identifier("com.example.MyApp")
apple_id("ci@example.com")
itc_team_id("123456789") # App Store Connect team ID
team_id("ABCDE12345") # Developer Portal team ID
# ── fastlane/Matchfile ──────────────────────────────────────
git_url("git@github.com:your-org/certificates.git")
type("appstore")
app_identifier(["com.example.MyApp"])
username("ci@example.com")
# ── fastlane/Fastfile ───────────────────────────────────────
default_platform(:ios)
platform :ios do
# ── Shared setup ──────────────────────────────────────────
before_all do
# Read secrets from env so nothing is hard-coded
ENV["FASTLANE_PASSWORD"] ||= ENV["ASC_PASSWORD"]
ENV["MATCH_PASSWORD"] ||= ENV["MATCH_PASSPHRASE"]
ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"] ||= ENV["APP_SPECIFIC_PASSWORD"]
end
# ── Beta lane: builds + uploads to TestFlight ─────────────
lane :beta do |options|
# 1. Sync distribution cert + provisioning profile
match(
type: "appstore",
readonly: is_ci # never regenerate certs in CI
)
# 2. Bump the build number from the latest TestFlight build
latest_build = latest_testflight_build_number(
app_identifier: "com.example.MyApp"
)
increment_build_number(build_number: latest_build + 1)
# 3. Archive the app
build_app(
scheme: "MyApp",
configuration: "Release",
export_method: "app-store",
export_options: {
provisioningProfiles: {
"com.example.MyApp" => "match AppStore com.example.MyApp"
}
},
output_directory: "./build",
output_name: "MyApp.ipa",
clean: true
)
# 4. Upload to TestFlight via pilot
upload_to_testflight(
ipa: "./build/MyApp.ipa",
skip_waiting_for_build_processing: true, # don't block the lane
distribute_external: false, # internal testers only initially
notify_external_testers: false,
changelog: options[:notes] || "New build from fastlane"
)
# 5. Notify Slack (optional – remove if unused)
slack(
message: "✅ MyApp #{get_version_number} (#{get_build_number}) uploaded to TestFlight!",
channel: "#ios-releases",
success: true
) if ENV["SLACK_URL"]
end
# ── Release lane: promote to external testers ─────────────
lane :release_beta do
upload_to_testflight(
distribute_external: true,
notify_external_testers: true,
groups: ["External Beta"],
changelog: "Bug fixes and performance improvements."
)
end
# ── Error handler ─────────────────────────────────────────
error do |lane, exception|
slack(
message: "❌ Lane #{lane} failed: #{exception.message}",
success: false
) if ENV["SLACK_URL"]
end
end
# ── Run it ──────────────────────────────────────────────────
# Local: fastlane beta notes:"Sprint 12 – dark mode fixes"
# CI env: fastlane beta
How it works
-
match(type: "appstore", readonly: is_ci)— Downloads your App Store distribution certificate and provisioning profile from a private git repo. Thereadonly:flag prevents CI from accidentally regenerating certs, which would revoke existing ones. -
latest_testflight_build_number+increment_build_number— Queries App Store Connect via the pilot API to find the last submitted build number, then bumps by one. This avoids "build number already exists" rejections without ever touching yourInfo.plistmanually. -
build_app— Runsxcodebuild archivethenxcodebuild -exportArchivewith the correct export options, producing a signed.ipaready for upload. TheprovisioningProfileshash tells Xcode which match-managed profile to use. -
upload_to_testflight(pilot) — Submits the.ipato App Store Connect.skip_waiting_for_build_processing: truereturns immediately after the upload succeeds, so your lane doesn't block for 10–20 minutes while Apple processes the binary. -
before_allenv block — Maps CI secret variables to the environment keys fastlane expects. This means the sameFastfileworks locally (where you may be logged in interactively) and in GitHub Actions / Xcode Cloud without any changes.
Variants
GitHub Actions CI integration
# .github/workflows/testflight.yml
name: TestFlight
on:
push:
branches: [main]
jobs:
deploy:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
- name: Install SSH key for match
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.MATCH_SSH_KEY }}
- name: Run fastlane beta
env:
ASC_PASSWORD: ${{ secrets.ASC_PASSWORD }}
MATCH_PASSPHRASE: ${{ secrets.MATCH_PASSPHRASE }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
SLACK_URL: ${{ secrets.SLACK_URL }}
run: bundle exec fastlane beta
Distributing to external testers immediately
Replace distribute_external: false with distribute_external: true and add a groups: ["Public Beta"] key in your upload_to_testflight call. Note that Apple still needs to approve external builds — set skip_waiting_for_build_processing: false and add a submit_beta_review: true key so fastlane automatically submits the build for review once processing completes. Be aware this can add 20–30 minutes to your lane runtime.
Common pitfalls
-
App-Specific Password required for non-API-key auth. Apple no longer allows your regular Apple ID password in CI. Either create an App Store Connect API key (
api_key_pathin fastlane) — the recommended path — or generate an app-specific password at appleid.apple.com and export it asFASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD. -
Build number conflicts. If two engineers run
fastlane betasimultaneously they may generate the same build number. Uselatest_testflight_build_number(which hits the API) rather than reading from the project file, and consider adding a shortsleepor a mutex in your CI queue to serialize uploads. -
Xcode version mismatch. Apple rejects builds archived with Xcode versions older than what's required for the current SDK. Pin your CI runner's Xcode with
xcodes select 16.xor thexcode-installfastlane plugin, and match it to the version used in local development to avoid surprise rejections after an OS update.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement TestFlight deployment for my SwiftUI iOS 17+ app. Use fastlane with the pilot (upload_to_testflight) action. Set up a beta lane with match for code signing, auto build number increment from latest TestFlight build, and a GitHub Actions workflow that reads secrets from env vars. Add inline comments explaining each fastlane action.
In the Soarias Build phase, paste this prompt after your SwiftUI screens are implemented — Soarias will scaffold the Fastfile, Matchfile, and CI YAML alongside your existing project structure.
Related
FAQ
Does this work on iOS 16?
The fastlane/pilot workflow is agnostic to your app's deployment target — it ships whatever .ipa Xcode produces. If your app targets iOS 16, simply set IPHONEOS_DEPLOYMENT_TARGET = 16.0 in your Xcode project and fastlane will archive it correctly. The guide highlights iOS 17+ APIs because that's the baseline Soarias apps use, but the pipeline itself has no iOS version constraint.
Should I use an App Store Connect API key instead of Apple ID credentials?
Yes — the API key approach (app_store_connect_api_key action or api_key_path parameter) is strongly preferred. It doesn't require two-factor authentication, never expires, and works reliably in headless CI environments. Generate a key with Developer role in App Store Connect → Users and Access → Integrations → App Store Connect API, download the .p8 file, and reference it in your Fastfile. The old Apple ID + app-specific password path still works but is increasingly fragile.
What's the UIKit / Xcode Cloud equivalent?
The UIKit equivalent is identical — fastlane doesn't care whether your UI layer uses UIKit or SwiftUI; it only builds and signs the .xcarchive. If you prefer Apple's native CI, Xcode Cloud offers built-in TestFlight distribution via a TestFlight Internal Testing post-action with zero fastlane setup required. The trade-off: Xcode Cloud is easier to start but offers less customisation (no arbitrary shell scripts, no Slack notifications) compared to a full fastlane lane.
Last reviewed: 2026-05-11 by the Soarias team.