Skip to main content
mobile1 marzo 202612 min di lettura

Automazione dei Deploy iOS: App Store Connect e TestFlight

Una guida completa all'automazione dei deploy di app iOS — dalla configurazione di Fastlane alla distribuzione su TestFlight e all'invio all'App Store.

iosfastlanecicd
Automazione dei Deploy iOS: App Store Connect e TestFlight

Ogni sviluppatore iOS ha vissuto il rituale: aprire Xcode, incrementare il numero di build, selezionare lo schema di archiviazione, attendere quindici minuti, cliccare nell'Organizer, caricare su App Store Connect, attendere ancora un po', quindi aggiungere manualmente i tester in TestFlight. Facendolo due volte a settimana su più app, si iniziano a perdere ore che dovrebbero essere dedicate alla scrittura di codice. La parte peggiore non è il tempo, ma l'incoerenza. Un passaggio dimenticato, un profilo di provisioning sbagliato, e la build fallisce silenziosamente o viene rifiutata dai controlli automatici di Apple ore dopo.

Ho automatizzato l'intera pipeline per un'app iOS basata su Flutter e da allora ho applicato gli stessi schemi ad altri progetti. L'investimento si ripaga dopo il terzo o quarto ciclo di rilascio.

Perché i Deploy Manuali Falliscono

Il processo manuale presenta tre problemi fondamentali, oltre all'ovvio spreco di tempo.

Primo, la concentrazione della conoscenza. Quando solo una persona del team sa come effettuare il deploy, diventa un collo di bottiglia. Vacanze, giorni di malattia o semplicemente trovarsi in un fuso orario diverso significano che i rilasci si bloccano. La documentazione aiuta, ma una guida al deploy di 15 passaggi è di per sé un problema — diventa obsoleta nel momento in cui qualcuno cambia un'impostazione di build.

Secondo, l'errore umano nel momento peggiore. I rilasci tendono a verificarsi sotto pressione. Una correzione di bug critica, una scadenza da un cliente, una funzionalità che deve essere rilasciata prima di un concorrente. Queste sono esattamente le condizioni in cui qualcuno dimentica di incrementare il numero di build, seleziona lo schema sbagliato o carica una build di debug invece di una di rilascio.

Terzo, la mancanza di auditabilità. Quando qualcosa va storto con un rilascio, si vuole sapere esattamente cosa è stato costruito, da quale commit, con quale configurazione. I processi manuali non lasciano una traccia affidabile.

Fastlane: Le Fondamenta

Fastlane è lo standard de facto per l'automazione del deploy iOS, e per una buona ragione. Gestisce la firma del codice, la compilazione, il caricamento e la gestione dei metadati tramite un DSL basato su Ruby che si legge come una checklist di deploy.

Configurazione Iniziale

Configurare Fastlane in un progetto esistente è semplice.

# Install Fastlane
brew install fastlane

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

Durante l'inizializzazione, Fastlane chiede cosa si desidera automatizzare. Consiglio di scegliere la configurazione manuale e di configurare tutto esplicitamente — la configurazione automatica fa delle ipotesi che non sempre corrispondono alla struttura del progetto, specialmente per le app Flutter.

Il risultato è una directory fastlane/ contenente un Appfile e un Fastfile.

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

L'Appfile memorizza i dettagli del tuo account Apple Developer. Gli ID del team sono importanti se il tuo Apple ID appartiene a più team — senza di essi, Fastlane ti chiederà interattivamente, il che interrompe la CI.

Firma del Codice con Match

La firma del codice è dove la maggior parte degli sforzi di automazione iOS muore. Profili di provisioning, certificati, entitlements — l'intero sistema sembra progettato per punire l'automazione. Lo strumento match di Fastlane risolve questo problema memorizzando i tuoi certificati e profili in un repository Git privato o in un cloud storage, rendendoli accessibili a qualsiasi macchina che necessiti di compilare.

Configurazione di Match

fastlane match init

Questo crea un Matchfile dove configuri il tuo backend di archiviazione.

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

Quindi genera e memorizza i tuoi certificati:

# Generate development certificates and profiles
fastlane match development

# Generate App Store distribution certificates and profiles
fastlane match appstore

