iOSGitHub ActionsTestFlightXcodeCI/CDCode Signing

iOS-Apps mit GitHub Actions bauen, signieren und automatisch zu TestFlight hochladen (ohne fastlane)

Sloth255
Sloth255
·12 min read·2,576 words

Einleitung

Führst du den Release-Prozess deiner iOS-App noch jedes Mal manuell über Archive → Distribute... in Xcode durch?
Dieser Prozess ist zeitaufwendig und führt leicht zu umgebungsabhängigen Builds – bekannt als das Problem: "Bei mir baut es, aber auf dem anderen Mac nicht."

In diesem Artikel erklären wir, wie du eine vollständige Pipeline zum Bauen, Signieren und automatischen Hochladen einer iOS-App zu TestFlight mit GitHub Actions aufbaust.

Viele Tutorials zeigen eine Konfiguration mit fastlane, aber in diesem Artikel verwenden wir ausschließlich Apples eigenes xcodebuild und xcrun altool – ganz ohne fastlane. So wird keine Ruby-Umgebung benötigt, der Workflow bleibt einfach, und du bist von fastlane-Kompatibilitätsproblemen bei Xcode-Updates befreit.

Als Authentifizierungsmethode verwenden wir statt Apple ID + Passwort den sichereren App Store Connect API-Key, der nicht von der Zwei-Faktor-Authentifizierung betroffen ist.

Voraussetzungen

  • Mitgliedschaft im Apple Developer Program (kostenpflichtig)
  • App-Eintrag in App Store Connect erstellt
  • iOS-Projekt in ein GitHub-Repository gepusht
  • Lokale Builds unter macOS / Xcode erfolgreich

Gesamtablauf

Der Überblick über die aufzubauende Pipeline sieht wie folgt aus:

  1. Workflow wird durch Push auf den main-Branch oder manuell ausgelöst
  2. Xcode auf dem GitHub Actions macOS-Runner vorbereiten
  3. Zertifikat und Provisioning Profile temporär in den Keychain importieren
  4. Build-Nummer aktualisieren
  5. .xcarchive mit xcodebuild archive erstellen
  6. .ipa mit xcodebuild -exportArchive exportieren
  7. Mit xcrun altool zu TestFlight hochladen
  8. Temporären Keychain löschen und aufräumen
push → Actions gestartet → Zertifikat installieren → archive → export → altool → TestFlight

Step 1: Zertifikat und Provisioning Profile vorbereiten

Für Code Signing in CI werden die folgenden zwei Dateien Base64-kodiert und in GitHub Secrets gespeichert.

1-1. Verteilungszertifikat (.p12) exportieren

Öffne "Schlüsselbundverwaltung.app" auf deinem lokalen Mac, klicke mit der rechten Maustaste auf das Apple Distribution- oder iPhone Distribution-Zertifikat und exportiere es im .p12-Format. Notiere das beim Export gesetzte Passwort – du wirst es später benötigen.

Falls du kein Zertifikat hast, erstelle eines unter Certificates auf der Apple Developer-Website.

1-2. Provisioning Profile (.mobileprovision) herunterladen

Lade das Provisioning Profile für die App Store-Verteilung unter Profiles auf der Apple Developer-Website herunter. Notiere dabei unbedingt auch den Profilnamen (z.B. YourApp AppStore). Du wirst ihn später in der ExportOptions.plist benötigen.

1-3. In Base64 kodieren

Führe folgende Befehle im Terminal aus, um die Dateien in Base64-Strings umzuwandeln.

# Zertifikat
base64 -i Certificates.p12 | pbcopy

# Provisioning Profile
base64 -i YourApp_AppStore.mobileprovision | pbcopy

Durch pbcopy wird der Inhalt in die Zwischenablage kopiert und kann direkt in GitHub Secrets eingefügt werden.

Step 2: App Store Connect API-Key generieren

Um Probleme mit der Zwei-Faktor-Authentifizierung zu vermeiden, verwenden wir einen API-Key.

  1. Bei App Store Connect anmelden
  2. "Benutzer und Zugriff" → "Integrations" → "App Store Connect API" auswählen
  3. Mit der "+"-Schaltfläche einen Key generieren (Rolle muss App Manager oder höher sein)
  4. Die heruntergeladene AuthKey_XXXXXXXXXX.p8-Datei speichern (kann nicht erneut heruntergeladen werden – Vorsicht!)
  5. Die bei der Erstellung angezeigte Key ID und Issuer ID notieren

