Skip to main content
mobile1 mars 202612 min de lecture

Automatiser les déploiements iOS : App Store Connect et TestFlight

Un guide complet pour automatiser les déploiements d'applications iOS — de la configuration de Fastlane à la distribution TestFlight et la soumission App Store.

iosfastlanecicd
Automatiser les déploiements iOS : App Store Connect et TestFlight

Tout développeur iOS a vécu ce rituel : ouvrir Xcode, incrémenter le numéro de build, sélectionner le schéma d'archivage, attendre quinze minutes, cliquer dans l'Organizer, uploader vers App Store Connect, attendre encore, puis ajouter manuellement les testeurs dans TestFlight. Faites cela deux fois par semaine sur plusieurs applications et vous commencez à perdre des heures qui devraient être consacrées à écrire du code. Le pire, ce n'est pas le temps perdu — c'est l'incohérence. Une étape oubliée, un mauvais profil de provisionnement, et le build échoue silencieusement ou se fait rejeter par les vérifications automatiques d'Apple des heures plus tard.

J'ai automatisé l'ensemble de ce pipeline pour une application iOS basée sur Flutter et j'ai depuis appliqué les mêmes patterns à d'autres projets. L'investissement est rentabilisé après le troisième ou quatrième cycle de release.

Pourquoi les déploiements manuels finissent par échouer

Le processus manuel comporte trois problèmes fondamentaux au-delà de la perte de temps évidente.

Premièrement, la concentration des connaissances. Quand une seule personne dans l'équipe sait comment déployer, elle devient un goulot d'étranglement. Les vacances, les arrêts maladie, ou simplement être dans un fuseau horaire différent signifient que les releases sont bloquées. La documentation aide, mais un guide de déploiement en 15 étapes est lui-même un risque — il devient obsolète dès que quelqu'un modifie un paramètre de build.

Deuxièmement, l'erreur humaine au pire moment. Les releases ont tendance à se faire sous pression. Un correctif critique, une deadline client, une fonctionnalité qui doit sortir avant un concurrent. Ce sont exactement les conditions où quelqu'un oublie d'incrémenter le numéro de build, sélectionne le mauvais schéma, ou uploade un build debug au lieu du release.

Troisièmement, le manque de traçabilité. Quand quelque chose tourne mal avec une release, vous voulez savoir exactement ce qui a été compilé, à partir de quel commit, avec quelle configuration. Les processus manuels ne laissent aucune trace fiable.

Fastlane : la fondation

Fastlane est le standard de facto pour l'automatisation des déploiements iOS, et pour de bonnes raisons. Il gère la signature de code, la compilation, l'upload et la gestion des métadonnées via un DSL basé sur Ruby qui se lit comme une checklist de déploiement.

Configuration initiale

La mise en place de Fastlane dans un projet existant est simple.

# Install Fastlane
brew install fastlane

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

Lors de l'initialisation, Fastlane vous demande ce que vous voulez automatiser. Je recommande de choisir la configuration manuelle et de tout configurer explicitement — la configuration automatique fait des hypothèses qui ne correspondent pas toujours à la structure de votre projet, surtout pour les applications Flutter.

Le résultat est un répertoire fastlane/ contenant un Appfile et un Fastfile.

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

L'Appfile stocke les détails de votre compte Apple Developer. Les identifiants d'équipe sont importants si votre Apple ID appartient à plusieurs équipes — sans eux, Fastlane vous demandera de manière interactive, ce qui casse le CI.

Signature de code avec Match

La signature de code est l'étape où la plupart des efforts d'automatisation iOS échouent. Profils de provisionnement, certificats, entitlements — tout le système semble conçu pour punir l'automatisation. L'outil match de Fastlane résout ce problème en stockant vos certificats et profils dans un dépôt Git privé ou un stockage cloud, les rendant accessibles à toute machine qui a besoin de compiler.

Configuration de Match

fastlane match init

Cela crée un Matchfile où vous configurez votre backend de stockage.

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

Puis générez et stockez vos certificats :

# Generate development certificates and profiles
fastlane match development

# Generate App Store distribution certificates and profiles
fastlane match appstore

Match chiffre tout avant de pousser vers le dépôt. Vous définissez une phrase secrète lors de la configuration dont vous aurez besoin sur chaque machine — y compris le CI.

