iosNudge

How would you set up CI/CD for an iOS app?

MediumToolingTesting 2 min read

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)
end
  • match: 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_ci does 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 YES and 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 deliver or 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.