Die .p8-Datei ebenfalls in Base64 kodieren:

base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy

Step 3: In GitHub Secrets registrieren

Unter SettingsSecrets and variablesActions im Repository folgende Secrets registrieren:

Secret-Name Inhalt
BUILD_CERTIFICATE_BASE64 Base64-String des .p12-Zertifikats
P12_PASSWORD Beim .p12-Export gesetztes Passwort
BUILD_PROVISION_PROFILE_BASE64 Base64-String der .mobileprovision
KEYCHAIN_PASSWORD Beliebiges Passwort für den temporären Keychain
APP_STORE_CONNECT_API_KEY_ID Key ID des API-Keys
APP_STORE_CONNECT_API_ISSUER_ID Issuer ID
APP_STORE_CONNECT_API_KEY_BASE64 Base64-String der .p8

Step 4: ExportOptions.plist erstellen

Diese Konfigurationsdatei wird benötigt, um aus einer .xcarchive eine .ipa zu erzeugen. Sie wird als ios/ExportOptions.plist ins Repository committed (enthält keine vertraulichen Informationen).

ios/ExportOptions.plist
<?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>method</key>
    <string>app-store</string>

    <key>teamID</key>
    <string>ABCD123456</string>

    <key>signingStyle</key>
    <string>manual</string>

    <key>signingCertificate</key>
    <string>Apple Distribution</string>

    <key>provisioningProfiles</key>
    <dict>
        <key>com.example.yourapp</key>
        <string>YourApp AppStore</string>
    </dict>

    <key>uploadSymbols</key>
    <true/>

    <key>uploadBitcode</key>
    <false/>
</dict>
</plist>

Step 5: GitHub Actions Workflow erstellen

Jetzt geht es ans Eingemachte. Erstelle .github/workflows/ios-testflight.yml.

.github/workflows/ios-testflight.yml
name: iOS TestFlight Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  SCHEME: YourApp
  WORKSPACE: YourApp.xcworkspace # Bei .xcodeproj stattdessen PROJECT verwenden
  CONFIGURATION: Release
  EXPORT_OPTIONS_PLIST: ios/ExportOptions.plist

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

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_15.4.app

      - name: Install the Apple certificate and provisioning profile
        env:
          BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # Base64 dekodieren
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

          # Temporären Keychain erstellen
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          # Zertifikat importieren
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
            -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: \
            -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          # Provisioning Profile platzieren
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      - name: Set build number
        run: |
          # GitHub Actions Run-Nummer als Build-Nummer verwenden (Eindeutigkeit garantiert)
          BUILD_NUMBER=${{ github.run_number }}
          /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" \
            YourApp/Info.plist
          echo "Build number set to $BUILD_NUMBER"

      - name: Build archive
        run: |
          xcodebuild archive \
            -workspace "$WORKSPACE" \
            -scheme "$SCHEME" \
            -configuration "$CONFIGURATION" \
            -archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
            -destination "generic/platform=iOS" \
            -allowProvisioningUpdates \
            CODE_SIGNING_ALLOWED=YES \
            | xcbeautify

      - name: Export IPA
        run: |
          xcodebuild -exportArchive \
            -archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
            -exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
            -exportPath "$RUNNER_TEMP/export" \
            | xcbeautify

      - name: Prepare API key for altool
        env:
          APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
        run: |
          # altool liest aus ~/.appstoreconnect/private_keys/ oder ./private_keys/
          mkdir -p ~/.appstoreconnect/private_keys
          echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode \
            -o ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8

      - name: Upload to TestFlight
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
        run: |
          xcrun altool --upload-app \
            --type ios \
            --file "$RUNNER_TEMP/export/YourApp.ipa" \
            --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
            --apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID"

      - name: Clean up keychain and profiles
        if: always()
        run: |
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
          rm -f ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision
          rm -rf ~/.appstoreconnect/private_keys

Wichtige Hinweise

github.run_number als Build-Nummer verwenden
Statt fastlanes increment_build_number nutzen wir die von GitHub Actions automatisch vergebene Ausführungsnummer. Diese ist monoton steigend und erfüllt TestFlights Anforderung "keine doppelten Build-Nummern". Alternativ funktioniert auch eine datumsbasierte Nummer ($(date +%Y%m%d%H%M)).

