Far funzionare una action: workflow command e debug
Una action non ha un canale privilegiato verso GitHub. Parla con il runner in due modi soltanto: scrivendo righe speciali sullo standard output, che il runner intercetta mentre scorre il log, e scrivendo dentro gli environment file, file temporanei il cui percorso arriva alla action tramite variabili come GITHUB_OUTPUT, GITHUB_ENV, GITHUB_PATH e GITHUB_STEP_SUMMARY. L’insieme di questi due meccanismi si chiama workflow command, e funziona allo stesso modo per le action JavaScript, per quelle Docker e per le composite: cambia solo come le emetti, con un echo in shell oppure con una funzione del pacchetto @actions/core in JavaScript.
La forma su stdout è ::comando parametro=valore::messaggio, per esempio ::error file=app.js,line=12,title=Parse::Config non valida. I nomi dei comandi e dei parametri non sono case sensitive. Vale la pena ricordare che tutto ciò che stampi finisce sotto gli occhi del parser del runner: se la tua action rilancia l’output di un tool esterno e quel tool stampa una riga che comincia con due volte due punti, il runner la eseguirà come comando. Per questo esiste ::stop-commands::TOKEN, che sospende l’interpretazione fino a quando non stampi di nuovo ::TOKEN::. La documentazione è esplicita: il token deve essere generato in modo casuale e diverso a ogni run, altrimenti chi controlla l’output può indovinarlo, chiuderlo e riattivare l’iniezione di comandi.
I comandi che userai davvero
Per restituire un valore usa il file di output: echo "name=value" >> "$GITHUB_OUTPUT", equivalente a core.setOutput() nel toolkit. Se il valore è multilinea non puoi usare la forma con l’uguale: serve la sintassi con delimitatore, cioè una riga NOME<<EOF, poi il contenuto, poi una riga con solo EOF. Il delimitatore deve essere scelto in modo che non compaia da solo su una riga del valore.
Per nascondere un segreto c’è ::add-mask::valore, cioè core.setSecret(). Registra il valore prima di stamparlo o di usarlo in altri workflow command, altrimenti maschera solo le occorrenze successive. Attenzione a una conseguenza poco intuitiva e molto testata: una volta mascherato, quel valore è trattato come segreto sul runner e non puoi più impostarlo come output. Se un token compare vuoto o come asterischi nel job a valle, quasi sempre è perché qualcuno lo ha mascherato a monte.
Per leggibilità hai ::group::Titolo e ::endgroup:: (core.startGroup() / core.endGroup()), che collassano un blocco di log. Per le annotazioni hai notice, warning, error e debug: le prime tre compaiono nel riepilogo del run e, se passi file, line e endLine, si ancorano al codice; debug viene stampato solo se è attiva la variabile ACTIONS_STEP_DEBUG. Infine GITHUB_STEP_SUMMARY accetta Markdown e produce il riquadro in cima alla pagina del run: con >> accodi, con > sovrascrivi, e il buffer è isolato per singolo step, quindi due step non si sovrascrivono a vicenda.
- name: Build
id: build
shell: bash
run: |
echo "::add-mask::$RAW_TOKEN"
echo "::group::Compilazione"
make build
echo "::endgroup::"
echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
echo "### Build completata" >> "$GITHUB_STEP_SUMMARY"
Successo e fallimento: gli exit code
Il runner giudica uno step solo dal suo exit code. Zero significa successo e il job prosegue; qualunque valore diverso da zero marca lo step come fallito, interrompe le operazioni concorrenti e salta gli step successivi. Non esiste un workflow command che “fallisce” il job: stampare ::error:: produce un’annotazione rossa ma, da solo, non cambia l’esito. È la confusione più frequente su questo argomento, e ricorre spesso nelle domande d’esame.
In una action JavaScript la via corretta è core.setFailed(error.message) dentro un blocco try/catch: scrive il messaggio come errore e imposta il codice di uscita fallimentare in un colpo solo. In una action Docker o in uno step run di una composite fai exit 1 esplicito. Ricorda anche che in Bash, senza set -e, l’exit code di uno script multilinea è quello dell’ultimo comando: un comando intermedio che fallisce passa inosservato. Se invece vuoi che un fallimento non blocchi il job, la scelta è dichiararlo nel workflow con continue-on-error, non nascondere il codice di errore dentro la action.
Quando l’output non arriva al job chiamante
La catena da verificare è sempre la stessa, dal basso verso l’alto. Primo: lo step che scrive deve avere un id, altrimenti non è referenziabile. Secondo, e solo per le composite: in action.yml la sezione outputs deve contenere una chiave value che punta all’output dello step interno; senza quel mapping esplicito il valore resta confinato dentro la action. Le action Docker e JavaScript, al contrario, dichiarano l’output con la sola description. Terzo: per passarlo a un altro job serve la sezione outputs del job che produce, più needs sul job che consuma, che poi legge tramite il contesto needs.job1.outputs.nome. Saltare uno di questi tre anelli restituisce una stringa vuota, non un errore: il workflow prosegue e fallisce più avanti, in un punto che sembra scollegato.
Quando la catena sembra corretta ma il valore non c’è, scendi nei log. Per capire perché un job è stato saltato, scarica l’archivio dei log dal menu della run e apri il file system.txt della cartella del job: mostra la condizione if originale (Evaluating), la stessa espressione con i valori sostituiti a runtime (Expanded) e il Result. È il modo più rapido per vedere che una condizione confrontava una stringa vuota. Questo tracciamento vale però solo per le condizioni a livello di job: per gli step serve il debug logging. Su runner self-hosted, infine, la cartella _diag dell’installazione contiene i log del runner e un file Worker_ per ogni job processato, utile quando il problema è l’ambiente e non la action.