Hai un servizio che funziona, un modello con una versione precisa e un modo di metterli in produzione. Resta la domanda che chiude il modulo, e che nessuno strumento risolve al posto tuo: il rilascio deve partire da solo a ogni modifica, o lo lancia una persona? La risposta di moda è «automatizza tutto».
La risposta onesta è che dipende da quanto costa un errore, da quante persone rilasciano e da quanto spesso. In questa lezione separi due cose che spesso viaggiano insieme, la verifica automatica e il rilascio automatico, riproduci in piccolo un errore classico degli script di rilascio, scrivi uno script che si ferma, verifica e torna indietro, e ti fai un criterio per decidere fin dove spingere la pipeline nel tuo progetto.
Tutti i listati di shell di questa lezione sono stati eseguiti due volte con Bash 5.3.9, in una cartella temporanea fuori dal repository, con la stessa uscita (cambia solo il nome del file di log creato da mktemp).
I due file di GitHub Actions sono stati controllati nella sintassi YAML e confrontati con la documentazione, ma non li ha eseguiti GitHub: sono schemi da adattare. Il comando curl verso un servizio vero non è stato eseguito, e lo dichiaro dove compare.
I termini, in un paragrafo
Una pipeline è una sequenza di passi automatici (prova, costruisci, pubblica) collegati in catena, come una catena di montaggio in cui ogni stazione riceve il lavoro della precedente. La CI (continuous integration, integrazione continua) è la parte che a ogni modifica esegue test e controlli: è il custode all'ingresso del ramo principale.
La CD ha due significati che conviene non confondere: nel continuous delivery il codice è sempre pronto da rilasciare ma la decisione di rilasciare resta di una persona, nel continuous deployment ogni modifica che supera i controlli va in produzione senza intervento umano.
Quest'ultimo è ciò che si chiama anche «push-to-deploy». Il codice d'uscita (exit code) è il numero con cui ogni programma dice al sistema come è andata: zero è «bene», qualsiasi altro valore è «problema». La pipe (|) collega l'uscita di un comando all'ingresso del successivo, e set -e è l'impostazione di Bash che interrompe lo script al primo comando fallito, come un interruttore automatico.
Un rilascio fantasma è un deploy che sembra riuscito, senza errori e con il servizio che risponde, ma non ha pubblicato il codice nuovo: il nome è mio, il fenomeno è quello che riprodurrai fra poco.
L'analogia: il pilota automatico e il pilota
Il pilota automatico di un aereo è uno strumento straordinario in crociera: costante, instancabile, preciso. Ma nessuno gli affida decollo e atterraggio senza un pilota che guarda, perché sono i momenti in cui un errore costa tutto e le condizioni cambiano. Il rilascio è il decollo del tuo software.
Un'automazione che funziona nel 99% dei casi è eccellente finché non incontra il caso che nessuno aveva previsto, e allora esegue con la stessa precisione la cosa sbagliata, a velocità macchina. Questo non è un argomento contro l'automazione: è un argomento per sapere che cosa sostituisce l'occhio umano quando lo togli.
Che cosa fa bene la CI, senza ironia
Prima di discutere i limiti, riconosci i meriti. Una pipeline fa bene tre cose che una persona fa male:
- Ripete in modo identico: stessa sequenza, stessi comandi, stesso ambiente, ogni volta. Una persona si distrae e salta un passo il venerdì sera.
- Non dimentica i controlli: i test girano sempre, anche quando «è solo una piccola modifica».
- Scala con le persone: con venti sviluppatori che rilasciano più volte al giorno il rilascio manuale diventa il collo di bottiglia e la fonte di incidenti.
Le prime due voci valgono per chiunque, anche per chi lavora da solo, e sono quasi gratuite da ottenere: sono il motivo per cui la CI è il primo gradino sensato per quasi ogni progetto.
La terza dipende dal contesto: il beneficio cresce con il numero di persone e con la frequenza dei rilasci, e con una persona sola e rilasci rari quasi non si presenta. L'ordine delle voci non conta, ma la loro natura sì: due sono proprietà della macchina, una è una conseguenza dell'organizzazione.
Questo è un esempio minimo di sola CI con GitHub Actions: a ogni push e a ogni pull request installa l'ambiente esattamente dal lockfile (lezione 05) ed esegue i test veloci (lezioni 06 e 07). Va in .github/workflows/ci.yml. Lo schema segue la guida di UV per GitHub Actions, ed è uno schema da adattare: l'ho controllato come YAML (due righe di verifica con PyYAML) ma non l'ha eseguito GitHub.
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- run: uv sync --locked
- run: uv run pytest -q -m "not slow"
La riga on: [push, pull_request] dice quando partire. runs-on: ubuntu-latest chiede una macchina virtuale pulita, e «pulita» è il punto: è il test della cucina pulita della lezione 01 ripetuto a ogni modifica.
uv sync --locked installa esattamente il lockfile e fallisce se questo non è aggiornato rispetto a pyproject.toml, quindi un lockfile dimenticato rompe la CI invece di passare in silenzio. -m "not slow" esclude i test marcati come lenti (il marcatore della lezione 06), che puoi spostare in un job separato o notturno.
Nota le due righe uses: ho copiato il formato della guida di UV, che fissa ogni azione a un hash di commit con la versione in commento (actions/checkout alla v7.0.1, astral-sh/setup-uv alla v9.0.0, versioni consultate il 2026-11-13).
Non è pignoleria: l'elenco dei tag del repository astral-sh/setup-uv non ha una etichetta v9 che segua gli aggiornamenti, ma solo v9.0.0, e un esempio vecchio con @v5 punterebbe a una versione superata. Un riferimento a un hash non cambia sotto i tuoi piedi, lo stesso principio del digest dell'articolo su registry e tag.
Il risultato è un segno verde o rosso accanto a ogni commit. È valore puro e a basso rischio: se qualcosa si rompe hai un test fallito in più, non un servizio in meno. Con i rami protetti della lezione 03 puoi anche impedire l'unione se il segno è rosso. Per una pipeline completa, con costruzione dell'immagine e pubblicazione, c'è la serie su pipeline ML con CI/CD, GitHub Actions e Docker; qui serve il criterio per decidere fin dove spingerla.
Un errore classico: il build che fallisce e il rilascio che parte lo stesso
La parte più rischiosa dell'automazione non è la pipeline: è lo script che la pipeline, o tu, lanci alla fine. Un'idea innocente è mostrare solo le ultime righe del log di un build lungo, collegando il build a tail con una pipe. Il problema nasce da una regola del linguaggio: nel manuale di Bash, sezione Pipelines, il codice d'uscita di una pipe è quello dell'ultimo comando, a meno che sia attiva l'opzione pipefail.
Quel comando è tail, che riesce sempre. Il manuale dice anche, nella descrizione di set -e, che la shell non esce se a fallire è un comando di una pipeline che non sia l'ultimo. Il codice d'uscita del build si perde, e lo script prosegue con il passo successivo.
Riprodurlo in piccolo
Per vederlo serve un build finto che fallisce. Il file stampa un messaggio normale, un errore sullo standard error ed esce con codice 1, come farebbe un compilatore che non trova un modulo:
#!/usr/bin/env bash
echo "compilo..."
echo "errore: modulo mancante" >&2
exit 1
Ora la versione sbagliata dello script di rilascio, con set -e convinto di essere al sicuro:
#!/usr/bin/env bash
set -e
./build.sh 2>&1 | tail -25
echo "copio il dist in produzione (quello VECCHIO)"
echo "riavvio il servizio"
L'uscita è la prova del reato: compaiono sia compilo... sia errore: modulo mancante, e subito dopo copio il dist in produzione (quello VECCHIO), riavvio il servizio e exit=0. L'errore è lì, stampato in chiaro, e lo script è andato avanti lo stesso. È esattamente il rilascio fantasma: il processo termina con successo, il servizio risponde, e in produzione c'è la versione di prima, riavviata.
Per vedere i codici di ogni anello di una pipe esiste l'array PIPESTATUS di Bash: false | true; echo "${PIPESTATUS[@]}" stampa 1 0. Il primo comando ha fallito, il secondo no, e il codice complessivo è lo zero del secondo.
Tre correzioni, e una sorpresa
La correzione minima è set -o pipefail, che rende il codice della pipe quello dell'ultimo comando fallito. Si scrive in cima allo script, insieme a -e:
#!/usr/bin/env bash
set -eo pipefail
./build.sh 2>&1 | tail -25
echo "NON DOVREBBE ARRIVARCI"
Provato, stampa l'errore ed esce con exit=1 senza raggiungere l'ultima riga. È una riga sola, e fa tornare vero ciò che set -e prometteva. La seconda correzione è non mettere in pipe il comando che conta: scrivere il log su un file e leggere l'esito a parte, in modo da avere sia il codice sia il testo a disposizione. Tentativo naturale:
#!/usr/bin/env bash
set -e
./build.sh > build.log 2>&1
RC=$?
[ "$RC" -ne 0 ] && { tail -30 build.log; exit 1; }
echo "copio il dist"
Qui sta la sorpresa, trovata provando con set -e attivo: lo script esce con exit=1, e fin qui bene, ma non stampa il log. L'uscita è vuota. Succede perché set -e interrompe lo script nel momento stesso in cui ./build.sh fallisce, prima di arrivare a RC=$? e al tail.
L'effetto di sicurezza c'è, la diagnosi no: alle due di notte ti ritrovi con «fallito» e nessun motivo. La regola che ne ricavo riguarda il modo di verificare le correzioni: un controllo si prova nella condizione reale (qui con set -e attivo), non lo si legge soltanto, perché letto sembrava giusto.
La terza forma funziona con set -e perché il comando è dentro la condizione di un if: il manuale precisa che la shell non esce se a fallire è una parte del test di un if, e che il codice d'uscita invertito da ! è anch'esso esentato. Così il fallimento diventa un ramo del programma invece di un'uscita improvvisa:
#!/usr/bin/env bash
set -euo pipefail
LOG=$(mktemp)
if ! ./build.sh > "$LOG" 2>&1; then
echo "BUILD FALLITO, ultime righe del log:" >&2
tail -30 "$LOG" >&2
exit 1
fi
echo "copio il dist"
Provato, stampa BUILD FALLITO, ultime righe del log: seguito dal log, ed esce con exit=1, senza copiare nulla: ferma e spiega. Le opzioni aggiunte rispetto alla correzione minima sono -u, che tratta una variabile mai definita come un errore (un refuso nel nome di una variabile non diventa una stringa vuota silenziosa), e pipefail come rete di sicurezza per le pipe che aggiungerai in seguito.
Resta un limite da conoscere: set -e ha molte eccezioni elencate nel manuale (liste con && e ||, cicli, funzioni chiamate in un contesto che lo ignora), quindi non va considerato una garanzia totale ma una prima rete.
La seconda difesa: verificare un fatto che cambia
Ogni correzione sopra protegge da un errore che hai già immaginato. La difesa contro quelli che non hai immaginato è un'altra: dopo il rilascio, verificare un fatto misurabile che cambia con la modifica (la versione servita, l'hash del commit, la presenza di una stringa nuova in una pagina), non soltanto che il servizio risponda.
«Risponde 200» è vero sia con il codice nuovo sia con quello vecchio: non distingue. Per un servizio ML il fatto naturale è la versione del modello esposta dal controllo di salute, come nel servizio con il middleware dell'articolo precedente, il cui /health restituisce model_version.
Uno script di rilascio che si ferma, verifica e torna indietro
Si può migliorare molto un rilascio manuale senza una pipeline: basta che i passi stiano in uno script scritto con attenzione, che una persona lancia. Lo script che segue raccoglie le difese viste: ferma il rilascio se il build fallisce, pubblica e riavvia, verifica che sia servita la versione attesa, e torna alla precedente se la verifica fallisce.
Per poterlo eseguire senza un server, i comandi veri (build, pubblicazione, lettura della versione) arrivano da variabili d'ambiente, e li sostituisco con comandi finti. Le prime righe preparano le variabili:
#!/usr/bin/env bash
set -euo pipefail
NUOVO="${1:?uso: deploy.sh <versione-attesa>}"
BUILD_CMD="${BUILD_CMD:-./build.sh}"
DEPLOY_CMD="${DEPLOY_CMD:?serve il comando che pubblica e riavvia}"
HEALTH_CMD="${HEALTH_CMD:?serve il comando che stampa la versione servita}"
TENTATIVI="${TENTATIVI:-5}"
ATTESA="${ATTESA:-1}"
PRECEDENTE="$($HEALTH_CMD 2>/dev/null || echo sconosciuta)"
LOG="$(mktemp)"
set -euo pipefail raccoglie le tre impostazioni di sicurezza viste sopra. NUOVO="${1:?...}" pretende l'argomento: senza la versione attesa lo script si rifiuta di partire e stampa il messaggio d'uso (è il significato di :?).
Le variabili scritte come ${NOME:-valore} hanno un valore di riserva che l'esterno può sostituire, mentre quelle scritte con :? non hanno riserva e fanno fermare lo script se mancano: DEPLOY_CMD e HEALTH_CMD sono obbligatorie di proposito, perché un valore di riserva come true farebbe passare una verifica che non verifica niente.
Prima di toccare qualunque cosa lo script chiede al servizio quale versione sta servendo e la conserva in PRECEDENTE: servirà per il rollback, e se il servizio è spento la risposta diventa sconosciuta. Un dettaglio che morde: le variabili di comando sono espanse senza virgolette perché contengono comando e argomenti, quindi non possono contenere una pipe né percorsi con spazi.
Provato con HEALTH_CMD="echo 3 | cat" il risultato è la stringa 3 | cat, non 3. Per un controllo che richiede una pipe, come quello sul /health, il comando va messo in un file a parte (lo mostro più sotto).
echo "[1/4] build (log in $LOG)"
if ! $BUILD_CMD > "$LOG" 2>&1; then
echo "BUILD FALLITO: nulla è stato rilasciato. Ultime righe:" >&2
tail -5 "$LOG" >&2
exit 1
fi
echo "[2/4] pubblicazione e riavvio (era in servizio: $PRECEDENTE)"
$DEPLOY_CMD "$NUOVO"
Il build scrive su un file di log, senza pipe, dentro un if !, la forma che ferma e spiega. Se fallisce, lo script esce con codice 1 stampando le ultime righe e prima di pubblicare o riavviare qualsiasi cosa: il rilascio fantasma di prima qui non può avvenire.
Solo dopo un build riuscito si chiama DEPLOY_CMD con la versione da rilasciare, il comando che nel mondo reale copia i file e riavvia il servizio. Se quel comando fallisce, set -e ferma lo script con il suo codice, senza rollback: in quel punto lo stato del servizio è sconosciuto e un passo automatico non può indovinarlo, quindi è giusto che si fermi e lo dica a una persona.
echo "[3/4] verifica (attesa versione $NUOVO)"
for i in $(seq "$TENTATIVI"); do
SERVITA="$($HEALTH_CMD 2>/dev/null || true)"
if [ "$SERVITA" = "$NUOVO" ]; then
echo "[4/4] OK: servita la versione $SERVITA"
exit 0
fi
echo " tentativo $i/$TENTATIVI: servita '$SERVITA'"
sleep "$ATTESA"
done
Qui sta il cuore, la verifica di un fatto che cambia. Fino a TENTATIVI volte, a ATTESA secondi l'una dall'altra, lo script chiede la versione servita e la confronta con quella attesa. Se coincide: successo, uscita 0.
Il ciclo di tentativi esiste perché un servizio con un modello pesante non è pronto subito dopo il riavvio: l'articolo precedente ha mostrato un controllo di salute che distingue «vivo» da «pronto», e con un modello grande il tempo di attesa si allunga di molto, quindi i cinque secondi di riserva sono un valore didattico da adattare alla tua misura di avvio.
if [ "$PRECEDENTE" = "sconosciuta" ]; then
echo "VERIFICA FALLITA e nessuna versione precedente nota: rollback impossibile" >&2
exit 3
fi
echo "VERIFICA FALLITA: rollback a $PRECEDENTE" >&2
$DEPLOY_CMD "$PRECEDENTE"
exit 2
Se dopo tutti i tentativi la versione non è quella attesa, lo script torna alla precedente. Con una versione precedente sconosciuta il rollback è impossibile e lo dice con un codice diverso. Notare i tre codici d'uscita: 1 significa «il build è fallito, nulla è stato rilasciato», 2 «rilasciato ma non verificato, ripristinato» e 3 «non verificato e senza versione a cui tornare».
Chi chiama lo script, una persona o una pipeline, può distinguere i tre casi e reagire diversamente. Un limite onesto: dopo il rollback lo script non verifica di nuovo la versione servita, e dovrebbe.
Le quattro prove usano un file di stato che fa da «servizio» finto. Il comando di lettura stampa il contenuto del file, un comando attiva scrive la versione richiesta, e uno attiva_guasto la ignora e lascia sempre la versione 2, per simulare un riavvio che non porta il codice nuovo. Il driver è questo:
#!/usr/bin/env bash
export HEALTH_CMD=./health.sh TENTATIVI=3 ATTESA=0
echo "--- 1. tutto bene"
printf 1 > stato
BUILD_CMD=./build_ok.sh DEPLOY_CMD=./attiva.sh ./deploy.sh 2; echo "exit=$? stato=$(cat stato)"
echo "--- 2. build fallito"
printf 2 > stato
BUILD_CMD=./build.sh DEPLOY_CMD=./attiva.sh ./deploy.sh 3; echo "exit=$? stato=$(cat stato)"
echo "--- 3. il riavvio non porta la versione nuova"
printf 2 > stato
BUILD_CMD=./build_ok.sh DEPLOY_CMD=./attiva_guasto.sh ./deploy.sh 3; echo "exit=$? stato=$(cat stato)"
echo "--- 4. servizio spento prima del rilascio"
rm -f stato
BUILD_CMD=./build_ok.sh DEPLOY_CMD=./attiva_guasto.sh ./deploy.sh 3; echo "exit=$? stato=$(cat stato)"
Ho eseguito il driver due volte con la stessa uscita, a parte il nome del file di log (la riga «log in ...» è abbreviata qui sotto). Il risultato, scenario per scenario:
| Scenario | Che cosa stampa lo script | Uscita | Stato finale |
|---|---|---|---|
| 1. Tutto bene (in servizio la 1, rilascio della 2) | [4/4] OK: servita la versione 2 | 0 | 2 |
| 2. Build fallito | BUILD FALLITO: nulla è stato rilasciato, poi compilo... ed errore: modulo mancante | 1 | 2 (invariato) |
| 3. Il riavvio non porta la 3 | tre righe servita '2', poi VERIFICA FALLITA: rollback a 2 | 2 | 2 |
| 4. Servizio spento prima del rilascio | era in servizio: sconosciuta, tre tentativi, rollback impossibile | 3 | 2 |
Il confronto fra il secondo e il terzo scenario è istruttivo: in entrambi il servizio resta alla versione 2, ma nel secondo il build ha impedito qualsiasi cambiamento, nel terzo il rilascio è avvenuto e solo la verifica ha scoperto che non aveva effetto.
Senza la verifica il terzo scenario sarebbe terminato con zero. Lo script non è pronto da copiare in produzione, perché non conosce il tuo servizio, i tuoi percorsi e il tuo comando di build: è un modello in cui ogni riga corrisponde a un errore che puoi commettere davvero.
In produzione il comando di lettura della versione sarebbe un file a parte, per il motivo della pipe visto sopra. Questo è lo schema, non eseguito perché richiede un servizio in ascolto; la riga con python3 invece è stata provata su una risposta di esempio e stampa 3:
#!/usr/bin/env bash
set -euo pipefail
curl -fsS http://127.0.0.1:8000/health \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["model_version"])'
Si usa passando HEALTH_CMD=./versione_servita.sh. Presuppone che il servizio esponga model_version nel controllo di salute, come l'applicazione dell'articolo precedente; -f di curl fa fallire il comando se la risposta è un errore HTTP, -s toglie la barra di avanzamento e -S riporta comunque gli errori. Con pipefail attivo, se curl non riesce a connettersi il comando fallisce invece di passare una risposta vuota a Python.
Manuale o automatico: come si decide
Una persona che rilascia un servizio su una macchina fa di fatto un'altra scelta rispetto a un reparto con venti sviluppatori. Il ragionamento che segue è un ragionamento sui compromessi, non una regola: i cinque fattori della tabella dicono quando la pipeline di rilascio ripaga e quando no.
| Fattore | Con una persona che rilascia, su una macchina | Con un team che rilascia ogni giorno |
|---|---|---|
| Chi rilascia | Una persona sola: nessuno si pesta i piedi, e il beneficio principale della pipeline, scalare con il team, non si presenta. | Il rilascio manuale diventa un collo di bottiglia e una fonte di incidenti. |
| Quanto costa un errore | Un modello sbagliato in produzione ha conseguenze reali e immediate: il passaggio umano è un controllo in più, e costa quasi niente. | Un errore costa uguale, ma la frequenza dei rilasci rende il controllo manuale non sostenibile senza automazione. |
| Rilasci per cause esterne | Un build lungo o fragile, che dipende da servizi esterni, moltiplica i punti di guasto se lo automatizzi tutto. | La pipeline isola il build in un ambiente pulito, e ripete lo stesso passo identico. |
| Manutenzione | Una pipeline è software: ha segreti, versioni, guasti propri. Ogni ora spesa a mantenerla è sottratta al prodotto. | Il costo si ripartisce su molte persone e su molti rilasci. |
| Superficie di attacco | Una pipeline che può scrivere sul server di produzione è un'altra porta d'ingresso: se le sue credenziali vengono rubate, lo è il server. | Lo stesso rischio, compensato da permessi stretti e da approvazioni. |
La tabella non dice che il manuale sia «meglio»: dice che per una persona e rilasci rari il costo totale è più basso, e che cambia quando cambiano i numeri. Se entrano altre quattro persone che rilasciano ogni giorno, il calcolo si rovescia e la scelta giusta diventa una pipeline, con le difese viste sopra.
Un deploy manuale ben fatto non è improvvisato: i comandi esatti, l'ordine, la verifica e il rollback sono scritti in un documento, il runbook, che è automazione per le persone invece che per le macchine.
Ha sempre le stesse parti: quando si usa, le precondizioni (test verdi, versione scelta, accesso al server), i comandi nell'ordine in cui vanno eseguiti, la verifica con il risultato atteso, il rollback con i comandi esatti, le trappole già incontrate e una cronologia degli aggiornamenti.
L'ordine qui conta, perché le precondizioni vengono prima dei comandi e il rollback dopo la verifica, per questo lo scrivo come un percorso e non come un elenco. La regola più utile da portarsi a casa è che ogni errore risolto una volta diventa una riga nel runbook, così non si ripete: costa quindici minuti a incidente e ne risparmia ore a chi viene dopo, te compreso.
Tre domande prima di automatizzare qualsiasi cosa
«Automatizzare» ha una buona reputazione, e in generale la merita. Ma ogni automazione è software, e il software ha un costo di scrittura, uno di manutenzione e uno di guasto. Prima di scrivere una pipeline di rilascio conviene rispondere a tre domande:
- Lo faccio abbastanza spesso? Un'operazione che si ripete dieci volte al giorno ripaga l'automazione in una settimana; una che si ripete una volta al mese forse non la ripaga mai, perché ogni volta che la esegui di nuovo la pipeline è cambiata sotto di te e va sistemata.
- La capisco abbastanza da descriverla senza ambiguità? Automatizzare un processo che non si capisce congela i suoi difetti. Se ogni volta che lo fai a mano compare una sorpresa, un passo in più o un'eccezione, non è ancora pronto.
- Posso rilevare un fallimento, anche inatteso? L'automazione toglie l'occhio umano dal processo. Se non lo hai sostituito con controlli (test, verifica del fatto che cambia, avvisi), hai solo tolto la sicurezza.
Tre «sì» dicono di automatizzare senza esitare. Un «no» alla seconda o alla terza è il caso più importante: significa che il costo reale di automatizzare male è una pubblicazione sbagliata che nessuno ha deciso, mentre il costo reale di non automatizzare è una persona che esegue una riga di comando.
Le tre domande non hanno un ordine, ma la terza è quella che di solito si scopre troppo tardi, perché un guasto atteso si pensa in anticipo e uno inatteso no, e il rilascio fantasma della sezione precedente era proprio un guasto di quel tipo.
Una scala in tre gradini, e che cosa cambia per un modello
La scelta non è binaria, e conviene vederla come una scala dove ogni gradino presuppone il precedente. Il primo è il manuale documentato: un runbook con comandi esatti e verifica dopo il rilascio, che basta per un servizio, una persona e rilasci rari.
Il secondo è la CI automatica con rilascio manuale: i test girano da soli a ogni modifica, mentre la decisione e il comando di rilascio restano umani, ed è il miglior rapporto fra costo e beneficio per la maggior parte dei progetti.
Il terzo è la CD completa: ogni modifica verificata viene rilasciata, con rollback automatico, rilascio graduale e un monitoraggio che lo ferma se peggiorano gli indicatori. Si giustifica quando la frequenza e il numero di persone lo richiedono, e quando la maturità di test e monitoraggio la rende sicura. Saltare un gradino è la ricetta dei rilasci fantasma: una CD senza i test del secondo gradino rilascia più in fretta ciò che non sa verificare.
Rispetto al software ordinario, un rilascio di un modello ha un rischio in più: il modello nuovo può peggiorare senza dare errori. È la degradazione silenziosa vista nella lezione 01 e nella serie sul drift: nessuna eccezione, nessun 500, solo previsioni peggiori.
Una pipeline di CD per un modello ha quindi bisogno di un passaggio che il software normale non ha, una porta di qualità: la regola di promozione con margine della lezione sul model registry, valutata su un insieme fisso, tra «addestrato» e «in servizio».
Se non sai ancora scrivere quella porta, non automatizzare il passo dopo. Sul confronto fra versioni in produzione, con campioni di utenti, c'è anche l'articolo su A/B testing dei modelli ML, e sui livelli di maturità dell'insieme la serie MLOps: da esperimento a produzione.
Una via di mezzo: automazione con un cancello umano
Fra «tutto a mano» e «tutto automatico» esiste il continuous delivery con approvazione: la pipeline fa tutto ciò che è sicuro fare da soli (test, costruzione dell'immagine, pubblicazione nel registry) e si ferma davanti al rilascio, che parte soltanto quando una persona lo approva. Con GitHub Actions si esprime con un environment con revisori obbligatori. Il file qui sotto estende la CI di prima con un secondo job. È uno schema controllato come YAML, non eseguito da GitHub:
name: rilascio
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- run: uv sync --locked
- run: uv run pytest -q -m "not slow"
rilascia:
needs: test
runs-on: ubuntu-latest
environment: production
steps:
- run: echo "qui il rilascio vero, con verifica e rollback"
La parola chiave needs: test impone l'ordine: il job rilascia non parte se i test falliscono (senza needs i job di un workflow girano in parallelo, come dice la documentazione della sintassi dei workflow).
environment: production collega il job a un ambiente di nome production, che configuri nelle impostazioni del repository; la guida alla gestione degli ambienti spiega che si possono indicare fino a sei persone o team come revisori obbligatori, che basta l'approvazione di uno solo perché il job prosegua, e che si può impedire l'auto-approvazione. Due precisazioni che cambiano la scelta.
Primo: la stessa pagina (consultata il 2026-11-13) dice che far girare un workflow che cita un ambiente inesistente ne crea uno con quel nome, e un ambiente appena creato non ha revisori, quindi finché non li configuri tu il job parte senza alcuna pausa.
Secondo: le regole di protezione come i revisori obbligatori sono disponibili per i repository pubblici su tutti i piani, mentre per quelli privati o interni, con i piani Free, Pro e Team, non lo sono. Verifica il tuo piano prima di contare su quel cancello. Il riferimento al comando che il job esegue, qui un semplice echo, è il posto in cui andrebbe lo script con verifica e rollback della sezione precedente.
Che cosa fare ora, con un tuo progetto
Il corso finisce qui, ma il lavoro vero comincia con un tuo progetto. L'ordine conta, perché ogni pratica poggia sulle precedenti e costa più della precedente: parti da Git e da un .gitignore corretto (mezz'ora, beneficio immediato); poi UV con pyproject.toml e uv.lock, da cui il progetto si ricostruisce con un comando (un'ora); poi cinque test sulle cose che temi di rompere, cioè i dati in ingresso, due invarianti e una soglia con margine (mezza giornata); poi la configurazione esterna con validazione all'avvio; poi MLflow con SQLite per tracciare gli esperimenti, e il registry quando hai due modelli da confrontare; il Dockerfile solo quando devi spostare il progetto su un'altra macchina o servirlo a qualcuno; un runbook di rilascio scritto la prima volta che rilasci; e la CI quando più di una persona tocca il codice o ti accorgi di dimenticare di lanciare i test.
Le stime di tempo sono mie, a titolo orientativo, e non misurate. Non è un obbligo di eseguire tutto: il criterio dal primo all'ultimo punto è aggiungere la pratica quando il dolore che risolve è reale, e annotare perché l'hai aggiunta.
| Problema implicito del notebook | Che cosa lo chiude | Lezione |
|---|---|---|
| Il codice «nella testa» | Git e GitHub, test | 02, 03, 06, 07 |
| Librerie a caso | UV e lockfile | 05 |
| Configurazione e segreti | Variabili d'ambiente | 08 |
| Sistema operativo | Container, Dockerfile, registry | 09, 10, 11 |
| Memoria dei risultati | MLflow: tracking e registry | 12, 13 |
| Chi lo fa girare, e come | Deploy, verifica, rilascio | 14, 15 |
Che cosa sai fare adesso
Sai distinguere la CI, che controlla a ogni modifica, dal rilascio automatico, che pubblica senza una decisione, e sai che sono scelte diverse con costi diversi. Sai riprodurre con poche righe il rilascio fantasma di una pipe con tail, spiegarlo con una regola del manuale di Bash e chiuderlo in tre modi (pipefail, log su file con if !, mai la pipe sul comando che conta), compresa la sorpresa di set -e che nasconde il log.
Sai scrivere uno script che ferma il rilascio se il build fallisce, verifica un fatto che cambia, torna alla versione precedente e usa codici d'uscita distinti, e sai dove resta incompleto. Sai mettere un cancello umano davanti al rilascio con un environment di GitHub Actions e quale limite di piano controllare prima.
Con la lezione precedente, deploy, dove gira il modello e come scegliere, hai le quattro forme di messa in servizio e il criterio per decidere quanto automatizzarle. Il passo successivo non è un altro strumento: è scegliere un tuo progetto e portarlo, un gradino alla volta, fuori dal notebook.
Nella stessa serie trovi anche ML-Ops: dal notebook al servizio, ML-Ops: GitHub, remoto, branch protetti e pull request e ML-Ops: UV, ambienti e dipendenze che si ripetono uguali.
Fonti
- Manuale di Bash: Pipelines - il codice d'uscita di una pipe è quello dell'ultimo comando salvo
pipefail, ePIPESTATUS; consultata il 2026-11-13. - Manuale di Bash: il builtin set -
-ee le sue eccezioni (if,!, pipeline non finale),-u,pipefail; consultata il 2026-11-13. - UV: integrazione con GitHub Actions - forma del workflow con
uv sync --lockedeuv run, azioni fissate a hash con versione in commento (checkout v7.0.1, setup-uv v9.0.0); consultata il 2026-11-13. - GitHub Actions: gestire gli ambienti di rilascio - revisori obbligatori (fino a sei, ne basta uno), divieto di auto-approvazione, limiti per piano e per repository privati; consultata il 2026-11-13.
- GitHub Actions: sintassi dei workflow -
jobs.<job_id>.needsejobs.<job_id>.environment, job in parallelo per default; consultata il 2026-11-13. - Elenco dei tag dei repository
astral-sh/setup-uveactions/checkout, ottenuto con l'API pubblica di GitHub (/repos/<repo>/git/matching-refs/tags/v) il 2026-11-13: l'ultimo tag di setup-uv èv9.0.0e non esiste un aliasv9.
Come sono stati verificati i listati: gli script di shell (build finto, le quattro varianti, PIPESTATUS, deploy.sh e il driver con quattro scenari) sono stati eseguiti due volte con Bash 5.3.9 in una cartella temporanea fuori dal repository, con uscita identica salvo il nome del file creato da mktemp. I due file YAML sono stati letti con PyYAML 6.0.3 (sintassi e relazione fra job) e non eseguiti da GitHub.
versione_servita.sh non è stato eseguito (richiede un servizio in ascolto), il solo comando python3 è stato provato su una risposta di esempio. uv sync --locked con un lockfile non aggiornato esce con codice 1 e il messaggio «The lockfile at uv.lock needs to be updated, but --locked was provided» (provato con uv in un progetto di prova). Versioni, nomi di servizio e numeri di versione sono inventati per la prova.
Commenti
Caricamento commenti...
Accedi per commentare