Pourquoi Match plutôt que la signature manuelle

L'alternative — gérer manuellement les certificats via le portail Apple Developer et distribuer des fichiers .p12 — fonctionne jusqu'à ce que ça ne fonctionne plus. Les modes de défaillance courants incluent :

  • Le certificat d'un développeur expire et personne ne s'en aperçoit jusqu'à ce qu'un build release échoue.
  • Quelqu'un révoque un certificat en déboguant un problème de signature, invalidant tous les profils qui en dépendent.
  • Une nouvelle machine CI nécessite le provisionnement et la personne qui a configuré la machine d'origine a quitté l'équipe.

Match élimine tout cela en étant la source unique de vérité. Si un certificat expire, vous exécutez fastlane match nuke et régénérez. Chaque machine tire du même dépôt.

Signature de code en CI

En CI, vous devez gérer le Keychain macOS puisqu'il n'y a pas d'interface graphique pour le déverrouiller. Fastlane fournit des helpers pour cela :

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

Le flag readonly: true est critique pour le CI. Sans lui, match pourrait essayer de créer de nouveaux certificats s'il ne trouve pas les existants, ce qui échouera sans autorisation interactive et peut révoquer les certificats existants.

Compilation avec Gym

Gym est l'outil de compilation de Fastlane. Il encapsule xcodebuild avec des valeurs par défaut sensées et une meilleure sortie d'erreur.

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

Pour les projets Flutter, vous avez deux options : exécuter flutter build ipa et gérer la sortie, ou utiliser gym directement sur le workspace Xcode que Flutter génère. Je préfère gym car il donne plus de contrôle sur la signature et les options d'export, mais les deux fonctionnent.

# 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

Le flag --no-codesign dans l'étape de build Flutter est intentionnel — nous laissons gym gérer la signature avec les profils gérés par match plutôt que de s'appuyer sur la signature automatique de Xcode.

Upload vers TestFlight avec Pilot

Pilot gère les uploads TestFlight et la gestion des bêta-testeurs.

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

Le flag skip_waiting_for_build_processing est très important pour le CI. Sans lui, Fastlane va interroger App Store Connect jusqu'à ce qu'Apple ait fini de traiter votre build — ce qui peut prendre de 15 à 45 minutes. Votre runner CI resterait inactif pendant tout ce temps, brûlant des crédits de calcul. Il vaut mieux uploader et laisser le traitement se faire de manière asynchrone.

Gestion des testeurs TestFlight

Vous pouvez aussi automatiser la gestion des groupes de testeurs :

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

Pour les testeurs externes, rappelez-vous qu'Apple exige une Beta App Review pour le premier build envoyé aux groupes externes et chaque fois que vous ajoutez de nouveaux groupes de test externes. C'est une porte manuelle que vous ne pouvez pas contourner par l'automatisation — prévoyez un délai de 24 à 48 heures pour les premières distributions externes.

Soumission App Store avec Deliver

Une fois votre application prête pour la production, deliver gère la soumission App Store.

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 peut aussi gérer les métadonnées de votre App Store — captures d'écran, descriptions, mots-clés, notes de version — le tout stocké sous forme de fichiers dans votre dépôt.

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

C'est l'une des fonctionnalités les plus sous-estimées de Fastlane. Avoir votre fiche App Store sous contrôle de version signifie que vous pouvez revoir les changements de métadonnées dans les pull requests, suivre quand les descriptions ont été mises à jour et revenir en arrière si un rédacteur fait une erreur.

Stratégies d'incrémentation de version

La gestion des versions est étonnamment controversée. L'approche que j'ai adoptée utilise une combinaison de versionnement sémantique pour la version marketing et un numéro de build auto-incrémenté.

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

Pour le numéro de build spécifiquement, je recommande d'utiliser le numéro de build CI ou un horodatage plutôt qu'un simple entier incrémental. Cela évite les conflits quand plusieurs branches sont compilées simultanément.

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

Pour les projets Flutter, vous devez aussi garder pubspec.yaml synchronisé avec le projet Xcode :

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

Le Fastfile complet

Voici un Fastfile de production qui combine le tout :

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

Intégration GitHub Actions

Le workflow CI relie tout ensemble. Voici un workflow GitHub Actions complet pour le déploiement TestFlight :

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