Match cripta tutto prima di effettuare il push nel repository. Imposti una passphrase durante la configurazione che ti servirà su ogni macchina — inclusa la CI.

Perché Match Invece della Firma Manuale

L'alternativa — la gestione manuale dei certificati tramite il portale Apple Developer e la distribuzione di file .p12 — funziona finché non smette di funzionare. Le modalità di fallimento comuni includono:

  • Il certificato di uno sviluppatore scade e nessuno se ne accorge finché una build di rilascio non fallisce.
  • Qualcuno revoca un certificato durante il debug di un problema di firma, invalidando tutti i profili che dipendono da esso.
  • Una nuova macchina CI necessita di provisioning e la persona che ha configurato la macchina originale ha lasciato il team.

Match elimina tutti questi problemi essendo l'unica fonte di verità. Se un certificato scade, esegui fastlane match nuke e rigeneri. Ogni macchina preleva dallo stesso repository.

Firma del Codice CI

Sulla CI, è necessario gestire il Portachiavi di macOS poiché non esiste una GUI per sbloccarlo. Fastlane fornisce degli helper per questo:

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

Il flag readonly: true è fondamentale per la CI. Senza di esso, match potrebbe tentare di creare nuovi certificati se non riesce a trovarne di esistenti, il che fallirà senza autorizzazione interattiva e può revocare i certificati esistenti.

Compilazione con Gym

Gym è lo strumento di compilazione di Fastlane. Avvolge xcodebuild con impostazioni predefinite sensate e un output di errore migliore.

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

Per i progetti Flutter, hai due opzioni: eseguire flutter build ipa e gestire l'output, oppure usare gym direttamente sull'area di lavoro Xcode che Flutter genera. Preferisco gym perché offre un maggiore controllo sulle opzioni di firma ed esportazione, ma entrambi funzionano.

# 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

Il flag --no-codesign nel passaggio di build di Flutter è intenzionale — lasciamo che gym gestisca la firma con i profili gestiti da match piuttosto che affidarci alla firma automatica di Xcode.

Caricamento su TestFlight con Pilot

Pilot gestisce i caricamenti su TestFlight e la gestione dei beta tester.

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

Il flag skip_waiting_for_build_processing è molto importante per la CI. Senza di esso, Fastlane interrogherà App Store Connect finché Apple non avrà terminato l'elaborazione della tua build — il che può richiedere da 15 a 45 minuti. Il tuo runner CI rimarrebbe inattivo per tutto quel tempo, bruciando crediti di calcolo. Meglio caricare e lasciare che l'elaborazione avvenga in modo asincrono.

Gestione dei Tester di TestFlight

Puoi anche automatizzare la gestione dei gruppi di tester:

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

Per i tester esterni, ricorda che Apple richiede la Beta App Review per la prima build inviata a gruppi esterni e ogni volta che aggiungi nuovi gruppi di testing esterni. Questa è una barriera manuale che non puoi automatizzare — prevedi un ritardo di 24-48 ore per le prime distribuzioni esterne.

Invio all'App Store con Deliver

Una volta che la tua app è pronta per la produzione, deliver gestisce l'invio all'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 può anche gestire i metadati del tuo App Store — screenshot, descrizioni, parole chiave, note di rilascio — tutti archiviati come file nel tuo repository.

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

Questa è una delle funzionalità più sottovalutate di Fastlane. Avere la tua scheda dell'App Store sotto controllo di versione significa che puoi rivedere le modifiche ai metadati nelle pull request, tenere traccia di quando le descrizioni sono state aggiornate e ripristinare se un copywriter commette un errore.

Strategie di Incremento della Versione

La gestione delle versioni è sorprendentemente controversa. L'approccio che ho adottato utilizza una combinazione di versionamento semantico per la versione di marketing e un numero di build auto-incrementante.

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

Per il numero di build in particolare, consiglio di utilizzare il numero di build della CI o un timestamp piuttosto che un semplice intero incrementale. Questo evita conflitti quando più rami vengono compilati contemporaneamente.

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

Per i progetti Flutter, devi anche mantenere pubspec.yaml sincronizzato con il progetto 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

Il Fastfile Completo

Ecco un Fastfile di produzione che combina tutto:

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

Integrazione con GitHub Actions

