```html SwiftUI: How to Implement CI with Fastlane (iOS 17+, 2026)

How to implement CI with fastlane in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: fastlane / match Updated: May 11, 2026
TL;DR

Add fastlane to a Gemfile, run fastlane match init to centralise code-signing, then define test, beta, and release lanes in your Fastfile. Your CI provider calls bundle exec fastlane beta and the rest is automated.

# Gemfile
source "https://rubygems.org"
gem "fastlane"

# Fastfile (minimal)
default_platform(:ios)

platform :ios do
  lane :beta do
    match(type: "appstore")
    build_app(scheme: "MyApp")
    upload_to_testflight
  end
end

Full implementation

The setup below covers four files: a Gemfile that pins the fastlane version, a Matchfile that stores code-signing settings, a production-ready Fastfile with test / beta / release lanes, and a GitHub Actions workflow that ties them together. Each lane is independently callable locally (bundle exec fastlane test) or from CI, so your machine and the build server stay in sync. Match uses App Store Connect API keys (keyfile or environment variables) so no Apple ID passwords are stored anywhere.

Gemfile

source "https://rubygems.org"

gem "fastlane", "~> 2.225"

fastlane/Matchfile

git_url("https://github.com/your-org/ios-certificates")

storage_mode("git")          # or "google_cloud" / "s3"
type("appstore")             # default; overridden per lane

app_identifier(["com.example.MyApp"])
username("ci@example.com")  # optional when using API key

fastlane/Fastfile

default_platform(:ios)

# -- Shared helpers ----------------------------------------------------------

def asc_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"],   # base-64 encoded .p8
    is_key_content_base64: true
  )
end

# ---------------------------------------------------------------------------

platform :ios do

  before_all do
    # Ensure working tree is clean on CI; skip locally
    ensure_git_status_clean if ENV["CI"]
  end

  # Run tests without signing
  lane :test do
    run_tests(
      scheme:              "MyApp",
      device:              "iPhone 16 Pro",
      code_coverage:       true,
      result_bundle:       true,
      output_directory:    "fastlane/test_output",
      xcargs:              "-maximum-parallel-testing-workers 4"
    )
  end

  # Build + upload to TestFlight
  lane :beta do
    api_key = asc_api_key

    match(
      type:    "appstore",
      api_key: api_key,
      readonly: ENV["CI"] ? true : false  # CI reads; local can refresh
    )

    increment_build_number(
      build_number: latest_testflight_build_number(api_key: api_key) + 1
    )

    build_app(
      scheme:          "MyApp",
      configuration:   "Release",
      export_method:   "app-store",
      output_directory: "build"
    )

    upload_to_testflight(
      api_key:           api_key,
      skip_waiting_for_build_processing: true,
      changelog:         last_git_commit[:message]
    )

    clean_build_artifacts
  end

  # Promote latest TestFlight build to App Store
  lane :release do
    api_key = asc_api_key

    deliver(
      api_key:        api_key,
      submit_for_review: true,
      automatic_release: false,
      force:          true,          # skip HTML preview
      precheck_include_in_app_purchases: false
    )
  end

  # Refresh / create certs + profiles locally
  lane :certs do
    match(type: "development", api_key: asc_api_key)
    match(type: "appstore",    api_key: asc_api_key)
  end

  after_all do |lane|
    # Optionally notify Slack / Discord on success
    # slack(message: "#{lane} succeeded ✓")
  end

  error do |lane, exception|
    # slack(message: "#{lane} failed: #{exception.message}", success: false)
    UI.error("Lane #{lane} failed: #{exception.message}")
  end

end

.github/workflows/ios-ci.yml

name: iOS CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode 16
        run: sudo xcode-select -s /Applications/Xcode_16.app

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3"
          bundler-cache: true

      - name: Run tests
        run: bundle exec fastlane test

  beta:
    needs: test
    runs-on: macos-15
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode 16
        run: sudo xcode-select -s /Applications/Xcode_16.app

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3"
          bundler-cache: true

      - name: Deploy to TestFlight
        env:
          ASC_KEY_ID:      ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID:   ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
          MATCH_PASSWORD:  ${{ secrets.MATCH_PASSWORD }}
          GIT_SSH_KEY:     ${{ secrets.GIT_SSH_KEY }}
        run: bundle exec fastlane beta

How it works

  1. App Store Connect API key (asc_api_key helper) — Instead of an Apple ID and password, the helper reads three environment variables (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_CONTENT) and returns a reusable key object that fastlane actions accept directly. The .p8 file is base-64 encoded and stored as a CI secret — no file on disk needed.
  2. match in readonly mode on CI — The readonly: ENV["CI"] ? true : false guard prevents CI from accidentally creating or overwriting certificates. Certificates live in the private ios-certificates repo, encrypted at rest with MATCH_PASSWORD. Locally you run bundle exec fastlane certs to provision a new machine.
  3. Auto-incrementing build numberlatest_testflight_build_number + 1 queries the current highest build number from App Store Connect and bumps it automatically, so you never manually edit CURRENT_PROJECT_VERSION in Xcode.
  4. Two-job GitHub Actions pipeline — The test job runs on every push and PR; the beta job runs only on merges to main and requires the test job to pass first (needs: test). This prevents broken builds reaching TestFlight.
  5. Artifact cleanup (clean_build_artifacts) — Removes the signed .ipa and .dSYM from the runner after upload, preventing accidental exposure of signed binaries in CI logs or artifact storage.

Variants

Using S3 instead of a git repo for match

# fastlane/Matchfile  — S3 variant
storage_mode("s3")
s3_bucket("my-company-ios-certs")
s3_region("us-east-1")
# s3_access_key and s3_secret_access_key are read from env:
#   MATCH_S3_ACCESS_KEY  /  MATCH_S3_SECRET_ACCESS_KEY
# Or use an IAM role on your CI runner — no keys needed at all.

app_identifier(["com.example.MyApp", "com.example.MyApp.NotificationExtension"])
type("appstore")

S3 storage is preferred for teams already on AWS — bucket access policies replace SSH key management, and versioning keeps a full history of every certificate rotation.

Running Sonar / SwiftLint as a pre-flight lane

lane :lint do
  swiftlint(
    mode:          :lint,
    config_file:   ".swiftlint.yml",
    raise_if_swiftlint_error: true,
    strict:        true
  )
end

# Then in your CI workflow add a step before `test`:
# run: bundle exec fastlane lint

Keeping lint as its own lane means it runs in under 30 seconds without spinning up a simulator, making PR feedback fast. Hook it into a before_all block if you want it gating every lane.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement CI with fastlane for an iOS 17+ SwiftUI app.
Use fastlane/match with App Store Connect API key auth.
Create lanes: test, beta (TestFlight), release, certs.
Add a GitHub Actions workflow (macos-15, Xcode 16).
Include MATCH_PASSWORD and ASC_KEY_CONTENT as env vars.
Add README comments explaining each secret needed.

In the Soarias Build phase, running this prompt wires your fastlane setup into the same project scaffold that Soarias generates — so your CI pipeline is ready before you write your first SwiftUI view.

Related

FAQ

Does this work on iOS 16?
Yes — fastlane and match are build-time tools and have no runtime iOS version dependency. The IPHONEOS_DEPLOYMENT_TARGET in your Xcode project controls the minimum supported iOS version. You can target iOS 16 (or lower) while using the exact same Fastfile; just update your project's deployment target.
Can I use fastlane match with a team that has multiple app targets (app + extensions)?
Absolutely. Pass an array to app_identifier in your Matchfile — e.g. ["com.example.MyApp", "com.example.MyApp.ShareExtension", "com.example.MyApp.WidgetExtension"]. Match will fetch or create a separate provisioning profile for each bundle ID and install all of them. Make sure each extension's bundle ID also exists in App Store Connect before the first run.
What's the UIKit / Xcode-native equivalent?
Xcode Cloud is Apple's native CI/CD alternative — it's embedded directly in Xcode and App Store Connect, handles code signing automatically, and supports custom scripts via ci_post_clone.sh hooks. The tradeoff: Xcode Cloud is tied to Apple's infrastructure (limited free compute hours, no on-prem), whereas fastlane runs on any macOS CI runner and gives you full control over each step. Many teams use both: fastlane locally and on self-hosted runners, Xcode Cloud for nightly distribution builds.

Last reviewed: 2026-05-11 by the Soarias team.

```