Release automatizzate in un monorepo Flutter con Melos: versioning, changelog e publish in CI

Foto di Samuel Sianipar su Unsplash

Best practiceAvanzato55 min Flutter 3.x (Dart 3.x, Melos 6.x)

Release automatizzate in un monorepo Flutter con Melos: versioning, changelog e publish in CI

Quando un monorepo Flutter cresce oltre i tre o quattro package interni, il collo di bottiglia non è più il codice ma la release: chi alza la versione di core_network? Chi aggiorna i vincoli nei package che dipendono da esso? Chi scrive il changelog? Chi pubblica, e in che ordine?

Melos risolve esattamente questo problema: analizza il grafo delle dipendenze interne, deduce il bump di versione dai conventional commits, aggiorna i vincoli nei package dipendenti, genera i CHANGELOG.md, crea un commit e i tag Git, e infine pubblica i package pubblicabili nell'ordine topologico corretto.

In questo tutorial costruiamo una pipeline di release completa su un monorepo realistico:

my_app/
├── melos.yaml
├── apps/
│   └── mobile/            # app Flutter, non pubblicabile
└── packages/
    ├── core_models/       # modelli condivisi
    ├── core_network/      # dipende da core_models
    └── design_system/     # dipende da core_models

Partiamo dal presupposto che tu conosca già le basi di Melos (melos bootstrap, melos exec) e che il monorepo sia già in piedi: qui ci concentriamo su versioning, changelog, tag e publish automatizzati, inclusi i casi limite (hotfix, prerelease, rollback, breaking change prima della 1.0).

