iOSGitHub ActionsTestFlightXcodeCI/CDCode Signing

Compiler, signer et déployer automatiquement une app iOS vers TestFlight avec GitHub Actions (sans fastlane)

Sloth255
Sloth255
·14 min read·3,142 words

Introduction

Faites-vous encore Archive → Distribute... manuellement depuis Xcode à chaque release de votre app iOS ?
Ce processus est chronophage et tend à créer des dépendances environnementales — le fameux problème : "ça build sur mon Mac, mais pas sur le sien."

Dans cet article, nous allons construire un pipeline complet pour compiler, signer et uploader automatiquement une app iOS vers TestFlight avec GitHub Actions.

Beaucoup de tutoriels utilisent fastlane, mais nous adoptons ici une approche différente : utiliser uniquement xcodebuild et xcrun altool, les outils natifs d'Apple — sans fastlane. Inutile d'introduire un environnement Ruby, le workflow reste simple, et on échappe aux problèmes de compatibilité fastlane lors des mises à jour de Xcode.

Pour l'authentification, nous utilisons une clé API App Store Connect — plus sécurisée et non affectée par la double authentification — plutôt qu'un identifiant Apple ID + mot de passe.

Prérequis

  • Inscription au Apple Developer Program (payant)
  • Enregistrement de l'app dans App Store Connect
  • Projet iOS poussé dans un dépôt GitHub
  • Builds locaux fonctionnels sous macOS / Xcode confirmés

Vue d'ensemble

Le pipeline que nous allons construire fonctionne ainsi :

  1. Déclenchement du workflow par un push sur main ou manuellement
  2. Préparation de Xcode sur le runner macOS de GitHub Actions
  3. Import temporaire du certificat et du profil de provisionnement dans le trousseau
  4. Mise à jour du numéro de build
  5. Génération du .xcarchive avec xcodebuild archive
  6. Export en .ipa avec xcodebuild -exportArchive
  7. Upload vers TestFlight avec xcrun altool
  8. Suppression du trousseau temporaire et nettoyage
push → Actions déclenché → Installation certificat → archive → export → altool → TestFlight

Étape 1 : Préparer les certificats et profil de provisionnement

Pour effectuer la signature de code en CI, encodez les deux fichiers suivants en Base64 et stockez-les dans GitHub Secrets.

1-1. Exporter le certificat de distribution (.p12)

Ouvrez "Trousseaux d'accès.app" sur votre Mac, faites un clic droit sur le certificat Apple Distribution ou iPhone Distribution et exportez-le au format .p12. Notez le mot de passe défini lors de l'export — vous en aurez besoin plus tard.

Si vous n'avez pas de certificat, créez-en un depuis la section Certificates du site Apple Developer.

1-2. Télécharger le profil de provisionnement (.mobileprovision)

Téléchargez le profil de provisionnement pour la distribution App Store depuis la section Profiles du site Apple Developer. Notez également le nom du profil (ex. : YourApp AppStore) — vous devrez l'indiquer dans ExportOptions.plist.

1-3. Encoder en Base64

Exécutez les commandes suivantes dans votre terminal pour convertir les fichiers en chaînes Base64.

# Certificat
base64 -i Certificates.p12 | pbcopy

# Profil de provisionnement
base64 -i YourApp_AppStore.mobileprovision | pbcopy

Le contenu est copié dans le presse-papiers via pbcopy et peut être collé directement dans GitHub Secrets.

Étape 2 : Générer une clé API App Store Connect

Pour éviter les complications liées à la double authentification, nous utilisons une clé API.

  1. Se connecter à App Store Connect
  2. Sélectionner "Utilisateurs et accès" → "Integrations" → "App Store Connect API"
  3. Générer une clé avec le bouton "+" (rôle App Manager ou supérieur requis)
  4. Sauvegarder le fichier AuthKey_XXXXXXXXXX.p8 téléchargé (téléchargement unique — à conserver précieusement)
  5. Noter la Key ID et l'Issuer ID affichés lors de la création

Encoder également le fichier .p8 en Base64 :

base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy

Étape 3 : Enregistrer dans GitHub Secrets

Dans SettingsSecrets and variablesActions du dépôt, enregistrez les Secrets suivants :

Nom du Secret Contenu
BUILD_CERTIFICATE_BASE64 Chaîne Base64 du certificat .p12
P12_PASSWORD Mot de passe défini lors de l'export .p12
BUILD_PROVISION_PROFILE_BASE64 Chaîne Base64 du .mobileprovision
KEYCHAIN_PASSWORD Mot de passe quelconque pour le trousseau temporaire
APP_STORE_CONNECT_API_KEY_ID Key ID de la clé API
APP_STORE_CONNECT_API_ISSUER_ID Issuer ID
APP_STORE_CONNECT_API_KEY_BASE64 Chaîne Base64 du .p8

