How would you set up CI/CD for an iOS app?
TL;DR
CI (continuous integration): on every pull request, build the app and run tests on a macOS machine. CD (continuous delivery): on merge or a tag, archive a signed build and upload it to TestFlight, then to the App Store. Common tools: Xcode Cloud, GitHub Actions, Bitrise, CircleCI, plus fastlane for scripting. The hard parts are code signing, build time and flaky tests.
A typical pipeline
Pull request: checkout -> resolve packages (cached) -> lint -> build -> unit tests -> report
Merge to main: ... -> bump build number -> archive -> export IPA -> upload to TestFlight
Release tag: ... -> submit for review (often still a manual click)The core commands
# Run tests
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-resultBundlePath TestResults.xcresult
# Archive
xcodebuild archive \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive
# Export an IPA (signing details are in ExportOptions.plist)
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath build/fastlane
fastlane wraps these steps in Ruby "lanes".
lane :beta do
setup_ci
match(type: "appstore", readonly: true)
increment_build_number(build_number: ENV["GITHUB_RUN_NUMBER"])
build_app(scheme: "MyApp", configuration: "Release")
upload_to_testflight(skip_waiting_for_build_processing: true)
endmatch: fetches signing certificates and profiles from an encrypted store.upload_to_testflight: uploads with an App Store Connect API key (no Apple ID password or 2FA on CI).
GitHub Actions example
name: CI
on: [pull_request]
jobs:
test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
key: spm-${{ hashFiles('**/Package.resolved') }}
- run: sudo xcode-select -s /Applications/Xcode_26.0.app
- run: xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 17'Pin the Xcode version. Runner images change and can break builds.
Xcode Cloud
Apple's hosted CI, set up from Xcode. Workflows run on start conditions (PR, branch, tag). It handles signing for you and uploads to TestFlight easily. Custom steps go in ci_scripts/ci_post_clone.sh and similar scripts.
Good for teams that want less setup. Less flexible than a fully scripted pipeline.
Code signing on CI
- Never commit certificates or private keys in plain form.
- Use fastlane match, CI secrets, or cloud-managed signing with an App Store Connect API key.
- Create a temporary keychain on the runner (fastlane
setup_cidoes this) and delete it after the job.
Build numbers
Every upload needs a unique, increasing build number (CFBundleVersion). Use the CI run number or a timestamp. The marketing version (CFBundleShortVersionString, like 2.3.0) changes per release.
Speed tips
- Cache Swift packages and other dependencies.
- Split jobs: lint and unit tests in parallel.
- Run UI tests only on main or at night if they are slow.
- Use
-parallel-testing-enabled YESand test plans. - Modularize, so tests for a package can run alone.
Quality gates to add
- SwiftLint or
swift-format. - Unit tests and code coverage report.
- Treat warnings as errors in CI for new code.
- Upload dSYMs to your crash reporter after archive.
Common follow-up questions
- Why are iOS CI runners expensive? They need macOS on Apple hardware. Minutes cost more than Linux.
- How do you avoid flaky tests blocking merges? Fix or quarantine them quickly, use test retries carefully, and mock the network.
- Can CI submit to App Review? Yes, with fastlane
deliveror the App Store Connect API. Many teams keep the final submit manual. - How do you ship hotfixes fast? Keep the pipeline green, use phased release, and consider feature flags so you can turn features off without a new build.