Riferimento: Melos 6.x con Dart 3.x. Se usi Melos 7 con i pub workspaces di Dart 3.6+, la configurazione version/publish resta identica: cambia solo dove dichiari l'elenco dei package.

  1. 1

    Configurare Melos per la release: package, script e pubspec_overrides

    Il primo passo è definire la configurazione di base. Due punti sono critici per una release pulita:

    1. Le dipendenze interne in pubspec.yaml devono essere dichiarate come dipendenze hosted con vincolo di versione, non come path:. Un package con dipendenze path: non è pubblicabile su pub.dev. Melos risolve il problema in fase di bootstrap generando dei file pubspec_overrides.yaml (da mettere in .gitignore) che puntano ai path locali: in sviluppo lavori sul codice locale, in pubblicazione il vincolo pubblicato è quello corretto.
    2. Gli script devono essere eseguibili sia in locale sia in CI, perché li richiameremo dagli hook di versioning.

    Crea quindi melos.yaml alla radice e verifica che il grafo sia quello atteso con melos list --graph.

    # melos.yaml
    name: my_app
    repository: https://github.com/acme/my_app
    
    packages:
      - apps/**
      - packages/**
    
    command:
      bootstrap:
        # i vincoli condivisi stanno qui: un solo punto di verità
        environment:
          sdk: ">=3.4.0 <4.0.0"
          flutter: ">=3.22.0"
        dependencies:
          collection: ^1.18.0
        dev_dependencies:
          very_good_analysis: ^6.0.0
        hooks:
          post: melos run generate
    
    scripts:
      analyze:
        run: melos exec -c 1 -- dart analyze . --fatal-infos --fatal-warnings
        description: Analisi statica su tutti i package.
    
      test:
        run: melos exec -c 4 --dir-exists=test -- flutter test
        description: Test di tutti i package che hanno una cartella test.
    
      generate:
        run: melos exec -c 1 --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs
        description: Rigenera il codice (json_serializable, freezed, ...).
    
      publish:check:
        run: melos publish --dry-run --no-private --yes
        description: Simula la pubblicazione senza toccare pub.dev.

    Risultato atteso

    `melos bootstrap` collega i package locali tramite `pubspec_overrides.yaml` e `melos list --graph` mostra il grafo `core_models -> core_network/design_system`. I `pubspec.yaml` contengono solo vincoli hosted.

  2. 2

    Adottare i conventional commits e configurare `command/version`

    Melos deduce il bump di versione dai messaggi di commit secondo la convenzione Conventional Commits:

    Prefisso Effetto sulla versione
    fix: patch (1.2.3 → 1.2.4)
    feat: minor (1.2.3 → 1.3.0)
    feat!: oppure footer BREAKING CHANGE: major (1.2.3 → 2.0.0), ma prima della 1.0.0 diventa un bump minor
    chore:, docs:, refactor:, test: nessun bump (compaiono comunque nel changelog se configurati)

    Il punto fondamentale è lo scope: Melos associa il commit a un package attraverso i file modificati, ma usare lo scope (feat(core_network): ...) rende il changelog molto più leggibile e permette di filtrare le sezioni.

    Aggiungi la sezione command/version a melos.yaml. Le opzioni chiave:

    • branch: impedisce di versionare da un branch sbagliato;
    • linkToCommits: inserisce nel changelog i link ai commit (richiede repository configurato);
    • workspaceChangelog: genera un CHANGELOG.md riepilogativo alla radice;
    • updateGitTagRefs: aggiorna i riferimenti ai tag nei file (utile per badge e doc);
    • changelogs: changelog aggiuntivi filtrati per scope.

    Per rendere la convenzione obbligatoria e non solo consigliata, aggiungi una validazione in CI sui titoli delle PR (es. amannn/action-semantic-pull-request) oppure un hook commit-msg locale.

    # melos.yaml (estratto)
    command:
      version:
        # versiona solo da main: evita tag creati da feature branch
        branch: main
        message: "chore(release): publish packages\n\n{new_package_versions}"
        includeCommitId: true
        linkToCommits: true
        workspaceChangelog: true
        updateGitTagRefs: true
        # propaga il bump ai package dipendenti e aggiorna i loro vincoli
        includeScopes: true
        changelogs:
          - path: CHANGELOG.md
            description: |
              Changelog consolidato di tutti i package pubblici del monorepo.
            packageFilters:
              noPrivate: true
        aggregateChangelogs: true
        hooks:
          preCommit: melos run generate && git add .

    Risultato atteso

    Un commit `feat(core_network): aggiunge retry configurabile` viene riconosciuto da Melos come bump minor del package `core_network`.

  3. 3

    Blindare la release con hook e quality gate

    Un tag di release deve corrispondere a un commit verde: se il codice generato non è aggiornato o i test falliscono, il tag è già marcio. Melos espone hook sul comando version che possiamo usare come quality gate.

    Hook disponibili sul comando version:

    • preCommit: eseguito dopo l'aggiornamento di versioni e changelog, prima del commit di release. È il punto giusto per rigenerare codice che dipende dalla versione (es. costanti appVersion) e fare git add.
    • post: eseguito a versioning completato.

    L'analisi statica e i test, invece, vanno eseguiti prima di avviare il versioning: li incapsuliamo in uno script release:prepare che fa da porta d'ingresso unica, sia in locale sia in CI.

    Un dettaglio spesso trascurato: se un package espone una costante con la propria versione, generala da pubspec.yaml nell'hook preCommit, altrimenti resterà disallineata rispetto al tag.

    # melos.yaml (estratto)
    scripts:
      release:prepare:
        run: |
          melos bootstrap && \
          melos run generate && \
          melos run analyze && \
          melos run test && \
          melos run publish:check
        description: Quality gate completo prima di versionare.
    
      version:sync:
        run: melos exec -c 1 --no-private -- dart run ../../tool/write_version.dart
        description: Scrive lib/src/version.g.dart leggendo la versione dal pubspec.
    
    command:
      version:
        hooks:
          preCommit: melos run version:sync && git add .

    Risultato atteso

    `melos run release:prepare` fallisce (exit code ≠ 0) se analisi, test o dry-run della pubblicazione non passano, bloccando la release prima che venga creato qualsiasi tag.

  4. 4

    Versionare in locale: graduate, prerelease e versioni manuali

    Ora la parte centrale. melos version esegue, in un'unica transazione:

    1. calcolo del bump per ogni package modificato dall'ultimo tag;
    2. propagazione ai dipendenti: se core_models passa a 2.0.0, i vincoli core_models: ^1.x in core_network e design_system vengono riscritti e quei package ricevono un bump patch;
    3. scrittura dei CHANGELOG.md;
    4. commit di release + un tag per package nel formato nome_package-vX.Y.Z.

    Flag che userai davvero:

    • --yes: non interattivo (obbligatorio in CI);
    • --no-private: ignora i package con publish_to: none (tipicamente le app);
    • --prerelease --preid=beta: produce 1.3.0-beta.0;
    • --graduate: promuove una prerelease a versione stabile (1.3.0-beta.3 → 1.3.0);
    • -V core_network:2.0.0: forza una versione manuale per un package specifico;
    • --dependent-versions / --dependent-constraints: controllano la propagazione (per default entrambi attivi);
    • --no-git-tag-version: calcola e scrive versioni e changelog senza creare commit e tag (utile per ispezionare il diff).

    Consiglio operativo: la prima volta esegui con --no-git-tag-version su un branch usa e getta, ispeziona il diff, poi git checkout ..

    Attenzione al semver pre-1.0: con versione 0.x.y un BREAKING CHANGE produce 0.(x+1).0, non 1.0.0. Se vuoi davvero la 1.0.0, usa -V package:1.0.0.

    # 1) Prova a vuoto: scrive versioni e changelog senza commit né tag
    melos version --no-private --no-git-tag-version --yes
    git diff            # ispeziona il risultato
    git checkout .      # annulla
    
    # 2) Release stabile reale
    melos run release:prepare
    melos version --no-private --yes
    
    # 3) Prerelease beta su un branch di integrazione
    melos version --no-private --prerelease --preid=beta --yes
    
    # 4) Promozione delle beta a stabile
    melos version --no-private --graduate --yes
    
    # 5) Forzare una major su un package specifico
    melos version -V core_models:2.0.0 --yes
    
    # 6) Pubblica il commit di release e TUTTI i tag generati
    git push --follow-tags origin main

    Risultato atteso

    Un commit `chore(release): publish packages` con i `CHANGELOG.md` aggiornati, i `pubspec.yaml` modificati (versione + vincoli interni riallineati) e un tag Git per ciascun package versionato.

  5. 5

    Preparare e verificare la pubblicazione su pub.dev

    Prima di pubblicare, ogni package pubblico deve superare una checklist:

    • publish_to: none solo su app e package interni non destinati a pub.dev (Melos li esclude con --no-private);
    • nessuna dipendenza path: o git: nel pubspec.yaml versionato (i pubspec_overrides.yaml devono essere in .gitignore);
    • homepage/repository e description valorizzati, LICENSE, README.md e CHANGELOG.md presenti;
    • vincoli SDK coerenti tra i package del monorepo.

    melos publish esegue per default un dry-run e, aspetto cruciale, calcola l'ordine topologico: core_models viene pubblicato prima di core_network. Pubblica solo i package la cui versione non è ancora presente su pub.dev, quindi è idempotente: se la CI va in errore a metà, puoi rilanciarla senza duplicare pubblicazioni.

    Per pubblicare davvero serve --no-dry-run. Le credenziali possono arrivare in due modi:

    • OIDC di GitHub Actions (consigliato): su pub.dev abiliti "Automated publishing" per ciascun package, indicando repository e pattern dei tag (es. {{package}}-v{{version}}, che è esattamente il formato dei tag di Melos). Nessun segreto da gestire.
    • Credenziali statiche: scrivi il contenuto di ~/.config/dart/pub-credentials.json in un secret e ricostruisci il file in CI. Più semplice, ma rotazione a tuo carico.
    # packages/core_network/pubspec.yaml
    name: core_network
    description: Client HTTP condiviso con retry, interceptor e mapping degli errori.
    version: 1.3.0
    repository: https://github.com/acme/my_app/tree/main/packages/core_network
    homepage: https://github.com/acme/my_app
    
    environment:
      sdk: ">=3.4.0 <4.0.0"
    
    dependencies:
      core_models: ^2.0.0   # vincolo hosted: NON usare path qui
      dio: ^5.4.0
    
    # apps/mobile/pubspec.yaml
    name: mobile
    publish_to: none        # escluso da --no-private
    version: 1.8.0+42

    Risultato atteso

    `melos publish --dry-run --no-private --yes` elenca i package da pubblicare nell'ordine corretto e non segnala errori di validazione (`Package has 0 warnings`).

  6. 6

    Automatizzare la release con GitHub Actions

    Mettiamo insieme i pezzi in due workflow distinti, perché hanno permessi e trigger diversi:

    Workflow 1 — release.yml (manuale, workflow_dispatch): esegue il quality gate, lancia melos version e pusha commit e tag. Serve contents: write. Il bot deve avere un'identità Git configurata.

    Workflow 2 — publish.yml (trigger sui tag): parte quando arrivano i tag creati dal primo workflow e pubblica su pub.dev usando OIDC. Serve id-token: write.

    Perché separarli? Perché il push dei tag da parte di un workflow con GITHUB_TOKEN non innesca altri workflow. La soluzione pulita è pubblicare dal medesimo job (come nell'esempio sotto, con un job dipendente) oppure usare un PAT/GitHub App per il push. Qui usiamo un unico workflow con due job collegati da needs, così evitiamo il problema alla radice.

    Nota sul fetch-depth: 0: Melos ha bisogno dell'intera storia e di tutti i tag per capire qual è stata l'ultima release di ogni package. Con un checkout shallow calcolerebbe bump sbagliati.

    # .github/workflows/release.yml
    name: release
    
    on:
      workflow_dispatch:
        inputs:
          prerelease:
            description: "Pubblica come prerelease beta"
            type: boolean
            default: false
    
    permissions:
      contents: write
      id-token: write   # necessario per il publishing OIDC su pub.dev
    
    jobs:
      version:
        runs-on: ubuntu-latest
        outputs:
          sha: ${{ steps.push.outputs.sha }}
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0        # storia + tag completi: indispensabile
              ref: main
    
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: '3.24.x'
              cache: true
    
          - name: Install Melos
            run: dart pub global activate melos 6.2.0
    
          - name: Bootstrap
            run: melos bootstrap
    
          - name: Quality gate
            run: melos run release:prepare
    
          - name: Configure git bot
            run: |
              git config user.name "release-bot"
              git config user.email "release-bot@users.noreply.github.com"
    
          - name: Version packages
            run: |
              if [ "${{ inputs.prerelease }}" = "true" ]; then
                melos version --no-private --prerelease --preid=beta --yes
              else
                melos version --no-private --yes
              fi
    
          - name: Push commit and tags
            id: push
            run: |
              git push --follow-tags origin main
              echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
    
      publish:
        needs: version
        runs-on: ubuntu-latest
        permissions:
          contents: read
          id-token: write
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 0
              ref: ${{ needs.version.outputs.sha }}
    
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: '3.24.x'
              cache: true
    
          - run: dart pub global activate melos 6.2.0
          - run: melos bootstrap
    
          # Rimuove gli override locali: su pub.dev devono valere i vincoli hosted
          - name: Clean pubspec overrides
            run: find . -name pubspec_overrides.yaml -delete
    
          - name: Publish to pub.dev
            run: melos publish --no-dry-run --no-private --yes

    Risultato atteso

    Avviando il workflow da GitHub, il job `version` crea commit e tag su `main` e il job `publish` carica su pub.dev solo i package con versioni non ancora pubblicate, nell'ordine topologico corretto.

  7. 7

    Gestire i casi limite: hotfix, rollback e package che non devono finire su pub.dev

    La pipeline funziona: ora servono le procedure per quando qualcosa va storto.

    Hotfix su una release passata. Crea un branch dal tag (git checkout -b hotfix/core_network-1.3.x core_network-v1.3.0), applica il fix:, poi versiona forzando il branch consentito e la versione:

    melos version -V core_network:1.3.1 --yes --no-private
    

    Ricorda che command/version/branch: main blocca il versioning da altri branch: usa melos version --no-private --yes dopo aver temporaneamente passato --branch hotfix/... oppure sposta il controllo in CI.

    Rollback. Su pub.dev non si può cancellare una versione: puoi solo ritirarla (retract, entro 7 giorni) dalla dashboard del package e pubblicare subito una patch correttiva. In locale, se il tag non è ancora stato pushato:

    git tag -d core_network-v1.3.1
    git reset --hard HEAD~1
    

    Package interni mai pubblicabili. Oltre a publish_to: none, aggiungi una verifica automatica in CI che fallisca se un pubspec.yaml pubblico contiene dipendenze path: o git:: è l'errore che più spesso rompe una release.

    Versioni allineate tra più package. Se vuoi che un gruppo di package condivida sempre la stessa versione (modello fixed versioning), usa -V multipli nello stesso comando: Melos li applica in una sola transazione, mantenendo coerenti vincoli e changelog.

    Verifica finale. Dopo ogni release, controlla che l'app consumi davvero le versioni pubblicate: rimuovi gli override e lancia flutter pub get in apps/mobile partendo da un checkout pulito del tag.

    // tool/check_publishable.dart
    // Fallisce se un package pubblico dichiara dipendenze path: o git:.
    import 'dart:io';
    import 'package:yaml/yaml.dart';
    
    void main() {
      final failures = <String>[];
    
      for (final entity in Directory('packages').listSync()) {
        if (entity is! Directory) continue;
        final file = File('${entity.path}/pubspec.yaml');
        if (!file.existsSync()) continue;
    
        final yaml = loadYaml(file.readAsStringSync()) as YamlMap;
        if (yaml['publish_to'] == 'none') continue;
    
        final name = yaml['name'] as String;
        for (final key in const ['dependencies', 'dev_dependencies']) {
          final deps = yaml[key];
          if (deps is! YamlMap) continue;
          deps.forEach((dep, spec) {
            if (spec is YamlMap && (spec.containsKey('path') || spec.containsKey('git'))) {
              failures.add('$name -> $dep usa una dipendenza non pubblicabile ($key)');
            }
          });
        }
      }
    
      if (failures.isNotEmpty) {
        stderr.writeln('Package non pubblicabili:\n - ${failures.join('\n - ')}');
        exit(1);
      }
      stdout.writeln('Tutti i package pubblici sono pubblicabili.');
    }

    Risultato atteso

    `dart run tool/check_publishable.dart` termina con exit code 0 su un monorepo sano e blocca la pipeline non appena qualcuno reintroduce una dipendenza `path:` in un package destinato a pub.dev.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!