Étape 4 : Créer ExportOptions.plist

Ce fichier de configuration est nécessaire pour générer un .ipa à partir d'un .xcarchive. Commitez-le dans votre dépôt sous ios/ExportOptions.plist (aucune information sensible).

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>

Étape 5 : Créer le workflow GitHub Actions

Voici la partie principale. Créez .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 # Utiliser PROJECT pour .xcodeproj
  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

          # Décoder le Base64
          echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
          echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH

          # Créer un trousseau temporaire
          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          # Importer le certificat
          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

          # Placer le profil de provisionnement
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles

      - name: Set build number
        run: |
          # Utiliser le numéro d'exécution GitHub Actions comme numéro de build (unicité garantie)
          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 lit depuis ~/.appstoreconnect/private_keys/ ou ./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

Points clés

Utiliser github.run_number comme numéro de build
Plutôt que d'utiliser increment_build_number de fastlane, on exploite le numéro d'exécution attribué automatiquement par GitHub Actions. Ce numéro est garanti croissant et satisfait l'exigence de TestFlight de "pas de numéro de build en doublon". Un numéro basé sur la date ($(date +%Y%m%d%H%M)) fonctionne aussi.

Formater les logs avec xcbeautify
Les logs bruts de xcodebuild sont très difficiles à lire ; on les redirige donc vers xcbeautify. Il est préinstallé sur les runners macos-14. S'il n'est pas disponible, ajoutez brew install xcbeautify comme étape.

Emplacement du fichier de clé API
xcrun altool détecte automatiquement la clé API depuis l'un des chemins suivants. Aucun flag ne permet de spécifier le chemin explicitement, donc la fichier doit être placé dans un répertoire désigné :

  • ./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

Pourquoi utiliser un trousseau temporaire
Importer des certificats dans le trousseau par défaut de l'environnement CI est difficile à nettoyer et pose des problèmes de sécurité. La meilleure pratique est de créer un trousseau par job et de le supprimer après.

Nettoyage avec if: always()
Le trousseau temporaire et les fichiers de clés sont toujours supprimés, même en cas d'échec du build. C'est particulièrement important avec les runners self-hosted.

Étape 6 : Vérification

Poussez sur la branche main ou exécutez manuellement depuis l'onglet Actions avec Run workflow.

L'opération se termine en 5 à 10 minutes, et un nouveau build apparaît dans l'onglet TestFlight d'App Store Connect. Le passage du statut "En traitement" (Processing) à "Terminé" peut prendre 10 à 30 minutes supplémentaires.

Options de gestion des certificats et configurations recommandées

Nous avons construit le pipeline en encodant .p12 en Base64 et en le stockant dans GitHub Secrets, mais GitHub Actions offre d'autres options. Choisissez celle qui convient le mieux à la taille et aux besoins de votre équipe.

Option 1 : Stockage en Base64 dans Secrets (méthode de cet article)

Principe : .p12 et .mobileprovision sont encodés en Base64 et stockés en tant que chaînes dans GitHub Secrets.

Avantages

  • Aucune dépendance externe ; tout fonctionne dans GitHub
  • Configuration initiale la plus simple
  • .p12 fait généralement quelques Ko à quelques dizaines de Ko, bien en dessous de la limite de 64 Ko des Secrets

Inconvénients

  • Mise à jour manuelle des Secrets lors de la rotation des certificats
  • La gestion de plusieurs apps multiplie les Secrets et devient complexe
  • Les Secrets ne peuvent pas être manipulés comme des fichiers (contrairement à Azure DevOps Secure Files)

Idéal pour : développeurs solo, petites équipes de 1 à 3 personnes, app unique

Option 2 : Committer des fichiers chiffrés dans le dépôt

Principe : .p12 est chiffré avec GPG ou OpenSSL et commité dans le dépôt ; seule la passphrase de déchiffrement est stockée dans Secrets.

# Chiffrer localement
gpg --symmetric --cipher-algo AES256 Certificates.p12
# → Commiter Certificates.p12.gpg dans le dépôt

Dans le 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 }}

Avantages

  • Gestion en tant que fichier ; historique des rotations traçable via Git
  • Pas de limite de taille des Secrets
  • Approche la plus proche d'Azure DevOps Secure Files

Inconvénients

  • En cas de compromission du dépôt, la force de la passphrase est la dernière ligne de défense
  • La présence de fichiers .gpg dans le dépôt peut déranger certains