Il workflow CI lega tutto insieme. Ecco un workflow completo di GitHub Actions per il deploy su 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

Chiavi API di App Store Connect

Nota che sto usando chiavi API anziché credenziali Apple ID. Questo è importante per la CI — l'autenticazione con Apple ID richiede l'autenticazione a due fattori, che non funziona in ambienti non interattivi. Le chiavi API vengono create in App Store Connect sotto Utenti e Accesso, e non scadono mai a meno che non le revochi.

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

Memorizza il contenuto del file chiave .p8 come un segreto codificato in base64 in GitHub Actions. Questo evita problemi di percorso dei file e mantiene la chiave fuori dal tuo repository.

Problemi Comuni e Come Risolverli

Discrepanze nei Profili di Provisioning

L'errore di compilazione più comune è una discrepanza tra il profilo di provisioning e gli entitlements nella tua app. I sintomi includono errori come "no provisioning profile matching" o "code signing entitlements not valid".

La soluzione è quasi sempre rigenerare i tuoi profili match:

fastlane match nuke appstore
fastlane match appstore

Quindi verifica che l'identificatore del bundle del tuo progetto Xcode corrisponda esattamente a quanto presente nel tuo Matchfile e Appfile.

Problemi di Entitlements

Se la tua app utilizza notifiche push, domini associati, Sign in with Apple o altre funzionalità, queste devono essere configurate sia nel portale Apple Developer che nel tuo file Runner.entitlements. Una discrepanza tra i due causa errori di firma che producono messaggi di errore fuorvianti.

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

Rifiuto del Numero di Build

App Store Connect rifiuta i caricamenti in cui il numero di build non è strettamente maggiore del caricamento precedente per la stessa versione. Se utilizzi i numeri di build della CI, questo di solito non è un problema. Ma se reimposti la tua CI o cambi provider CI, potresti riscontrare questo problema. La soluzione è usare app_store_build_number per recuperare l'ultimo numero di build e incrementare da lì:

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

Discrepanze nella Versione di Xcode

Apple interrompe regolarmente il supporto per le build create con versioni di Xcode più vecchie. Il tuo runner CI deve corrispondere alla versione di Xcode con cui sviluppi. Su GitHub Actions, puoi specificarlo:

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

Controlla la documentazione di Apple per la versione minima di Xcode richiesta per gli invii all'App Store — questa cambia ad ogni rilascio maggiore di iOS.

Configurazioni Specifiche per Ambiente

La maggior parte delle app necessita di configurazioni diverse per ambienti diversi — endpoint API diversi, progetti Firebase diversi, feature flag diverse. Gestisci questo nel tuo 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

Per Firebase, usa file GoogleService-Info.plist diversi per ambiente e copia quello corretto durante la build:

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

Monitoraggio dello Stato della Build

Una volta che la tua pipeline è in esecuzione, vuoi visibilità sul suo stato. Aggiungo alcune cose a ogni pipeline di deploy:

Tracciamento del tempo di build — se le tue build iniziano a richiedere più tempo, vuoi saperlo prima che raggiungano il limite di timeout.

Notifiche Slack — le notifiche di successo e fallimento tengono informato il team senza che nessuno debba monitorare la dashboard della CI.

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

Conservazione degli artefatti — conserva i file dSYM per la simbolizzazione dei crash. Caricali automaticamente sul tuo servizio di crash reporting:

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

Il Ritorno sull'Investimento

La configurazione di questa intera pipeline richiede circa un giorno per chi l'ha già fatta, e due o tre giorni per un principiante — la maggior parte del tempo viene spesa a combattere problemi di firma del codice, non a scrivere la configurazione di Fastlane. Dopo questo investimento iniziale, ogni rilascio successivo passa da un processo manuale di 30-60 minuti a un semplice git push.

Il vero valore non è nemmeno il risparmio di tempo. È la fiducia. Quando il deploy è un singolo comando o un trigger automatico, si effettua il deploy più spesso. Deploy più piccoli significano meno rischi per rilascio, feedback più rapidi dai tester e iterazioni più veloci sulle funzionalità. La pipeline si ripaga non in ore risparmiate, ma in un flusso di lavoro di sviluppo fondamentalmente migliore.

DU

Danil Ulmashev

Full Stack Developer

Interessato a collaborare?