Logs mit xcbeautify formatieren
Die Rohlogs von xcodebuild sind sehr schwer lesbar, daher leiten wir sie durch xcbeautify. Auf macos-14-Runnern ist es vorinstalliert. Falls nicht vorhanden, füge brew install xcbeautify als Schritt hinzu.

Speicherort der API-Key-Datei
xcrun altool erkennt den API-Key automatisch aus einem der folgenden Pfade. Da kein Flag zur expliziten Pfadangabe existiert, muss die Datei in einem der vorgesehenen Verzeichnisse abgelegt werden:

  • ./private_keys/AuthKey_<KEY_ID>.p8
  • ~/private_keys/AuthKey_<KEY_ID>.p8
  • ~/.private_keys/AuthKey_<KEY_ID>.p8
  • ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8

Warum einen temporären Keychain verwenden
Zertifikate in den Standard-Keychain der CI-Umgebung zu importieren ist umständlich aufzuräumen und sicherheitstechnisch bedenklich. Pro Job einen Keychain zu erstellen und danach zu löschen ist Best Practice.

Aufräumen mit if: always()
Auch bei einem fehlgeschlagenen Build werden der temporäre Keychain und die Schlüsseldateien immer gelöscht. Das ist besonders bei self-hosted Runnern wichtig.

Step 6: Funktionsprüfung

Push auf den main-Branch oder führe den Workflow manuell über den Actions-Tab mit Run workflow aus.

Nach etwa 5–10 Minuten ist der Vorgang abgeschlossen und ein neuer Build erscheint im TestFlight-Tab von App Store Connect. Der Wechsel vom Status "In Bearbeitung" (Processing) zur Fertigstellung kann weitere 10–30 Minuten dauern.

Zertifikatsverwaltung: Optionen und Empfehlungen

Wir haben die Pipeline mit der Methode aufgebaut, .p12 in Base64 zu kodieren und in GitHub Secrets zu speichern, aber GitHub Actions bietet weitere Optionen zur Zertifikatsverwaltung. Wähle je nach Teamgröße und Anforderungen die passende Option.

Option 1: Als Base64 in Secrets speichern (Methode dieses Artikels)

Funktionsweise: .p12 und .mobileprovision werden in Base64 kodiert und als Strings in GitHub Secrets gespeichert.

Vorteile

  • Keine externen Abhängigkeiten, alles innerhalb von GitHub
  • Einfachste Ersteinrichtung
  • .p12-Dateien sind meist wenige KB bis einige Dutzend KB und liegen deutlich unter dem 64-KB-Limit von Secrets

Nachteile

  • Bei Zertifikatsrotation manuelle Aktualisierung der Secrets nötig
  • Bei mehreren Apps steigt die Anzahl der Secrets und wird unübersichtlich
  • Secrets können nicht wie Azure DevOps Secure Files als Dateien behandelt werden

Geeignet für: Einzelentwickler, kleine Teams mit 1–3 Personen, einzelne App

Option 2: Verschlüsselte Dateien ins Repository committen

Funktionsweise: .p12 wird mit GPG oder OpenSSL verschlüsselt und ins Repository committet; nur die Passphrase wird in Secrets gespeichert.

# Lokal verschlüsseln
gpg --symmetric --cipher-algo AES256 Certificates.p12
# → Certificates.p12.gpg ins Repository committen

Im Workflow:

- name: Decrypt certificate
  run: |
    gpg --quiet --batch --yes --decrypt \
      --passphrase="$CERT_PASSPHRASE" \
      --output $RUNNER_TEMP/cert.p12 \
      secrets/Certificates.p12.gpg
  env:
    CERT_PASSPHRASE: ${{ secrets.CERT_PASSPHRASE }}

Vorteile

  • Als Datei verwaltbar; Rotationsverlauf über Git-Historie nachvollziehbar
  • Keine Secrets-Größenbeschränkung
  • Nächste Analogie zu Azure DevOps Secure Files

Nachteile

  • Bei Repository-Kompromittierung ist die Passphrase-Stärke die letzte Verteidigungslinie
  • .gpg-Dateien im Repository können das Erscheinungsbild beeinflussen

Geeignet für: Migration von Azure DevOps, dateibasierte Verwaltung gewünscht, private Repositories

Option 3: fastlane match

Funktionsweise: Verschlüsselte Zertifikate werden in einem dedizierten privaten Repository (oder S3/GCS) gespeichert; fastlane übernimmt automatisch Abrufen, Installieren und Entschlüsseln.

