CI/CD per Flutter: build firmate e pubblicazione sugli store con GitHub Actions

Foto di Wesley Derks su Unsplash

GuideAvanzato75 min Flutter 3.x

CI/CD per Flutter: build firmate e pubblicazione sugli store con GitHub Actions

Automatizzare la consegna di un'app Flutter significa eliminare la classe di errori più fastidiosa: quella umana. Keystore dimenticati sul portatile di un collega, build number duplicati, provisioning profile scaduti, versioni caricate sullo store con il flavor sbagliato.

In questo tutorial advanced costruiamo una pipeline CI/CD completa con GitHub Actions:

  • job di quality gate (flutter analyze, dart format, test unitari e widget) su ogni pull request;
  • firma Android con keystore iniettato dai GitHub Secrets in base64 e build di un .aab con obfuscation e symbol upload;
  • firma iOS con certificati e provisioning profile importati in un keychain temporaneo sul runner macOS;
  • upload automatico su Google Play (traccia internal) e su TestFlight tramite App Store Connect API key;
  • versionamento derivato dal tag Git e build number derivato da github.run_number, per build sempre riproducibili e monotone.

Prerequisiti: un progetto Flutter 3.x funzionante, un account Google Play Console con l'app già creata (il primo upload va sempre fatto a mano), un account Apple Developer con app registrata su App Store Connect, e diritti di amministratore sul repository per creare Secrets ed Environments.

