CI/CD for iOS: A Practical Guide
CI/CD for iOS: a realistic setup of the whole release pipeline with Fastlane, GitHub Actions and Apple services.
1. Introduction: Why CI/CD Is Hard on iOS
Continuous integration and continuous delivery (CI/CD) make every commit build, test, and deploy automatically. On web or backend projects this is relatively easy. On iOS there are three special challenges: code signing is tied to Apple’s certificate and profile system, builds can only run on macOS machines, and build times are long. This guide explains how to set up a pipeline that overcomes these challenges in a practical way.
2. The Stages of the Pipeline
A typical iOS pipeline follows this order:
- Checkout: Pull the code and resolve dependencies.
- Lint: Run static analysis (SwiftLint).
- Test: Run unit and UI tests.
- Build: Sign and archive the app (
.ipa). - Deploy: Upload to TestFlight or App Store Connect.
If any stage fails, the pipeline stops. This way broken code never reaches the deploy stage.
3. Build Automation: Fastlane
Raw xcodebuild commands are powerful but long and hard to read. Fastlane wraps these commands into readable lanes. Below is an example Fastfile:
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Run the tests"
lane :test do
run_tests(
scheme: "MyApp",
devices: ["iPhone 15"],
clean: true
)
end
desc "Upload a beta to TestFlight"
lane :beta do
setup_ci
match(type: "appstore", readonly: true)
build_app(
scheme: "MyApp",
export_method: "app-store"
)
upload_to_testflight(skip_waiting_for_build_processing: true)
end
end
Doing the same job with raw xcodebuild would look like this:
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-resultBundlePath ./TestResults \
CODE_SIGNING_ALLOWED=NO
4. Code Signing: The Most Critical Step
Signing is the part that causes the most trouble on CI. Managing certificates by hand on every developer’s machine is not sustainable. Fastlane’s match tool stores certificates and profiles in an encrypted git repository and distributes the same set to every machine, including CI. It creates a single source of truth.
# Initial setup (run once)
fastlane match init
fastlane match appstore
# On CI (in readonly mode)
fastlane match appstore --readonly
In a CI environment, setup_ci is called to create a temporary keychain. This loads the certificates into a temporary, isolated space:
lane :beta do
setup_ci # creates a temporary keychain
match(type: "appstore", readonly: true)
# ...
end
5. A Full Workflow with GitHub Actions
The workflow below triggers tests when a PR is opened and a TestFlight deploy when code is pushed to main:
# .github/workflows/ios.yml
name: iOS CI
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/Library/Caches/org.swift.swiftpm
.build
key: spm-${{ hashFiles('**/Package.resolved') }}
- name: Run SwiftLint
run: swiftlint --strict
- name: Run tests
run: fastlane test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Upload to TestFlight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
run: fastlane beta
The deploy job runs only when the test job succeeds and the branch is main. This ordering makes it impossible to ship broken code.
6. Authentication with the App Store Connect API
An Apple ID password and two factor authentication are not suitable for CI. Instead, an App Store Connect API key (ES256 JWT based) is used. This key is created once and given to CI as a secure secret:
# Using the API key inside the Fastfile
lane :beta do
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"], # the .p8 content
is_key_content_base64: true
)
build_app(scheme: "MyApp")
upload_to_testflight(api_key: api_key)
end
This method is fully automated and secure because it requires no password.
7. Managing Secrets
No certificate, password, or key should ever sit in the repository as plain text. All sensitive values are kept in the CI provider’s secret store and injected as environment variables at runtime. Files like .p8 can be base64 encoded and stored as a single secret:
# On a local machine, convert the .p8 to base64 and add the output as a secret
base64 -i AuthKey_ABC123.p8 | pbcopy
If you need to decode it back to a file inside the pipeline:
echo "$ASC_KEY_CONTENT" | base64 --decode > AuthKey.p8
Avoid printing secrets to the log. Most CI providers automatically mask secret values, but caution is still warranted.
8. Shortening Build Time
iOS builds are slow and CI minutes cost money. The main techniques to cut time:
- Dependency cache: Cache Swift Package Manager dependencies (shown in the workflow above).
- Derived data cache: Cache build outputs where possible.
- Test splitting: Move UI tests into a separate job and run them in parallel, independent of unit tests.
- Run only what is needed: On PRs, target a single reference simulator rather than the full device matrix.
# Example of splitting unit and UI tests in parallel
strategy:
matrix:
test-plan: [UnitTests, UITests]
steps:
- run: fastlane test plan:${{ matrix.test-plan }}
9. Versioning and Automatic Build Numbers
Every TestFlight upload requires a unique build number. Managing this by hand is error prone. Automate it on CI:
lane :beta do
latest = latest_testflight_build_number(version: get_version_number)
increment_build_number(build_number: latest + 1)
build_app(scheme: "MyApp")
upload_to_testflight
end
This reads the last build number from App Store Connect and assigns one higher, so a conflict never happens.
10. Conclusion
Setting up a solid CI/CD pipeline on iOS comes down to solving three core challenges: centralizing signing with match, automating authentication with an App Store Connect API key, and managing build time through caching. When you order the stages as lint, test, build, and deploy and tie each one to the success of the previous, it becomes impossible for broken code to reach users. A well built pipeline reduces releasing from a stressful ritual to a single merge.
← Back to writing