Vorteile

  • Zertifikate für mehrere Apps und Umgebungen (dev/adhoc/appstore) zentral verwalten
  • Neue Teammitglieder richten die lokale Umgebung mit einem einzigen fastlane match-Aufruf ein
  • Automatische Zertifikatsgenerierung unterstützt

Nachteile

  • Ruby und fastlane erforderlich
  • Mögliche fastlane-Kompatibilitätsprobleme bei Xcode-Updates
  • Widerspricht dem fastlane-freien Ansatz dieses Artikels

Geeignet für: Mehrere Apps, iOS-Teams ab 5 Personen, häufige Zertifikatsrotation

Option 4: AWS Parameter Store + OIDC-Integration

Funktionsweise: Base64-Strings von .p12 und Passwörter werden als SecureString im AWS Systems Manager Parameter Store gespeichert und über OIDC aus GitHub Actions abgerufen. Kein Bedarf an langlebigen Anmeldeinformationen – ein großer Vorteil.

Parameter Store vs. Secrets Manager

AWS bietet zwei ähnliche Dienste, hier ein Überblick:

Eigenschaft Parameter Store Secrets Manager
Kosten (Standard) Kostenlos $0,40/Secret/Monat + API-Gebühren
Kosten (Advanced) $0,05/10.000 API-Aufrufe Wie oben
Wertgröße Standard 4 KB / Advanced 8 KB 64 KB
Automatische Rotation Nein Ja
KMS-Verschlüsselung Via SecureString-Typ Nativ

Base64-kodierte .p12-Dateien haben oft einige Dutzend bis 30 KB, was die entscheidende Weiche ist. Wenn es in 8 KB passt, ist Parameter Store deutlich günstiger; überschreitet es das, sind Secrets Manager oder der unten beschriebene S3-Hybridansatz besser geeignet.

Umgang mit Dateien über 8 KB

Ist die Größe zu groß, ist eine Hybridkonfiguration praktisch elegant: Die verschlüsselte .p12 wird in S3 abgelegt, und Parameter Store enthält nur den S3-Schlüssel und das Entschlüsselungspasswort. Damit wird die Einfachheit von Parameter Store mit der Größenfreiheit von S3 kombiniert.

AWS-Einrichtung

Erstelle zunächst einen OIDC-Identitätsanbieter für GitHub Actions in IAM (überspringen, falls bereits vorhanden):

  • Provider URL: https://token.actions.githubusercontent.com
  • Audience: sts.amazonaws.com

Dann Parameter im Parameter Store speichern (AWS CLI-Beispiel):

# Zertifikatskörper
aws ssm put-parameter \
  --name /ios/yourapp/dist-cert-base64 \
  --type SecureString \
  --value "$(base64 -i Certificates.p12)"

# .p12-Passwort
aws ssm put-parameter \
  --name /ios/yourapp/p12-password \
  --type SecureString \
  --value "your-p12-password"

# Provisioning Profile
aws ssm put-parameter \
  --name /ios/yourapp/provisioning-profile-base64 \
  --type SecureString \
  --value "$(base64 -i YourApp_AppStore.mobileprovision)"

# App Store Connect API-Key (.p8)
aws ssm put-parameter \
  --name /ios/yourapp/asc-api-key-base64 \
  --type SecureString \
  --value "$(base64 -i AuthKey_XXXXXXXXXX.p8)"

Eine IAM-Rolle für GitHub Actions erstellen und in der Trust Policy auf spezifische Repositories und Branches beschränken:

trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Berechtigungsrichtlinie nach Least-Privilege-Prinzip – nur GetParameter für den Zielpfad:

