Skip to main content
mobile1. März 202610 Min. Lesezeit

iOS-Deployments automatisieren: App Store Connect und TestFlight

Ein vollständiger Leitfaden zur Automatisierung von iOS-App-Deployments — vom Fastlane-Setup über TestFlight-Distribution bis zur App-Store-Einreichung.

iosfastlanecicd
iOS-Deployments automatisieren: App Store Connect und TestFlight

Jeder iOS-Entwickler hat das Ritual durchgemacht: Xcode öffnen, Build-Nummer erhöhen, das Archive-Schema auswählen, fünfzehn Minuten warten, sich durch den Organizer klicken, zu App Store Connect hochladen, weiter warten, dann manuell Tester in TestFlight hinzufügen. Machen Sie das zweimal pro Woche über mehrere Apps hinweg, und Sie verlieren Stunden, die Sie mit Code schreiben verbringen sollten. Das Schlimmste ist nicht die Zeit — es ist die Inkonsistenz. Ein vergessener Schritt, ein falsches Provisioning Profile, und der Build scheitert stillschweigend oder wird Stunden später von Apples automatisierten Prüfungen abgelehnt.

Ich habe diese gesamte Pipeline für eine Flutter-basierte iOS-App automatisiert und die gleichen Patterns seitdem auf andere Projekte angewandt. Die Investition amortisiert sich nach dem dritten oder vierten Release-Zyklus.

Warum manuelle Deployments scheitern

Der manuelle Prozess hat drei grundlegende Probleme jenseits des offensichtlichen Zeitverlusts.

Erstens, Wissenskonzentration. Wenn nur eine Person im Team weiß, wie man deployt, wird sie zum Flaschenhals. Urlaub, Krankheitstage oder einfach eine andere Zeitzone bedeuten, dass Releases ins Stocken geraten. Dokumentation hilft, aber ein 15-Schritte-Deployment-Leitfaden ist selbst eine Schwachstelle — er veraltet in dem Moment, in dem jemand eine Build-Einstellung ändert.

Zweitens, menschliche Fehler zum schlechtesten Zeitpunkt. Releases passieren tendenziell unter Druck. Ein kritischer Bugfix, eine Deadline vom Kunden, ein Feature, das vor einem Wettbewerber ausgeliefert werden muss. Das sind genau die Bedingungen, unter denen jemand vergisst, die Build-Nummer zu erhöhen, das falsche Schema auswählt oder einen Debug-Build statt Release hochlädt.

Drittens, fehlende Nachvollziehbarkeit. Wenn etwas bei einem Release schiefgeht, möchten Sie genau wissen, was gebaut wurde, von welchem Commit, mit welcher Konfiguration. Manuelle Prozesse hinterlassen keine zuverlässige Spur.

Fastlane: Das Fundament

Fastlane ist der De-facto-Standard für iOS-Deployment-Automatisierung, und das aus gutem Grund. Es übernimmt Code Signing, Building, Hochladen und Metadaten-Management durch eine Ruby-basierte DSL, die sich wie eine Deployment-Checkliste liest.

Ersteinrichtung

Fastlane in einem bestehenden Projekt einzurichten ist unkompliziert.

# Install Fastlane
brew install fastlane

# Initialize in your project directory (for Flutter, run in ios/)
cd ios
fastlane init

Während der Initialisierung fragt Fastlane, was Sie automatisieren möchten. Ich empfehle, das manuelle Setup zu wählen und alles explizit zu konfigurieren — das automatische Setup macht Annahmen, die nicht immer zur Projektstruktur passen, besonders bei Flutter-Apps.

Das Ergebnis ist ein fastlane/-Verzeichnis mit einem Appfile und einem Fastfile.

# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("your@email.com")
itc_team_id("123456789")
team_id("ABCD1234EF")

Das Appfile speichert Ihre Apple-Developer-Account-Details. Die Team-IDs sind wichtig, wenn Ihre Apple-ID zu mehreren Teams gehört — ohne sie wird Fastlane interaktiv nachfragen, was CI unterbricht.

Code Signing mit Match