Idéal pour : migration depuis Azure DevOps, gestion sous forme de fichiers souhaitée, dépôts privés

Option 3 : fastlane match

Principe : Les certificats chiffrés sont stockés dans un dépôt privé dédié (ou S3/GCS), et fastlane se charge automatiquement de les récupérer, installer et déchiffrer.

Avantages

  • Gestion centralisée des certificats pour plusieurs apps et environnements (dev/adhoc/appstore)
  • Les nouveaux membres configurent leur environnement local avec un seul fastlane match
  • Régénération automatique des certificats supportée

Inconvénients

  • Ruby et fastlane requis
  • Risques de problèmes de compatibilité fastlane lors des mises à jour Xcode
  • Va à l'encontre de l'approche sans fastlane de cet article

Idéal pour : gestion de plusieurs apps, équipes iOS de 5 personnes ou plus, rotation fréquente des certificats

Option 4 : AWS Parameter Store + Intégration OIDC

Principe : Les chaînes Base64 de .p12 et les mots de passe sont stockés en tant que SecureString dans AWS Systems Manager Parameter Store et récupérés depuis GitHub Actions via OIDC. L'absence de credentials à long terme est un avantage majeur.

Parameter Store vs. Secrets Manager

AWS propose deux services similaires ; voici une comparaison :

Critère Parameter Store Secrets Manager
Coût (Standard) Gratuit 0,40 $/secret/mois + frais API
Coût (Advanced) 0,05 $/10 000 appels API Idem
Limite de taille Standard 4 Ko / Advanced 8 Ko 64 Ko
Rotation automatique Non Oui
Chiffrement KMS Via le type SecureString Natif

Un .p12 encodé en Base64 fait généralement quelques dizaines de Ko à 30 Ko, ce qui est le point de décision. S'il tient en 8 Ko, Parameter Store est nettement moins cher ; sinon, Secrets Manager ou l'approche hybride S3 ci-dessous est plus appropriée.

Gestion des fichiers dépassant 8 Ko

Si la taille est trop grande, une configuration hybride consistant à stocker le .p12 chiffré dans S3 et à ne conserver que la clé S3 et le mot de passe de déchiffrement dans Parameter Store est pratiquement propre. Elle combine la simplicité de Parameter Store et la liberté de taille de S3.

Configuration côté AWS

Créez d'abord un fournisseur d'identité OIDC pour GitHub Actions dans IAM (ignorez si déjà créé) :

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

Stockez ensuite les paramètres dans Parameter Store (exemple AWS CLI) :

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

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

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

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

Créez un rôle IAM pour GitHub Actions et restreignez l'Assume Role à des dépôts et branches spécifiques dans la Trust Policy :

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"
        }
      }
    }
  ]
}

Politique de permissions minimales — uniquement GetParameter sur le chemin cible :

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"
    }
  ]
}

Workflow GitHub Actions

N'oubliez pas de définir id-token: write dans le bloc permissions. Sans cela, le token OIDC ne peut pas être récupéré.

.github/workflows/ios-testflight.yml
permissions:
  id-token: write   # Requis pour l'acquisition du token OIDC
  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

          # Récupérer depuis Parameter Store (SecureString nécessite --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)

          # Créer le trousseau et importer le certificat
          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

      # Les étapes archive / export / altool suivantes sont identiques à l'Étape 5

Avantages

  • Coût quasi nul : les paramètres standard sont entièrement gratuits ; Advanced est extrêmement bon marché
  • Pas de credentials à long terme grâce à OIDC : inutile de stocker des clés d'accès dans GitHub (avantage majeur)
  • Logs d'audit avec CloudTrail : enregistrement complet de qui a accédé au certificat et quand
  • Contrôle des permissions granulaire avec IAM/KMS : rôles séparés pour les environnements dev et prod
  • Partage entre plusieurs CI/CD : même certificat utilisable depuis GitHub Actions, CodeBuild, Jenkins et le développement local
  • Historique des versions : rollback vers des versions précédentes possible