permission-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ssm:GetParameter", "ssm:GetParameters"],
      "Resource": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/ios/yourapp/*"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
    }
  ]
}

GitHub Actions Workflow

Vergiss nicht, id-token: write im permissions-Block zu setzen. Ohne diesen Eintrag kann kein OIDC-Token abgerufen werden.

.github/workflows/ios-testflight.yml
permissions:
  id-token: write   # Für OIDC-Token-Abruf erforderlich
  contents: read

jobs:
  deploy:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
          aws-region: ap-northeast-1

      - name: Fetch secrets from Parameter Store
        env:
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # Aus Parameter Store abrufen (SecureString benötigt --with-decryption)
          aws ssm get-parameter \
            --name /ios/yourapp/dist-cert-base64 \
            --with-decryption \
            --query Parameter.Value --output text | base64 --decode > $CERTIFICATE_PATH

          aws ssm get-parameter \
            --name /ios/yourapp/provisioning-profile-base64 \
            --with-decryption \
            --query Parameter.Value --output text | base64 --decode > $PP_PATH

          P12_PASSWORD=$(aws ssm get-parameter \
            --name /ios/yourapp/p12-password \
            --with-decryption \
            --query Parameter.Value --output text)

          # Keychain erstellen und Zertifikat importieren
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
            -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: \
            -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      # Folgende archive / export / altool-Schritte sind identisch mit Step 5

Vorteile

  • Nahezu keine Kosten: Standard-Parameter sind vollständig kostenlos; Advanced ist extrem günstig
  • Keine langlebigen Anmeldeinformationen dank OIDC: Kein Access-Key in GitHub notwendig (größter Vorteil)
  • Audit-Logs mit CloudTrail: Vollständige Aufzeichnung, wer wann auf das Zertifikat zugegriffen hat
  • Feingranulare Berechtigungen mit IAM/KMS: Getrennte Rollen für Entwicklungs- und Produktionsumgebung
  • Auf mehreren CI/CD-Systemen nutzbar: Gleiches Zertifikat aus GitHub Actions, CodeBuild, Jenkins und lokaler Entwicklung
  • Versionsverlauf: Rollback auf frühere Versionen möglich

Nachteile

  • AWS-Konto erforderlich (hoher Einrichtungsaufwand für Teams, die AWS noch nicht nutzen)
  • Workaround nötig, wenn .p12 8 KB überschreitet (S3 kombinieren oder Secrets Manager)
  • Gewisse Lernkurve für OIDC-Einrichtung (nur einmalig)

Geeignet für: Teams, die AWS bereits nutzen; Audit-Log-Anforderungen; gleiches Zertifikat über mehrere CI/CD-Tools; Enterprise-Qualitätsmanagement zu niedrigen Kosten

Option 5: AWS Secrets Manager + OIDC-Integration

Funktionsweise: .p12 wird direkt als Binärdatei (SecretBinary) im AWS Secrets Manager gespeichert und über OIDC aus GitHub Actions abgerufen. Im Gegensatz zu Parameter Store können Dateien direkt behandelt werden, ohne sich um Base64-Konvertierung sorgen zu müssen.

Unterschiede zu Parameter Store

Eigenschaft Parameter Store Secrets Manager
Speicherformat Nur Strings (Binär erfordert Base64) Binär direkt möglich (SecretBinary-Typ)
Größenlimit 4 KB (Standard) / 8 KB (Advanced) 64 KB
Kosten Kostenlos bis sehr günstig $0,40/Monat/Secret + API-Gebühren
Automatische Rotation Nein Ja (Lambda-Integration)
Verwendungszweck Konfigurationswerte, kleine Geheimnisse Größere Geheimnisse, Produktionsbetrieb

Wenn .p12 8 KB überschreitet oder dateibasierte Handhabung bevorzugt wird, ist Secrets Manager die praktischere Wahl. Bei 1–2 Zertifikaten liegen die monatlichen Kosten unter einem Dollar.

AWS-Einrichtung

Die OIDC-Identitätsanbieter-Einrichtung ist mit Option 4 identisch und wird hier übersprungen.

Mit dem fileb://-Präfix als Binärdatei registrieren; die AWS CLI sendet die Datei direkt als Binärdaten:

# Zertifikatskörper (direkt als Binärdatei speicherbar)
aws secretsmanager create-secret \
  --name ios/yourapp/dist-cert \
  --secret-binary fileb://Certificates.p12

# Provisioning Profile als Binärdatei
aws secretsmanager create-secret \
  --name ios/yourapp/provisioning-profile \
  --secret-binary fileb://YourApp_AppStore.mobileprovision

# App Store Connect API-Key (.p8) als Binärdatei
aws secretsmanager create-secret \
  --name ios/yourapp/asc-api-key \
  --secret-binary fileb://AuthKey_XXXXXXXXXX.p8

# Passwörter als String
aws secretsmanager create-secret \
  --name ios/yourapp/p12-password \
  --secret-string "your-p12-password"

Für Updates update-secret verwenden:

aws secretsmanager update-secret \
  --secret-id ios/yourapp/dist-cert \
  --secret-binary fileb://Certificates_new.p12

Die IAM-Rollen-Berechtigungsrichtlinie erlaubt Secrets Manager statt Parameter Store:

permission-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["secretsmanager:GetSecretValue"],
      "Resource": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:ios/yourapp/*"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
    }
  ]
}

GitHub Actions Workflow

Über SecretBinary abgerufene Werte kommen Base64-kodiert in der API-Antwort zurück, daher mit base64 --decode dekodieren und in eine Datei schreiben:

.github/workflows/ios-testflight.yml
permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
          aws-region: ap-northeast-1

      - name: Fetch certificates from Secrets Manager
        env:
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: |
          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
          PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          # SecretBinary kommt Base64-kodiert zurück; dekodieren und in Datei schreiben
          aws secretsmanager get-secret-value \
            --secret-id ios/yourapp/dist-cert \
            --query SecretBinary --output text | base64 --decode > $CERTIFICATE_PATH

          aws secretsmanager get-secret-value \
            --secret-id ios/yourapp/provisioning-profile \
            --query SecretBinary --output text | base64 --decode > $PP_PATH

          # SecretString kann direkt abgerufen werden
          P12_PASSWORD=$(aws secretsmanager get-secret-value \
            --secret-id ios/yourapp/p12-password \
            --query SecretString --output text)

          # API-Key (.p8) am von altool erwarteten Pfad ablegen
          API_KEY_ID="${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}"
          mkdir -p ~/.appstoreconnect/private_keys
          aws secretsmanager get-secret-value \
            --secret-id ios/yourapp/asc-api-key \
            --query SecretBinary --output text | base64 --decode \
            > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8

          # Keychain erstellen und Zertifikat importieren
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
            -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: \
            -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH

          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      # Folgende archive / export / altool-Schritte sind identisch mit Step 5

Vorteile

  • .p12 direkt als Datei speichern und abrufen (größte Analogie zu Azure DevOps Secure Files)
  • Unterstützung bis 64 KB: Keine Sorgen um Größenlimits
  • Keine langlebigen Anmeldeinformationen dank OIDC: Gleiche Sicherheitsvorteile wie Option 4
  • Audit-Logs mit CloudTrail standardmäßig verfügbar
  • Automatische Rotation: Zertifikate können via Lambda automatisch erneuert werden (Apple-seitige API-Integration erhöht jedoch den Aufwand)
  • Feingranulare Berechtigungen mit IAM

Nachteile

  • Kostenverursachend: ~$0,40/Secret/Monat + API-Gebühren; bei 3–5 Zertifikaten wenige Dollar monatlich
  • AWS-Konto erforderlich
  • Lernkurve für OIDC-Einrichtung (geteilt mit Option 4)

Geeignet für: Dateibasierte Handhabung bevorzugt; große .p12-Dateien; viele Zertifikate über mehrere Apps; zukünftige automatische Rotation angestrebt; Azure DevOps Secure Files-ähnliche Erfahrung gewünscht

Option 6: Weitere Cloud-Secret-Manager

Für Teams, die kein AWS nutzen, sind folgende Optionen nach dem gleichen Prinzip verwendbar. Alle unterstützen OIDC-Integration:

Dienst Kosten Größenlimit Binärunterstützung
Azure Key Vault $0,03/10.000 Ops 25 KB ○ (Certificate-Typ)
Google Secret Manager $0,06/Monat/Secret 64 KB
HashiCorp Vault (OSS) Nur Serverkosten Keine Begrenzung

Azure Key Vault bietet insbesondere einen Certificate-Typ mit offizieller Unterstützung für direkten .p12-Import/-Export – ideal für Azure-basierte Teams.

Option 7: Zusätzlicher Schutz durch GitHub Environments

Diese Option kann mit allen obigen kombiniert werden. Durch GitHub Environments lässt sich der Zertifikatszugriff auf Deployments von bestimmten Branches beschränken oder ein manueller Genehmigungsschritt eingebaut werden:

jobs:
  deploy:
    runs-on: macos-14
    environment: production # ← Secrets dieses Environments können genutzt werden

Da Secrets pro Environment isoliert werden können, wird verhindert, dass Produktions-Zertifikate versehentlich für Entwicklungs-Builds verwendet werden.

Empfehlungen nach Teamgröße

Größe Empfohlene Konfiguration
Einzelentwickler / Hobbyprojekt Option 1 (Base64 + Secrets) – Einfachheit gewinnt
Startup / kleines Team (1–3 Personen) Option 1 oder Option 2 (verschlüsselte Dateien)
Mittelgroßes Team (mehrere Apps oder 5+ Personen) Option 3 (fastlane match) oder Option 5 (Secrets Manager)
AWS-Nutzer, kostenbewusst Option 4 (Parameter Store + OIDC)
AWS-Nutzer, betriebsorientiert Option 5 (Secrets Manager + OIDC) – dateifreundlich
Enterprise / Audit-Anforderungen Option 4 oder 5 + Option 7 (Environments) kombiniert
Migration von Azure DevOps Option 2 (verschlüsselte Dateien) oder Option 5 (Secrets Manager) – nächste Analogie zu Secure Files

Häufige Probleme und Lösungen

No signing certificate "iOS Distribution" found

Tritt häufig auf, wenn der Zertifikatsimport in den Keychain fehlgeschlagen ist oder set-key-partition-list fehlt. Füge security find-identity -v -p codesigning $KEYCHAIN_PATH als Schritt ein, um zu prüfen, ob das Zertifikat sichtbar ist – das erleichtert die Fehlersuche.

error: exportArchive: "YourApp.app" requires a provisioning profile

Das Provisioning Profile wurde nicht korrekt platziert, oder Bundle ID und Profilname in provisioningProfiles in der ExportOptions.plist stimmen nicht überein. Beachte: Der Profilname ist der "auf der Apple Developer-Website angezeigte Name", nicht der Dateiname.

altool: Invalid API key

Oft verursacht durch Zeilenumbrüche oder zusätzliche Leerzeichen in der Base64-kodierten .p8-Datei. Generiere sie mit base64 -i file.p8 | pbcopy und füge sie direkt ein. Wenn der Dateiname nicht der Namenskonvention AuthKey_<KEY_ID>.p8 folgt, erkennt altool ihn nicht – stelle ihn daher präzise über Umgebungsvariablen zusammen.

xcodebuild: error: The operation couldn't be completed. No such file or directory

Tritt auf, wenn .xcworkspace verwendet wird, aber -project angegeben ist (oder umgekehrt). Bei Verwendung eines CocoaPods- oder Swift Package Manager-Workspaces -workspace angeben.

Parameter Store: ParameterNotFound oder AccessDenied

Fehler bei der AWS-Integration. In dieser Reihenfolge prüfen:

  1. Tippfehler im Parameterpfad (Hierarchie wie /ios/yourapp/...)?
  2. Ist ssm:GetParameter für den Zielpfad in der IAM-Rollen-Berechtigungsrichtlinie erlaubt?
  3. Bei SecureString: Wird das --with-decryption-Flag verwendet?
  4. Hat die IAM-Rolle kms:Decrypt für den KMS-Schlüssel?
  5. Stimmt die sub-Bedingung in der Trust Policy mit dem Workflow-Ausführungs-Branch überein?

Weitere Verbesserungen

Wir haben die Pipeline mit einer Minimalfonfiguration aufgebaut. Folgende Erweiterungen sind denkbar:

  • Slack-Benachrichtigungen: slackapi/slack-github-action integrieren, um automatisch zu benachrichtigen, wenn der Upload abgeschlossen ist.
  • Automatische Release Notes: Änderungen aus git log extrahieren und automatisch in das "What to Test"-Feld von TestFlight eintragen (mit altool allein schwierig; erfordert direkten Aufruf der App Store Connect API).
  • Tag-Push-Trigger: Statt Push auf main bei v*-Tag-Push auslösen für einen bewussteren Release-Flow.
  • Unit-Tests vorab ausführen: xcodebuild test vor dem Archive ausführen und bei Testfehler das Deployment stoppen.

Fazit

Eine fastlane-freie Konfiguration hat die Vorteile, intuitiv und leicht verständlich zu sein, keine Ruby-Umgebung zu benötigen und von fastlane-Kompatibilitätsproblemen bei Xcode-Updates unabhängig zu sein. Dafür müssen Dinge wie die Build-Nummer-Verwaltung selbst übernommen werden, und beim Betrieb mehrerer Apps steigt der Verwaltungsaufwand. In diesem Fall ist eine Migration zu fastlane match oder AWS Secrets Manager / Parameter Store sinnvoll.

Die größte Hürde bei der Ersteinrichtung ist zweifellos die Zertifikatsverwaltung – dieser Artikel und der Abschnitt zu den Zertifikatsverwaltungsoptionen sollen dabei helfen, diese Hürde zu überwinden.