Code Signing ist der Punkt, an dem die meisten iOS-Automatisierungsversuche scheitern. Provisioning Profiles, Zertifikate, Entitlements — das gesamte System scheint darauf ausgelegt zu sein, Automatisierung zu bestrafen. Fastlanes match-Tool löst dies, indem es Ihre Zertifikate und Profile in einem privaten Git-Repository oder Cloud-Speicher ablegt und sie so jedem Computer zugänglich macht, der bauen muss.

Match einrichten

fastlane match init

Dies erstellt ein Matchfile, in dem Sie Ihr Storage-Backend konfigurieren.

# fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.yourapp"])

Dann generieren und speichern Sie Ihre Zertifikate:

# Generate development certificates and profiles
fastlane match development

# Generate App Store distribution certificates and profiles
fastlane match appstore

Match verschlüsselt alles, bevor es ins Repository gepusht wird. Sie legen während des Setups eine Passphrase fest, die Sie auf jedem Computer benötigen — einschließlich CI.

Warum Match statt manuelles Signing

Die Alternative — Zertifikate manuell über das Apple Developer Portal zu verwalten und .p12-Dateien zu verteilen — funktioniert, bis sie es nicht mehr tut. Häufige Fehlerquellen:

  • Das Zertifikat eines Entwicklers läuft ab und niemand bemerkt es, bis ein Release-Build scheitert.
  • Jemand widerruft ein Zertifikat beim Debuggen eines Signing-Problems und macht damit alle davon abhängigen Profile ungültig.
  • Ein neuer CI-Computer muss provisioniert werden und die Person, die den ursprünglichen Computer eingerichtet hat, ist nicht mehr im Team.

Match eliminiert all das, indem es die einzige Quelle der Wahrheit ist. Wenn ein Zertifikat abläuft, führen Sie fastlane match nuke aus und generieren neu. Jeder Computer zieht aus demselben Repository.

CI Code Signing

Auf CI müssen Sie den macOS Keychain handhaben, da es keine GUI zum Entsperren gibt. Fastlane bietet Hilfsfunktionen dafür:

lane :ci_setup do
  create_keychain(
    name: "ci_keychain",
    password: ENV["KEYCHAIN_PASSWORD"],
    default_keychain: true,
    unlock: true,
    timeout: 3600,
    lock_when_sleeps: false
  )

  match(
    type: "appstore",
    keychain_name: "ci_keychain",
    keychain_password: ENV["KEYCHAIN_PASSWORD"],
    readonly: true
  )
end

Das readonly: true-Flag ist entscheidend für CI. Ohne es könnte Match versuchen, neue Zertifikate zu erstellen, wenn es bestehende nicht finden kann, was ohne interaktive Autorisierung fehlschlagen wird und bestehende Zertifikate widerrufen kann.

Bauen mit Gym

Gym ist Fastlanes Build-Tool. Es kapselt xcodebuild mit sinnvollen Standardeinstellungen und besserer Fehlerausgabe.

lane :build do
  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    output_directory: "./build",
    output_name: "YourApp.ipa",
    clean: true,
    include_bitcode: false,
    export_options: {
      provisioningProfiles: {
        "com.yourcompany.yourapp" => "match AppStore com.yourcompany.yourapp"
      }
    }
  )
end

Bei Flutter-Projekten haben Sie zwei Optionen: flutter build ipa ausführen und die Ausgabe verarbeiten, oder Gym direkt auf dem Xcode-Workspace verwenden, den Flutter generiert. Ich bevorzuge Gym, weil es mehr Kontrolle über Signing und Export-Optionen gibt, aber beides funktioniert.

# Flutter-specific build lane
lane :build_flutter do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign")
  end

  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    clean: false,  # Flutter already built, no need to clean
    include_bitcode: false
  )
end

Das --no-codesign-Flag im Flutter-Build-Schritt ist beabsichtigt — wir lassen Gym das Signing mit den von Match verwalteten Profiles übernehmen, anstatt auf Xcodes automatisches Signing zu vertrauen.

Upload zu TestFlight mit Pilot

Pilot übernimmt TestFlight-Uploads und Beta-Tester-Verwaltung.