Alla fine avrai un flusso in cui git tag v1.4.0 && git push --tags produce una release firmata su entrambi gli store senza toccare un solo pulsante.

  1. 1

    Impostare il quality gate: analisi, format e test su ogni PR

    Prima di firmare qualsiasi cosa serve una pipeline che blocchi il codice rotto. Creiamo .github/workflows/ci.yml, eseguito su ogni pull request e su main.

    Punti chiave dell'approccio advanced:

    • pin della versione di Flutter: mai channel: stable senza versione, altrimenti la build di ieri non è riproducibile oggi. Se usi fvm, leggi la versione da .fvmrc/.fvm/fvm_config.json.
    • cache delle dipendenze pub e degli artefatti Gradle: riduce i tempi da ~6 a ~2 minuti.
    • dart format --set-exit-if-changed e flutter analyze --fatal-infos per fallire su ogni deriva stilistica.
    • concurrency group per cancellare le run obsolete quando si pusha di nuovo sullo stesso branch.
    • coverage caricata su Codecov (opzionale ma utile come soglia di merge).

    Aggiungi anche una regola di branch protection su main che richieda il job quality verde.

    name: CI
    
    on:
      pull_request:
        branches: [main, develop]
      push:
        branches: [main]
    
    concurrency:
      group: ci-${{ github.ref }}
      cancel-in-progress: true
    
    env:
      FLUTTER_VERSION: '3.24.5'
    
    jobs:
      quality:
        runs-on: ubuntu-latest
        timeout-minutes: 20
        steps:
          - uses: actions/checkout@v4
    
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: ${{ env.FLUTTER_VERSION }}
              channel: stable
              cache: true
    
          - name: Dipendenze
            run: flutter pub get
    
          - name: Codegen (se usi build_runner)
            run: dart run build_runner build --delete-conflicting-outputs
            continue-on-error: false
    
          - name: Formattazione
            run: dart format --output=none --set-exit-if-changed .
    
          - name: Analisi statica
            run: flutter analyze --fatal-infos --fatal-warnings
    
          - name: Test con coverage
            run: flutter test --coverage --reporter expanded
    
          - name: Upload coverage
            uses: codecov/codecov-action@v4
            with:
              files: coverage/lcov.info
              token: ${{ secrets.CODECOV_TOKEN }}
            if: always()

    Risultato atteso

    Ogni pull request esegue automaticamente format, analyze e test; la run viene annullata se pushi un nuovo commit sullo stesso branch.

  2. 2

    Configurare la firma Android leggibile sia in locale sia in CI

    La firma Android deve funzionare in due mondi: sul tuo Mac (dove hai key.properties) e sul runner (dove hai solo variabili d'ambiente). La soluzione robusta è un build.gradle che legge prima il file, poi le variabili d'ambiente e che, se non trova nulla, ricade sulla firma di debug solo per le build locali.

    Genera il keystore (una volta sola, poi conservalo in un password manager):

    keytool -genkey -v -keystore upload-keystore.jks \
      -keyalg RSA -keysize 2048 -validity 10000 -alias upload
    

    Aggiungi al .gitignore:

    android/key.properties
    **/*.jks
    

    Nel build.gradle abilitiamo anche minifyEnabled/shrinkResources e configuriamo ndk.debugSymbolLevel per caricare i simboli nativi su Play Console (crash report leggibili).

    // android/app/build.gradle
    
    def keystoreProperties = new Properties()
    def keystorePropertiesFile = rootProject.file('key.properties')
    if (keystorePropertiesFile.exists()) {
        keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
    }
    
    // Fallback su variabili d'ambiente (CI)
    def ksPath = keystoreProperties['storeFile'] ?: System.getenv('ANDROID_KEYSTORE_PATH')
    def ksPassword = keystoreProperties['storePassword'] ?: System.getenv('ANDROID_KEYSTORE_PASSWORD')
    def keyAlias = keystoreProperties['keyAlias'] ?: System.getenv('ANDROID_KEY_ALIAS')
    def keyPassword = keystoreProperties['keyPassword'] ?: System.getenv('ANDROID_KEY_PASSWORD')
    
    android {
        namespace "it.example.myapp"
        compileSdk 34
    
        defaultConfig {
            applicationId "it.example.myapp"
            minSdk 23
            targetSdk 34
            versionCode flutter.versionCode
            versionName flutter.versionName
            ndk {
                debugSymbolLevel 'FULL'
            }
        }
    
        signingConfigs {
            release {
                if (ksPath != null) {
                    storeFile file(ksPath)
                    storePassword ksPassword
                    keyAlias keyAlias
                    keyPassword keyPassword
                }
            }
        }
    
        buildTypes {
            release {
                signingConfig ksPath != null ? signingConfigs.release : signingConfigs.debug
                minifyEnabled true
                shrinkResources true
                proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            }
        }
    }

    Risultato atteso

    `flutter build appbundle --release` produce un AAB firmato con il keystore di upload sia in locale sia in CI, senza duplicare la configurazione.

  3. 3

    Cifrare e caricare i segreti su GitHub (Android e iOS)

    I file binari (keystore, certificati .p12, provisioning profile) non possono stare in un Secret così come sono: vanno convertiti in base64 su una sola riga.

    # Android
    base64 -i upload-keystore.jks | tr -d '\n' | pbcopy   # macOS
    base64 -w0 upload-keystore.jks                        # Linux
    
    # iOS: certificato di distribuzione esportato dal Portachiavi
    base64 -i distribution.p12 | tr -d '\n' | pbcopy
    # iOS: provisioning profile scaricato da developer.apple.com
    base64 -i MyApp_AppStore.mobileprovision | tr -d '\n' | pbcopy
    

    Per Google Play crea un service account in Google Cloud, abilita l'API "Google Play Android Developer", genera una chiave JSON e concedile in Play Console (Utenti e autorizzazioni) il permesso di rilasciare sulle tracce di test.

    Per Apple crea in App Store Connect → Users and Access → Integrations una API Key (ruolo App Manager): otterrai Issuer ID, Key ID e il file AuthKey_XXXX.p8.

    Vai poi in Settings → Environments del repo, crea l'environment production con required reviewers (così nessuno pubblica per sbaglio) e inserisci i Secret elencati nello snippet. Usare un Environment invece dei soli repository secrets ti dà approvazione manuale e log di audit.

    # Secrets da creare nell'environment "production"
    #
    # --- Android ---
    # ANDROID_KEYSTORE_BASE64     -> contenuto base64 di upload-keystore.jks
    # ANDROID_KEYSTORE_PASSWORD
    # ANDROID_KEY_ALIAS
    # ANDROID_KEY_PASSWORD
    # PLAY_SERVICE_ACCOUNT_JSON   -> JSON del service account (testo integrale)
    #
    # --- iOS ---
    # IOS_CERT_P12_BASE64         -> certificato Apple Distribution in base64
    # IOS_CERT_PASSWORD           -> password di export del .p12
    # IOS_PROVISION_PROFILE_BASE64
    # IOS_KEYCHAIN_PASSWORD       -> stringa random, serve solo al runner
    # APPSTORE_ISSUER_ID
    # APPSTORE_KEY_ID
    # APPSTORE_PRIVATE_KEY        -> contenuto del file .p8 (con BEGIN/END)
    #
    # --- Comuni ---
    # API_BASE_URL, SENTRY_DSN ... -> passati via --dart-define
    
    # Verifica rapida che il base64 sia integro (deve stampare "JKS" o simile)
    # echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/ks.jks && keytool -list -keystore /tmp/ks.jks

    Risultato atteso

    Tutti i segreti sono disponibili nell'environment `production`; nessun file sensibile è versionato nel repository.

  4. 4

    Workflow di release Android: build AAB firmato e upload su Google Play

    Ora il cuore della pipeline. Il workflow si attiva su tag v* (oppure manualmente con workflow_dispatch) e:

    1. estrae la versione dal tag (v1.4.01.4.0);
    2. calcola il build number come github.run_number sommato a un offset, così è sempre crescente anche se cambi repo;
    3. decodifica il keystore in android/app/upload-keystore.jks (fuori dal workspace tracciato);
    4. builda con --obfuscate --split-debug-info e carica i simboli come artefatto (ti serviranno per de-offuscare gli stack trace);
    5. pubblica sulla traccia internal con r0adkll/upload-google-play.

    Nota il passo finale rm -f: cancellare esplicitamente i materiali di firma è buona igiene, anche se i runner GitHub-hosted sono effimeri. Attenzione anche a --dart-define: i valori compaiono nella riga di comando, quindi non passare mai chiavi veramente segrete al client (finirebbero comunque nel binario).

    name: Release Android
    
    on:
      push:
        tags: ['v*']
      workflow_dispatch:
    
    jobs:
      android:
        runs-on: ubuntu-latest
        environment: production
        timeout-minutes: 40
        steps:
          - uses: actions/checkout@v4
    
          - uses: actions/setup-java@v4
            with:
              distribution: temurin
              java-version: '17'
              cache: gradle
    
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: '3.24.5'
              channel: stable
              cache: true
    
          - name: Calcola versione e build number
            id: ver
            run: |
              VERSION="${GITHUB_REF_NAME#v}"
              BUILD=$(( ${{ github.run_number }} + 1000 ))
              echo "version=$VERSION" >> $GITHUB_OUTPUT
              echo "build=$BUILD" >> $GITHUB_OUTPUT
              echo "Rilascio $VERSION+$BUILD"
    
          - name: Ripristina keystore
            env:
              KS: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
            run: echo "$KS" | base64 --decode > android/app/upload-keystore.jks
    
          - run: flutter pub get
    
          - name: Build App Bundle firmato
            env:
              ANDROID_KEYSTORE_PATH: upload-keystore.jks
              ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
              ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
              ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
            run: |
              flutter build appbundle --release \
                --build-name=${{ steps.ver.outputs.version }} \
                --build-number=${{ steps.ver.outputs.build }} \
                --dart-define=API_BASE_URL=${{ vars.API_BASE_URL }} \
                --obfuscate --split-debug-info=build/symbols
    
          - name: Salva i simboli di debug
            uses: actions/upload-artifact@v4
            with:
              name: debug-symbols-${{ steps.ver.outputs.version }}
              path: build/symbols
              retention-days: 90
    
          - name: Upload su Google Play (traccia internal)
            uses: r0adkll/upload-google-play@v1
            with:
              serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
              packageName: it.example.myapp
              releaseFiles: build/app/outputs/bundle/release/app-release.aab
              track: internal
              status: completed
              mappingFile: build/app/outputs/mapping/release/mapping.txt
              debugSymbols: build/app/intermediates/merged_native_libs/release/out/lib
    
          - name: Pulizia materiali di firma
            if: always()
            run: rm -f android/app/upload-keystore.jks

    Risultato atteso

    Pushando il tag `v1.4.0`, dopo pochi minuti l'AAB firmato compare nella traccia interna di Google Play con versione 1.4.0 e build number crescente.

  5. 5

    Workflow di release iOS: keychain temporaneo, IPA e TestFlight

    Su iOS la parte delicata è la firma. Sul runner macOS creiamo un keychain temporaneo, importiamo il .p12, installiamo il provisioning profile e usiamo flutter build ipa con un ExportOptions.plist in modalità manual.

    Due accortezze fondamentali:

    • security set-key-partition-list è indispensabile, altrimenti codesign resta bloccato in attesa di una conferma UI che non arriverà mai;
    • il nome del provisioning profile in ExportOptions.plist deve corrispondere esattamente a quello registrato su Apple Developer.

    Per l'upload usiamo xcrun altool/notarytool? No: per TestFlight la via moderna è xcrun altool --upload-app con API key (niente password app-specific, niente 2FA da gestire). La chiave .p8 va copiata in ~/private_keys con il nome esatto AuthKey_<KEY_ID>.p8.

    Se il progetto usa CocoaPods con dipendenze pesanti, aggiungi una cache di ios/Pods chiavata su Podfile.lock.

    name: Release iOS
    
    on:
      push:
        tags: ['v*']
      workflow_dispatch:
    
    jobs:
      ios:
        runs-on: macos-14
        environment: production
        timeout-minutes: 60
        steps:
          - uses: actions/checkout@v4
    
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: '3.24.5'
              channel: stable
              cache: true
    
          - name: Versione e build number
            id: ver
            run: |
              echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
              echo "build=$(( ${{ github.run_number }} + 1000 ))" >> $GITHUB_OUTPUT
    
          - name: Importa certificato e provisioning profile
            env:
              CERT: ${{ secrets.IOS_CERT_P12_BASE64 }}
              CERT_PWD: ${{ secrets.IOS_CERT_PASSWORD }}
              PROFILE: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
              KC_PWD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
            run: |
              CERT_PATH=$RUNNER_TEMP/cert.p12
              PROFILE_PATH=$RUNNER_TEMP/profile.mobileprovision
              KEYCHAIN=$RUNNER_TEMP/build.keychain-db
    
              echo "$CERT" | base64 --decode > "$CERT_PATH"
              echo "$PROFILE" | base64 --decode > "$PROFILE_PATH"
    
              security create-keychain -p "$KC_PWD" "$KEYCHAIN"
              security set-keychain-settings -lut 21600 "$KEYCHAIN"
              security unlock-keychain -p "$KC_PWD" "$KEYCHAIN"
              security import "$CERT_PATH" -P "$CERT_PWD" -A -t cert -f pkcs12 -k "$KEYCHAIN"
              security set-key-partition-list -S apple-tool:,apple: -k "$KC_PWD" "$KEYCHAIN"
              security list-keychain -d user -s "$KEYCHAIN" login.keychain
    
              mkdir -p ~/Library/MobileDevice/Provisioning\\ Profiles
              cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\\ Profiles/
    
          - run: flutter pub get
    
          - name: Genera ExportOptions.plist
            run: |
              cat > ios/ExportOptions.plist <<'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>ABCDE12345</string>
                <key>signingStyle</key><string>manual</string>
                <key>uploadSymbols</key><true/>
                <key>provisioningProfiles</key>
                <dict>
                  <key>it.example.myapp</key>
                  <string>MyApp AppStore</string>
                </dict>
              </dict>
              </plist>
              PLIST
    
          - name: Build IPA
            run: |
              flutter build ipa --release \
                --build-name=${{ steps.ver.outputs.version }} \
                --build-number=${{ steps.ver.outputs.build }} \
                --dart-define=API_BASE_URL=${{ vars.API_BASE_URL }} \
                --obfuscate --split-debug-info=build/symbols \
                --export-options-plist=ios/ExportOptions.plist
    
          - name: Upload su TestFlight
            env:
              KEY_ID: ${{ secrets.APPSTORE_KEY_ID }}
              ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
              P8: ${{ secrets.APPSTORE_PRIVATE_KEY }}
            run: |
              mkdir -p ~/private_keys
              echo "$P8" > ~/private_keys/AuthKey_$KEY_ID.p8
              IPA=$(ls build/ios/ipa/*.ipa | head -n 1)
              xcrun altool --upload-app -f "$IPA" -t ios \
                --apiKey "$KEY_ID" --apiIssuer "$ISSUER_ID"
    
          - name: Pulizia keychain
            if: always()
            run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true

    Risultato atteso

    L'IPA firmato viene caricato su App Store Connect e compare in TestFlight in stato "Processing" entro pochi minuti.

  6. 6

    Versionamento coerente e gestione dei flavor nella pipeline

    Con due workflow separati rischi che Android e iOS abbiano numeri diversi. Centralizza la logica in uno script Dart eseguito da entrambi i job: legge il tag, valida il SemVer, verifica la coerenza con pubspec.yaml e scrive gli output per GitHub Actions.

    Aggiungi anche il supporto ai flavor: se hai dev, staging e prod, il workflow di release deve accettare un input flavor e propagarlo a flutter build appbundle --flavor prod -t lib/main_prod.dart. Ricorda che su iOS ogni flavor richiede uno Scheme Xcode e un provisioning profile dedicato: mappa quindi flavor → secret con una include nella matrice.

    Lo script qui sotto fallisce (exit 1) se il tag non corrisponde alla versione dichiarata nel pubspec.yaml: è la guardia che impedisce di rilasciare v1.4.0 con dentro 1.3.9.

    // tool/release_version.dart
    // Uso: dart run tool/release_version.dart v1.4.0 123
    import 'dart:io';
    
    void main(List<String> args) {
      if (args.length < 2) {
        stderr.writeln('Uso: release_version.dart <tag> <runNumber>');
        exit(64);
      }
    
      final tag = args[0];
      final runNumber = int.parse(args[1]);
    
      final semver = RegExp(r'^v(\d+)\.(\d+)\.(\d+)$');
      final match = semver.firstMatch(tag);
      if (match == null) {
        stderr.writeln('Tag non valido: $tag (atteso vX.Y.Z)');
        exit(1);
      }
      final version = tag.substring(1);
    
      final pubspec = File('pubspec.yaml').readAsStringSync();
      final declared = RegExp(r'^version:\s*([0-9.]+)', multiLine: true)
          .firstMatch(pubspec)
          ?.group(1);
    
      if (declared != version) {
        stderr.writeln('Mismatch: pubspec=$declared, tag=$version');
        exit(1);
      }
    
      final buildNumber = runNumber + 1000;
      final out = File(Platform.environment['GITHUB_OUTPUT'] ?? '/dev/stdout');
      out.writeAsStringSync(
        'version=$version\nbuild=$buildNumber\n',
        mode: FileMode.append,
      );
      stdout.writeln('OK -> $version+$buildNumber');
    }

    Risultato atteso

    Il job fallisce subito (in pochi secondi) se il tag e il `pubspec.yaml` divergono, evitando build inutili da 20 minuti.

  7. 7

    Rifinire la pipeline: approvazioni, notifiche, rollback e costi

    Ultimo giro di vite per rendere la pipeline davvero production ready.

    1. Approvazione umana. L'environment production con required reviewers mette il job in pausa finché un maintainer non approva: è il tuo interruttore di sicurezza.

    2. Release notes automatiche. Genera il changelog dai commit tra due tag e passalo a Play Console come whatsnew/whatsnew-it-IT, oppure crea la GitHub Release allegando AAB/IPA.

    3. Rollback. Su Google Play non puoi "cancellare" una release: puoi però fermare il rollout graduale (status: inProgress + userFraction: 0.1) e promuovere solo dopo aver visto i crash-free users. Su TestFlight basta non promuovere la build.

    4. Costi. I runner macOS costano ~10x i runner Linux: esegui il job iOS solo su tag, mai su PR, e imposta sempre un timeout-minutes per evitare job appesi da 6 ore.

    5. Sicurezza. Usa permissions: contents: read di default, evita pull_request_target, pinna le action a un commit SHA se gestisci segreti di produzione, e ricorda che i log mascherano i secret solo se non li trasformi (un base64 di un secret NON viene mascherato).

    6. Debug delle build offuscate. Conserva la cartella build/symbols per ogni release: senza di essa gli stack trace di Crashlytics/Sentry sono illeggibili. Usa flutter symbolize -i stack.txt -d symbols/app.android-arm64.symbols.

    # Estratto: rollout graduale, changelog e notifica Slack
    
          - name: Genera changelog dal tag precedente
            id: changelog
            run: |
              PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
              mkdir -p distribution/whatsnew
              if [ -z "$PREV" ]; then
                git log --pretty="* %s" -20 > distribution/whatsnew/whatsnew-it-IT
              else
                git log "$PREV"..HEAD --pretty="* %s" > distribution/whatsnew/whatsnew-it-IT
              fi
              head -c 480 distribution/whatsnew/whatsnew-it-IT > /tmp/wn && mv /tmp/wn distribution/whatsnew/whatsnew-it-IT
    
          - name: Upload con rollout al 10%
            uses: r0adkll/upload-google-play@v1
            with:
              serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
              packageName: it.example.myapp
              releaseFiles: build/app/outputs/bundle/release/app-release.aab
              track: production
              status: inProgress
              userFraction: 0.1
              whatsNewDirectory: distribution/whatsnew
    
          - name: Notifica Slack
            if: always()
            uses: slackapi/slack-github-action@v1
            with:
              payload: |
                {"text": "Release ${{ github.ref_name }}: ${{ job.status }}"}
            env:
              SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
              SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

    Risultato atteso

    La release richiede un'approvazione manuale, parte con un rollout al 10%, pubblica il changelog in italiano e notifica il team su Slack a fine job.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!