Inconvénients

  • Compte AWS requis (coût initial élevé pour les équipes ne l'utilisant pas encore)
  • Workaround nécessaire si .p12 dépasse 8 Ko (utiliser S3 en complément ou Secrets Manager)
  • Courbe d'apprentissage pour la configuration OIDC (une seule fois)

Idéal pour : équipes utilisant AWS, exigences de logs d'audit, référencement du même certificat depuis plusieurs outils CI/CD, management de qualité entreprise à faible coût

Option 5 : AWS Secrets Manager + Intégration OIDC

Principe : .p12 est stocké directement en binaire (SecretBinary) dans AWS Secrets Manager et récupéré depuis GitHub Actions via OIDC. Contrairement à Parameter Store, la caractéristique principale est de pouvoir manipuler le fichier directement sans se soucier de la conversion Base64.

Différences avec Parameter Store

Critère Parameter Store Secrets Manager
Format de stockage Chaînes uniquement (binaire nécessite Base64) Binaire directement OK (type SecretBinary)
Limite de taille 4 Ko (Standard) / 8 Ko (Advanced) 64 Ko
Coût Gratuit à très bon marché 0,40 $/mois/secret + frais API
Rotation automatique Non Oui (intégration Lambda)
Cas d'usage Valeurs de configuration, petits secrets Secrets plus grands, exploitation en production

Si .p12 dépasse 8 Ko ou si vous privilégiez la facilité de manipulation en tant que fichier, Secrets Manager est plus naturel. Pour 1 à 2 certificats, le coût mensuel est inférieur à un dollar.

Configuration côté AWS

La configuration du fournisseur d'identité OIDC est partagée avec l'Option 4, nous la passons ici.

Enregistrez dans Secrets Manager en binaire avec le préfixe fileb://, qui indique à l'AWS CLI d'envoyer le fichier tel quel en binaire :

# Corps du certificat (peut être enregistré directement en binaire)
aws secretsmanager create-secret \
  --name ios/yourapp/dist-cert \
  --secret-binary fileb://Certificates.p12

# Profil de provisionnement en binaire
aws secretsmanager create-secret \
  --name ios/yourapp/provisioning-profile \
  --secret-binary fileb://YourApp_AppStore.mobileprovision

# Clé API App Store Connect (.p8) en binaire
aws secretsmanager create-secret \
  --name ios/yourapp/asc-api-key \
  --secret-binary fileb://AuthKey_XXXXXXXXXX.p8

# Mots de passe en chaîne
aws secretsmanager create-secret \
  --name ios/yourapp/p12-password \
  --secret-string "your-p12-password"

Pour les mises à jour, utilisez update-secret :

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

La politique de permissions du rôle IAM autorise Secrets Manager au lieu de 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"
    }
  ]
}

Workflow GitHub Actions

Les valeurs récupérées via SecretBinary sont retournées encodées en Base64 dans la réponse API, donc décodez avec base64 --decode et écrivez dans un fichier :

.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 est retourné encodé en Base64 ; décoder et écrire dans un fichier
          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 peut être récupéré directement
          P12_PASSWORD=$(aws secretsmanager get-secret-value \
            --secret-id ios/yourapp/p12-password \
            --query SecretString --output text)

          # Placer la clé API (.p8) au chemin attendu par altool
          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

          # Créer le trousseau et importer le certificat
          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

      # Les étapes archive / export / altool suivantes sont identiques à l'Étape 5