lane :beta do
  ci_setup
  build_flutter

  pilot(
    skip_waiting_for_build_processing: true,
    apple_id: "1234567890",
    distribute_external: false,
    notify_external_testers: false
  )
end

Das skip_waiting_for_build_processing-Flag ist sehr wichtig für CI. Ohne es würde Fastlane App Store Connect pollen, bis Apple Ihren Build verarbeitet hat — was 15 bis 45 Minuten dauern kann. Ihr CI-Runner würde die ganze Zeit untätig dastehen und Compute-Credits verbrennen. Besser hochladen und die Verarbeitung asynchron passieren lassen.

TestFlight-Tester verwalten

Sie können auch die Tester-Gruppenverwaltung automatisieren:

lane :distribute_to_testers do
  pilot(
    distribute_external: true,
    groups: ["External Beta Testers"],
    changelog: "Bug fixes and performance improvements",
    notify_external_testers: true
  )
end

Für externe Tester denken Sie daran, dass Apple ein Beta App Review für den ersten Build erfordert, der an externe Gruppen gesendet wird, und jedes Mal, wenn Sie neue externe Testgruppen hinzufügen. Das ist ein manuelles Gate, das Sie nicht automatisieren können — planen Sie eine 24-48 stündige Verzögerung bei ersten externen Distributionen ein.

App Store-Einreichung mit Deliver

Sobald Ihre App produktionsreif ist, übernimmt deliver die App Store-Einreichung.

lane :release do
  ci_setup
  build_flutter

  deliver(
    submit_for_review: true,
    automatic_release: false,
    force: true,  # Skip HTML preview verification
    submission_information: {
      add_id_info_uses_idfa: false
    },
    precheck_include_in_app_purchases: false
  )
end

Deliver kann auch Ihre App Store-Metadaten verwalten — Screenshots, Beschreibungen, Keywords, Release Notes — alles als Dateien in Ihrem Repository gespeichert.

fastlane/metadata/en-US/
├── description.txt
├── keywords.txt
├── name.txt
├── release_notes.txt
├── subtitle.txt
└── privacy_url.txt

fastlane/screenshots/en-US/
├── iPhone 6.5" Display/
│   ├── 01_home.png
│   ├── 02_detail.png
│   └── 03_settings.png
└── iPad Pro 12.9" Display/
    ├── 01_home.png
    └── 02_detail.png

Dies ist eines der am meisten unterschätzten Fastlane-Features. Ihre App-Store-Listung in der Versionskontrolle zu haben bedeutet, dass Sie Metadatenänderungen in Pull Requests reviewen, verfolgen können, wann Beschreibungen aktualisiert wurden, und zurückrollen können, wenn ein Texter einen Fehler macht.

Strategien für Versionierung

Versionsverwaltung ist überraschend umstritten. Der Ansatz, auf den ich mich festgelegt habe, verwendet eine Kombination aus semantischer Versionierung für die Marketing-Version und einer auto-inkrementierenden Build-Nummer.

lane :bump_patch do
  increment_version_number(bump_type: "patch")
  increment_build_number
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

lane :bump_minor do
  increment_version_number(bump_type: "minor")
  increment_build_number(build_number: 1)
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

Für die Build-Nummer speziell empfehle ich, die CI-Build-Nummer oder einen Zeitstempel zu verwenden statt einer einfachen inkrementierenden Ganzzahl. Das vermeidet Konflikte, wenn mehrere Branches gleichzeitig gebaut werden.

lane :set_ci_build_number do
  build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
  increment_build_number(build_number: build_number)
end

Bei Flutter-Projekten müssen Sie auch die pubspec.yaml mit dem Xcode-Projekt synchron halten:

lane :sync_flutter_version do
  version = get_version_number
  build = get_build_number
  Dir.chdir("..") do
    sh("sed -i '' 's/^version: .*/version: #{version}+#{build}/' pubspec.yaml")
  end
end

Das vollständige Fastfile

Hier ist ein produktionsreifes Fastfile, das alles kombiniert:

default_platform(:ios)

