← Articles

Code signing Flutter iOS apps in CI

By Charlin Joe · 23 October 2024

Code signing for iOS is the most common reason Flutter CI pipelines fail in ways that work perfectly on a developer's laptop. The laptop has certificates and provisioning profiles installed in the Keychain; the CI runner starts clean every time. This guide closes that gap.

What code signing actually does

Apple requires every app binary to be signed with a certificate that proves identity (who built this) and a provisioning profile that proves authorization (what devices can run this, what capabilities it uses). The private key lives in your Keychain; the certificate and profile live in the Apple Developer portal.

For CI you need to get the private key, certificate, and profile onto the runner without storing secrets in the repo.

Two approaches: Fastlane match vs manual

match stores encrypted certificates and profiles in a private Git repo (or S3/GCS). The CI runner clones that repo, decrypts the credentials, and installs them. Everyone on the team shares the same set.

Setup (one time):

bundle exec fastlane match init
# Choose storage: git
# Enter your private certs repo URL

bundle exec fastlane match appstore
# This generates and stores certificates + profiles

Fastfile:

platform :ios do
  lane :beta do
    setup_ci  # Creates a temporary Keychain on CI
    match(
      type: 'appstore',
      readonly: true,         # Never regenerate on CI
      git_url: ENV['MATCH_GIT_URL'],
      password: ENV['MATCH_PASSWORD'],
      app_identifier: 'com.example.myapp',
    )
    build_app(
      workspace: 'ios/Runner.xcworkspace',
      scheme: 'production',
      export_method: 'app-store',
    )
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end
end

Manual certificate export

If match is too much setup, export the certificate as a .p12 and store it as a base64-encoded secret:

# On your Mac, export the certificate from Keychain Access
openssl pkcs12 -export -out cert.p12 -inkey key.pem -in cert.pem
base64 -i cert.p12 | pbcopy  # Copy to clipboard, paste into GitHub secret

In CI, decode and install:

- name: Install certificate
  run: |
    echo "$IOS_CERT_BASE64" | base64 --decode > cert.p12
    security create-keychain -p "" build.keychain
    security import cert.p12 -k build.keychain -P "$IOS_CERT_PASSWORD" -T /usr/bin/codesign
    security list-keychains -s build.keychain
    security set-keychain-settings -t 3600 -u build.keychain
    security unlock-keychain -p "" build.keychain
    security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain

Complete GitHub Actions workflow

name: iOS Release
on:
  push:
    tags: ['v*']

jobs:
  ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.22.0'
          channel: 'stable'

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      - name: Flutter dependencies
        run: flutter pub get

      - name: iOS pod install
        run: cd ios && pod install --repo-update

      - name: Build & sign
        env:
          MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
        run: bundle exec fastlane ios beta

App Store Connect API key (replacing Apple ID auth)

App-specific passwords and Apple ID authentication break on CI. Use an API key instead:

  1. Go to App Store Connect → Users and Access → Keys
  2. Create a key with App Manager role
  3. Download the .p8 file and store its content as ASC_KEY_CONTENT secret

In Fastfile:

app_store_connect_api_key(
  key_id: ENV['APP_STORE_CONNECT_API_KEY_ID'],
  issuer_id: ENV['APP_STORE_CONNECT_API_ISSUER_ID'],
  key_content: ENV['APP_STORE_CONNECT_API_KEY_CONTENT'],
)

Multiple flavors

For apps with dev/staging/production flavors, each flavor usually has a different bundle ID and provisioning profile:

lane :release_production do
  setup_ci
  match(type: 'appstore', app_identifier: 'com.example.myapp')
  build_app(scheme: 'production')
  upload_to_testflight
end

lane :release_staging do
  setup_ci
  match(type: 'adhoc', app_identifier: 'com.example.myapp.staging')
  build_app(scheme: 'staging')
  firebase_app_distribution(app: ENV['FIREBASE_APP_ID_STAGING'])
end

Common pitfalls

setup_ci missing. Without it, match tries to use the default login Keychain, which doesn't exist on a CI runner. Always call setup_ci first.

Using readonly: false on CI. If the profile is expired and CI tries to regenerate it, it may hit Apple's rate limits or fail with MFA. Set readonly: true and regenerate profiles locally when needed.

Certificate expiry. Apple certificates expire after 1 year. Set a calendar reminder 2 weeks before expiry to run match nuke and recreate. An expired cert on CI silently fails the build.

Xcode version mismatch. The macos-latest runner Xcode version changes without notice. Pin it: xcode-select --install or use the maxim-lobanov/setup-xcode action.

CocoaPods cache miss. pod install takes 2–4 minutes. Cache ~/.cocoapods and ios/Pods keyed on ios/Podfile.lock.

Sign in to like, dislike, or report.

Code signing Flutter iOS apps in CI — ANN Tech