Clés API App Store Connect

Remarquez que j'utilise des clés API plutôt que des identifiants Apple ID. C'est important pour le CI — l'authentification Apple ID nécessite l'authentification à deux facteurs, qui ne fonctionne pas dans les environnements non interactifs. Les clés API sont créées dans App Store Connect sous Utilisateurs et Accès, et elles n'expirent jamais sauf si vous les révoquez.

# 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
)

Stockez le contenu du fichier de clé .p8 en tant que secret encodé en base64 dans GitHub Actions. Cela évite les problèmes de chemin de fichier et garde la clé hors de votre dépôt.

Pièges courants et comment les résoudre

Incompatibilités de profils de provisionnement

L'échec de build le plus courant est une incompatibilité entre le profil de provisionnement et les entitlements de votre application. Les symptômes incluent des erreurs comme "no provisioning profile matching" ou "code signing entitlements not valid."

La solution est presque toujours de régénérer vos profils match :

fastlane match nuke appstore
fastlane match appstore

Puis vérifiez que le bundle identifier de votre projet Xcode correspond exactement à ce qui se trouve dans votre Matchfile et Appfile.

Problèmes d'entitlements

Si votre application utilise les notifications push, les associated domains, Sign in with Apple, ou d'autres capacités, celles-ci doivent être configurées à la fois dans le portail Apple Developer et dans votre fichier Runner.entitlements. Une incompatibilité entre les deux cause des échecs de signature qui produisent des messages d'erreur trompeurs.

<!-- 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>

Rejet du numéro de build

App Store Connect rejette les uploads dont le numéro de build n'est pas strictement supérieur au précédent upload pour la même version. Si vous utilisez les numéros de build CI, ce n'est généralement pas un problème. Mais si vous réinitialisez votre CI ou changez de fournisseur CI, vous pouvez rencontrer ce problème. La solution est d'utiliser app_store_build_number pour récupérer le dernier numéro de build et incrémenter à partir de là :

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

Incompatibilités de version Xcode

Apple retire régulièrement le support des builds créés avec d'anciennes versions de Xcode. Votre runner CI doit correspondre à la version Xcode avec laquelle vous développez. Sur GitHub Actions, vous pouvez spécifier ceci :

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

Consultez la documentation Apple pour la version minimale de Xcode requise pour les soumissions App Store — cela change à chaque release majeure d'iOS.

Configurations spécifiques par environnement

La plupart des applications ont besoin de configurations différentes selon les environnements — différents endpoints API, différents projets Firebase, différents feature flags. Gérez cela dans votre 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

Pour Firebase, utilisez différents fichiers GoogleService-Info.plist par environnement et copiez le bon pendant le build :

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

Surveillance de la santé des builds

Une fois votre pipeline en fonctionnement, vous voulez avoir de la visibilité sur sa santé. J'ajoute quelques éléments à chaque pipeline de déploiement :

Suivi du temps de build — si vos builds commencent à prendre plus de temps, vous voulez le savoir avant qu'ils n'atteignent la limite de timeout.

Notifications Slack — les notifications de succès et d'échec tiennent l'équipe informée sans que personne n'ait besoin de surveiller le tableau de bord CI.

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

Rétention des artefacts — conservez les fichiers dSYM pour la symbolisation des crashs. Uploadez-les automatiquement vers votre service de rapport de crashs :

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

Le retour sur investissement

La mise en place de l'ensemble de ce pipeline prend environ une journée pour quelqu'un qui l'a déjà fait, et deux à trois jours pour un débutant — la majeure partie du temps est consacrée à résoudre les problèmes de signature de code, pas à écrire la configuration Fastlane. Après cet investissement initial, chaque release suivante passe d'un processus manuel de 30 à 60 minutes à un simple git push.

La vraie valeur n'est même pas le gain de temps. C'est la confiance. Quand déployer se résume à une seule commande ou un déclencheur automatique, vous déployez plus souvent. Des déploiements plus petits signifient moins de risque par release, un retour plus rapide des testeurs et une itération plus rapide sur les fonctionnalités. Le pipeline se rentabilise non pas en heures économisées, mais en un workflow de développement fondamentalement meilleur.

DU

Danil Ulmashev

Full Stack Developer

Intéressé par une collaboration ?