platform :ios do
  before_all do
    setup_ci if ENV["CI"]
  end

  desc "Setup CI environment"
  lane :ci_setup do
    create_keychain(
      name: "ci_keychain",
      password: ENV["KEYCHAIN_PASSWORD"],
      default_keychain: true,
      unlock: true,
      timeout: 3600,
      lock_when_sleeps: false
    )

    match(
      type: "appstore",
      keychain_name: "ci_keychain",
      keychain_password: ENV["KEYCHAIN_PASSWORD"],
      readonly: true
    )
  end

  desc "Build the Flutter iOS app"
  lane :build_flutter do
    Dir.chdir("..") do
      sh("flutter build ios --release --no-codesign")
    end

    gym(
      scheme: "Runner",
      workspace: "Runner.xcworkspace",
      export_method: "app-store",
      output_directory: "./build",
      clean: false,
      include_bitcode: false
    )
  end

  desc "Deploy to TestFlight"
  lane :beta do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    pilot(
      skip_waiting_for_build_processing: true,
      distribute_external: false
    )

    slack(
      message: "New TestFlight build uploaded!",
      slack_url: ENV["SLACK_WEBHOOK"]
    ) if ENV["SLACK_WEBHOOK"]
  end

  desc "Deploy to App Store"
  lane :release do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    deliver(
      submit_for_review: true,
      automatic_release: false,
      force: true,
      precheck_include_in_app_purchases: false
    )
  end

  desc "Set build number from CI"
  private_lane :set_ci_build_number do
    build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
    increment_build_number(build_number: build_number)
  end
end

GitHub Actions Integration

Der CI-Workflow bindet alles zusammen. Hier ist ein vollständiger GitHub Actions Workflow für das TestFlight-Deployment:

name: Deploy to TestFlight

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: macos-14
    timeout-minutes: 45

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.27.x'
          channel: stable
          cache: true

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

      - name: Flutter dependencies
        run: flutter pub get

      - name: Deploy to TestFlight
        working-directory: ios
        env:
          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: ${{ secrets.ASC_API_KEY }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: bundle exec fastlane beta

App Store Connect API Keys

Beachten Sie, dass ich API-Keys statt Apple-ID-Anmeldedaten verwende. Das ist wichtig für CI — die Apple-ID-Authentifizierung erfordert Zwei-Faktor-Authentifizierung, die in nicht-interaktiven Umgebungen nicht funktioniert. API-Keys werden in App Store Connect unter Benutzer und Zugriff erstellt und laufen nie ab, es sei denn, Sie widerrufen sie.

# In your Appfile or 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"],
  is_key_content_base64: true,
  in_house: false
)

Speichern Sie den Inhalt der .p8-Key-Datei als base64-codiertes Secret in GitHub Actions. Das vermeidet Dateipfad-Probleme und hält den Key außerhalb Ihres Repositorys.

Häufige Fallstricke und deren Lösung

Provisioning Profile-Konflikte

Der häufigste Build-Fehler ist ein Mismatch zwischen dem Provisioning Profile und den Entitlements in Ihrer App. Symptome sind Fehler wie „no provisioning profile matching" oder „code signing entitlements not valid."

Die Lösung besteht fast immer darin, Ihre Match-Profile zu regenerieren:

fastlane match nuke appstore
fastlane match appstore

Überprüfen Sie dann, dass der Bundle Identifier Ihres Xcode-Projekts exakt mit dem übereinstimmt, was in Ihrem Matchfile und Appfile steht.

Entitlements-Probleme

Wenn Ihre App Push-Benachrichtigungen, Associated Domains, Sign in with Apple oder andere Capabilities nutzt, müssen diese sowohl im Apple Developer Portal als auch in Ihrer Runner.entitlements-Datei konfiguriert sein. Ein Mismatch zwischen beiden verursacht Signing-Fehler, die irreführende Fehlermeldungen produzieren.

<!-- ios/Runner/Runner.entitlements -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>aps-environment</key>
    <string>production</string>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:yourdomain.com</string>
    </array>
</dict>
</plist>

Build-Nummern-Ablehnung

