← Back to writing

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:

  1. Checkout: Pull the code and resolve dependencies.
  2. Lint: Run static analysis (SwiftLint).
  3. Test: Run unit and UI tests.
  4. Build: Sign and archive the app (.ipa).
  5. 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