Avantages

  • .p12 enregistrable et récupérable directement comme fichier (le plus proche d'Azure DevOps Secure Files)
  • Support jusqu'à 64 Ko : pas de souci de limite de taille
  • Pas de credentials à long terme grâce à OIDC : mêmes avantages de sécurité que l'Option 4
  • Logs d'audit avec CloudTrail disponibles nativement
  • Rotation automatique : possible via Lambda (mais l'intégration côté Apple nécessite un effort de construction élevé)
  • Contrôle des permissions granulaire avec IAM

Inconvénients

  • Coût : ~0,40 $/secret/mois + frais API ; quelques dollars par mois pour 3 à 5 certificats
  • Compte AWS requis
  • Courbe d'apprentissage pour la configuration OIDC (partagée avec l'Option 4)

Idéal pour : priorité à la manipulation en tant que fichier, fichiers .p12 volumineux, nombreux fichiers de certificats pour plusieurs apps, rotation automatique envisagée, expérience équivalente à Azure DevOps Secure Files souhaitée

Option 6 : Autres gestionnaires de secrets cloud

Pour les équipes n'utilisant pas AWS, les options suivantes utilisent le même concept. Tous supportent l'intégration OIDC :

Service Coût Limite de taille Support binaire
Azure Key Vault 0,03 $/10 000 opérations 25 Ko ○ (type Certificate)
Google Secret Manager 0,06 $/mois/secret 64 Ko
HashiCorp Vault (OSS) Coûts serveur uniquement Aucune limite

Azure Key Vault propose notamment un type Certificate avec support officiel pour l'import/export direct au format .p12 — idéal pour les équipes utilisant l'écosystème Azure.

Option 7 : Protection supplémentaire avec GitHub Environments

Cette option peut être combinée avec n'importe laquelle des options ci-dessus. En configurant GitHub Environments, vous pouvez restreindre l'accès aux certificats aux déploiements depuis des branches spécifiques ou ajouter une étape d'approbation manuelle :

jobs:
  deploy:
    runs-on: macos-14
    environment: production # ← Les Secrets liés à cet Environment sont utilisables

Comme les Secrets peuvent être isolés par Environment, vous évitez d'utiliser accidentellement des certificats de distribution production pour des builds de développement.

Configurations recommandées par taille d'équipe

Taille Configuration recommandée
Développeur solo / projet hobby Option 1 (Base64 + Secrets) — la simplicité l'emporte
Startup / petite équipe (1 à 3 personnes) Option 1 ou Option 2 (fichiers chiffrés)
Équipe moyenne (plusieurs apps ou 5+ personnes) Option 3 (fastlane match) ou Option 5 (Secrets Manager)
Utilisateurs AWS, sensibles aux coûts Option 4 (Parameter Store + OIDC)
Utilisateurs AWS, orientés exploitation Option 5 (Secrets Manager + OIDC) — adapté aux fichiers
Enterprise / exigences d'audit Option 4 ou 5 + Option 7 (Environments) combinées
Migration depuis Azure DevOps Option 2 (fichiers chiffrés) ou Option 5 (Secrets Manager) — la plus proche de Secure Files

Problèmes courants et solutions

No signing certificate "iOS Distribution" found

Survient souvent quand l'import du certificat dans le trousseau a échoué ou que set-key-partition-list est absent. Ajoutez security find-identity -v -p codesigning $KEYCHAIN_PATH comme étape pour vérifier que le certificat est visible — cela facilite l'isolation du problème.

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

Le profil de provisionnement n'a pas été placé correctement, ou le Bundle ID et le nom du profil dans provisioningProfiles d'ExportOptions.plist ne correspondent pas. Le nom du profil est le "nom affiché sur le site Apple Developer", pas le nom de fichier.

altool: Invalid API key

Souvent causé par des sauts de ligne ou des espaces supplémentaires dans le fichier .p8 encodé en Base64. Générez-le avec base64 -i file.p8 | pbcopy et collez directement. Si le nom de fichier ne suit pas la convention AuthKey_<KEY_ID>.p8, altool ne le reconnaîtra pas — construisez-le précisément via les variables d'environnement.

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

Survient quand .xcworkspace est utilisé mais que -project est spécifié (ou vice versa). Si vous utilisez un workspace CocoaPods ou Swift Package Manager, spécifiez -workspace.

Parameter Store : ParameterNotFound ou AccessDenied

Erreurs lors de l'intégration AWS. Vérifiez dans cet ordre :

  1. Aucune faute de frappe dans le chemin du paramètre (hiérarchie comme /ios/yourapp/...) ?
  2. ssm:GetParameter est-il autorisé pour le chemin cible dans la politique de permissions du rôle IAM ?
  3. Pour les SecureString, le flag --with-decryption est-il inclus ?
  4. La permission kms:Decrypt pour la clé KMS est-elle accordée au rôle IAM ?
  5. La condition sub dans la Trust Policy correspond-elle à la branche d'exécution du workflow ?

Pour aller plus loin

Nous avons construit le pipeline avec une configuration minimale. Voici quelques extensions possibles :

  • Notifications Slack : Intégrer slackapi/slack-github-action pour notifier automatiquement à la fin de l'upload.
  • Release notes automatiques : Extraire les changements depuis git log et les saisir automatiquement dans le champ "What to Test" de TestFlight (difficile avec altool seul ; nécessite d'appeler directement l'App Store Connect API).
  • Déclencheur sur push de tag : Déclencher sur un push de tag v* plutôt que sur main pour un flow de release plus intentionnel.
  • Exécuter les tests unitaires en amont : Lancer xcodebuild test avant l'archive et arrêter le déploiement en cas d'échec des tests.

Conclusion

Une configuration sans fastlane présente les avantages d'être intuitive et facile à comprendre, de ne pas nécessiter d'environnement Ruby et d'être indépendante des problèmes de compatibilité fastlane lors des mises à jour Xcode. En contrepartie, des éléments comme la gestion des numéros de build doivent être gérés manuellement, et à mesure qu'on passe à la gestion de plusieurs apps, la charge de gestion augmente. Dans ce cas, migrer vers fastlane match ou AWS Secrets Manager / Parameter Store est un choix pragmatique.

Le principal obstacle lors de la configuration initiale est incontestablement la gestion des certificats — espérons que cet article et la section "Options de gestion des certificats" vous aideront à franchir cet obstacle.