App Store Connect lehnt Uploads ab, bei denen die Build-Nummer nicht strikt höher als der vorherige Upload für dieselbe Version ist. Wenn Sie CI-Build-Nummern verwenden, ist das normalerweise kein Problem. Aber wenn Sie Ihr CI zurücksetzen oder den CI-Anbieter wechseln, kann das auftreten. Die Lösung ist, app_store_build_number zu verwenden, um die neueste Build-Nummer abzurufen und von dort zu inkrementieren:

lane :smart_build_number do
  current = app_store_build_number(live: false)
  increment_build_number(build_number: current + 1)
end

Xcode-Versions-Konflikte

Apple stellt regelmäßig die Unterstützung für Builds ein, die mit älteren Xcode-Versionen erstellt wurden. Ihr CI-Runner muss mit der Xcode-Version übereinstimmen, mit der Sie entwickeln. Bei GitHub Actions können Sie dies angeben:

- name: Select Xcode version
  run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer

Prüfen Sie Apples Dokumentation für die minimale Xcode-Version, die für App Store-Einreichungen erforderlich ist — das ändert sich mit jedem größeren iOS-Release.

Umgebungsspezifische Konfigurationen

Die meisten Apps brauchen unterschiedliche Konfigurationen für verschiedene Umgebungen — verschiedene API-Endpunkte, verschiedene Firebase-Projekte, verschiedene Feature Flags. Behandeln Sie das in Ihrem Fastfile:

lane :beta_staging do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=staging")
  end
  # ... rest of build and upload
end

lane :beta_production do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=production")
  end
  # ... rest of build and upload
end

Für Firebase verwenden Sie verschiedene GoogleService-Info.plist-Dateien pro Umgebung und kopieren die richtige während des Builds:

lane :configure_firebase do |options|
  env = options[:env] || "production"
  sh("cp ../firebase/#{env}/GoogleService-Info.plist ../Runner/GoogleService-Info.plist")
end

Build-Zustand überwachen

Sobald Ihre Pipeline läuft, wollen Sie Einblick in ihren Zustand. Ich füge jeder Deployment-Pipeline einige Dinge hinzu:

Build-Zeit-Tracking — wenn Ihre Builds länger dauern, möchten Sie das wissen, bevor sie das Timeout-Limit erreichen.

Slack-Benachrichtigungen — Erfolgs- und Fehlerbenachrichtigungen halten das Team informiert, ohne dass jemand das CI-Dashboard beobachten muss.

error do |lane, exception|
  slack(
    message: "Build failed: #{exception.message}",
    success: false,
    slack_url: ENV["SLACK_WEBHOOK"]
  )
end

Artefakt-Aufbewahrung — bewahren Sie dSYM-Dateien für Crash-Symbolisierung auf. Laden Sie sie automatisch zu Ihrem Crash-Reporting-Dienst hoch:

lane :upload_symbols do
  upload_symbols_to_crashlytics(
    dsym_path: "./build/YourApp.app.dSYM.zip",
    gsp_path: "./Runner/GoogleService-Info.plist"
  )
end

Die Rendite

Das Einrichten dieser gesamten Pipeline dauert etwa einen Tag für jemanden, der es schon einmal gemacht hat, und zwei bis drei Tage für Erstanwender — die meiste Zeit wird damit verbracht, Code-Signing-Probleme zu bekämpfen, nicht Fastlane-Konfiguration zu schreiben. Nach dieser anfänglichen Investition wird jedes nachfolgende Release von einem 30-60-minütigen manuellen Prozess zu einem Git-Push.

Der wahre Wert ist nicht einmal die Zeitersparnis. Es ist das Vertrauen. Wenn Deployen ein einzelner Befehl oder ein automatischer Trigger ist, deployen Sie häufiger. Kleinere Deployments bedeuten weniger Risiko pro Release, schnelleres Feedback von Testern und schnellere Iteration bei Features. Die Pipeline amortisiert sich nicht in gesparten Stunden, sondern in einem grundlegend besseren Entwicklungsworkflow.

DU

Danil Ulmashev

Full Stack Developer

Interesse an einer Zusammenarbeit?