Perché una pipeline CI/CD per Flutter

Quando un progetto Flutter cresce, eseguire manualmente test, analisi statica e build diventa lento e soggetto a errori. Una pipeline di Continuous Integration / Continuous Delivery (CI/CD) automatizza queste operazioni ad ogni push o pull request, garantendo che il codice sia sempre analizzato, testato e pronto per la distribuzione.

In questo articolo vedremo come costruire una pipeline completa con GitHub Actions, uno strumento gratuito (nei limiti del piano) e integrato direttamente nei repository GitHub.

Struttura di un workflow

I workflow di GitHub Actions vivono nella cartella .github/workflows/ sotto forma di file YAML. Ogni workflow è composto da:

  • trigger (on): quando eseguire la pipeline (push, pull_request, tag, ecc.)
  • jobs: gruppi di step eseguiti su una macchina virtuale (runner)
  • steps: le singole azioni (checkout, setup, comandi shell)

Analisi statica e test automatici

Partiamo dal job più importante: garantire la qualità del codice. Creiamo il file .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  analyze-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.0'
          channel: 'stable'
          cache: true

      - name: Installa dipendenze
        run: flutter pub get

      - name: Verifica formattazione
        run: dart format --set-exit-if-changed .

      - name: Analisi statica
        run: flutter analyze

      - name: Esegui i test
        run: flutter test --coverage

      - name: Carica coverage
        uses: codecov/codecov-action@v4
        with:
          files: coverage/lcov.info

Alcuni dettagli utili:

  • subosito/flutter-action è l'action de facto standard per installare Flutter; l'opzione cache: true velocizza notevolmente le esecuzioni successive.
  • dart format --set-exit-if-changed fa fallire il job se il codice non è formattato correttamente.
  • Il flag --coverage genera il file lcov.info che possiamo caricare su servizi come Codecov.

Build dell'APK e dell'App Bundle Android

Una volta superati i controlli, possiamo automatizzare la generazione degli artefatti. Aggiungiamo un job che si attiva solo quando pubblichiamo un tag di versione:

  build-android:
    needs: analyze-and-test
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.0'
          channel: 'stable'
          cache: true

      - name: Decodifica keystore
        run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks

      - name: Crea key.properties
        run: |
          echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/key.properties
          echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
          echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
          echo "storeFile=upload-keystore.jks" >> android/key.properties

      - name: Build App Bundle
        run: flutter build appbundle --release

      - name: Carica artefatto
        uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: build/app/outputs/bundle/release/app-release.aab

La chiave di firma non deve mai essere committata nel repository. La soluzione è codificarla in Base64 e salvarla come secret di GitHub (Settings > Secrets and variables > Actions):

base64 -i upload-keystore.jks | pbcopy   # macOS
base64 -w 0 upload-keystore.jks           # Linux

Build iOS su runner macOS

La compilazione iOS richiede un runner macos-latest. La firma del codice è più complessa perché servono certificati e provisioning profile. Ecco un esempio semplificato per una build non firmata (utile per i test), spesso combinata con strumenti come fastlane per la firma completa:

  build-ios:
    needs: analyze-and-test
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.0'
          channel: 'stable'
          cache: true
      - run: flutter pub get
      - name: Build iOS (no codesign)
        run: flutter build ios --release --no-codesign

Attenzione ai minuti: i runner macOS consumano crediti a un fattore moltiplicativo più alto rispetto a quelli Linux. Riserva la build iOS ai soli tag di release.

Best practice per pipeline affidabili

  • Fissa la versione di Flutter: evita channel: stable senza flutter-version, altrimenti aggiornamenti del canale potrebbero rompere la build.
  • Usa la cache: cache: true sull'action Flutter e la cache di pub riducono drasticamente i tempi.
  • Separa i job: analisi/test da un lato, build dall'altro, sfruttando needs per creare dipendenze.
  • Proteggi i segreti: keystore, password e API key sempre nei GitHub Secrets, mai in chiaro.
  • Limita i trigger costosi: le build native solo su tag o branch di release.

Distribuzione automatica

Per chiudere il cerchio puoi collegare la pipeline a servizi di distribuzione:

  • Firebase App Distribution per i tester interni tramite l'action ufficiale.
  • Google Play con r0adkll/upload-google-play per il caricamento automatico dell'AAB.
  • TestFlight / App Store tramite fastlane e le action Apple.

Conclusioni

Una pipeline CI/CD ben strutturata trasforma il rilascio da un'operazione manuale rischiosa a un processo affidabile e ripetibile. Con GitHub Actions puoi partire da un semplice job di analisi e test, per poi aggiungere gradualmente build multipiattaforma e distribuzione automatica, mantenendo il codice sempre pronto per la produzione.