Une tâche de CI doit faire l’une de deux choses : réussir, ou échouer avec une raison claire. Le pire résultat est une tâche verte qui a caché un échec. Juste après vient la tâche rouge qui se contente d’afficher « exit code 1 ».
Cet article présente quatre outils : le mode strict, les traps, les codes de sortie et la journalisation. Tout le code provient de rzv-ci-plb, le guide de CI de Runzivo.
Les erreurs courantes
À ne pas faire :
# Hides every failure, including the ones you care about.
npm test || true
# Exit status of a pipeline is the last command's. grep wins, tests lose.
npm test | tee test.log | grep -c passed
# Cleanup only runs if everything above it succeeded.
docker run -d --name test-db postgres:16
npm run integration
docker rm -f test-dbLe mode strict et ce qu’il laisse passer
Commencez chaque script Bash par :
set -Eeuo pipefail| Option | Effet |
|---|---|
-e | Quitte lorsqu’une commande échoue. |
-E | Fait hériter le trap ERR aux fonctions et aux sous-shells. |
-u | Traite les variables non définies comme des erreurs. |
-o pipefail | Un pipeline échoue si l’une de ses commandes échoue, et pas seulement la dernière. |
Le mode strict a des lacunes, que le manuel de Bash énumère. -e n’arrête pas le script lorsque la commande en échec est :
- la condition d’un
if, d’unwhileou d’ununtil - une partie d’une liste
&&ou||, sauf la dernière commande - niée avec
!
Testez donc vos chemins d’échec. Ne les présumez pas.
Signaler la ligne en échec
Chaque script du guide charge un utilitaire commun et installe ses traps. Extrait de scripts/lib/03-errors.sh :
# ERR trap handler. Reports the file, line and command text. The command
# text is shown before expansion, so variable values are not printed.
_errors_on_err() {
local status=$1 line=$2 cmd=$3
local file=${BASH_SOURCE[1]:-unknown}
log_error "command failed (exit ${status}) at ${file##*/}:${line}: ${cmd}"
}
# Install ERR, INT and TERM traps. Use with `set -Eeuo pipefail`.
# INT and TERM exit with 130 and 143 so EXIT traps still run.
errors_install_traps() {
trap '_errors_on_err "$?" "$LINENO" "$BASH_COMMAND"' ERR
trap 'exit 130' INT
trap 'exit 143' TERM
}Un échec s’affiche alors ainsi :
2026-10-18T22:55:46Z [ERROR] command failed (exit 1) at u.sh:5: falseINT et TERM quittent avec les codes 130 et 143. Bash utilise 128 plus le numéro du signal pour une commande tuée par un signal, ce qui correspond donc à ce qu’attendent les appelants. Dans GitHub Actions, log_error affiche aussi une annotation ::error::.
Une réserve : le numéro de ligne peut différer d’une version de Bash à l’autre. Pour un échec à l’intérieur d’un bloc if, Bash 5.3 a indiqué la ligne 5 et Bash 3.2 (la version par défaut sur un Mac) a indiqué la ligne 6. Le texte de la commande est le même.
Nettoyer et conserver le code de sortie

Enregistrez le nettoyage au moment où vous créez une ressource. Le trap EXIT l’exécute, de la ressource la plus récente à la plus ancienne, et conserve le code de sortie d’origine. Extrait de scripts/lib/04-cleanup.sh :
_cleanup_on_exit() {
local status=$?
trap - EXIT
cleanup_run
exit "$status"
}
# Install the EXIT trap. Call once, near the top of the script.
cleanup_install_trap() {
trap _cleanup_on_exit EXIT
}_cleanup_on_exitlit d’abord$?, ce qui lui permet de conserver le code qui a causé la sortie.trap - EXITempêche le nettoyage de s’exécuter deux fois.cleanup_runjournalise un avertissement si une commande de nettoyage échoue et exécute quand même les autres.
Un script qui utilise les deux utilitaires commence comme examples/docker/ci/02-test.sh :
set -Eeuo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
APP_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
# When you copy this example, point LIB_DIR at your copy of scripts/lib.
LIB_DIR="$(cd -- "${SCRIPT_DIR}/../../../scripts/lib" && pwd -P)"
# shellcheck source=../../../scripts/lib/03-errors.sh
source "${LIB_DIR}/03-errors.sh"
# shellcheck source=../../../scripts/lib/04-cleanup.sh
source "${LIB_DIR}/04-cleanup.sh"
errors_install_traps
cleanup_install_trapDeux limites. Aucun trap ne s’exécute après un SIGKILL, ni si l’exécuteur lui-même est perdu. Gardez les ressources jetables, par exemple des répertoires temporaires et des conteneurs étiquetés, pour qu’une exécution ultérieure puisse les supprimer.
Gérer volontairement les échecs attendus
Lorsqu’un échec est normal, dites-le dans le code. Extrait du même test de fumée Docker :
# Logs are best effort: a container that failed to start may have none.
docker logs "$container_id" >"${REPORT_DIR}/docker-container.log" 2>&1 ||
log_warn "could not save container logs"Lorsqu’un échec compte, arrêtez-vous avec un message clair et un code de sortie. die journalise l’erreur et quitte, avec le code 1 sauf si vous en passez un autre :
[[ "$body" == "ok" ]] || die "unexpected /healthz body: ${body}"Une journalisation cohérente
Les journaux vont vers stderr sous forme de lignes [INFO], [WARN] ou [ERROR] avec un horodatage UTC. Dans GitHub Actions, les avertissements et les erreurs deviennent aussi des annotations. Extrait de scripts/lib/01-logging.sh :
_log_line() {
local level=$1
shift
printf '%s [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" >&2
}
log_info() {
_log_line INFO "$@"
}
log_warn() {
_log_line WARN "$@"
if in_github_actions; then
printf '::warning::%s\n' "$(_log_escape "$*")" >&2
fi
}
log_error() {
_log_line ERROR "$@"
if in_github_actions; then
printf '::error::%s\n' "$(_log_escape "$*")" >&2
fi
}Le même fichier contient log_group_start, log_group_end et summary_add. Dans Actions, ces fonctions affichent des sections repliables ::group:: et ajoutent du Markdown à $GITHUB_STEP_SUMMARY; en local, elles se rabattent sur une sortie simple. À l’usage, extrait de scripts/ci/01-validate.sh :
log_group_start "shellcheck (${#files[@]} files)"
shellcheck "${files[@]}"
log_group_end
log_group_start "shfmt"
shfmt --diff "${files[@]}"
log_group_end
summary_add "- Lint: actionlint, shellcheck and shfmt passed"Ne journalisez jamais un secret. GitHub masque les secrets enregistrés, mais son guide d’utilisation sécurisée précise que la rédaction automatique n’est pas garantie. Le gestionnaire ERR ci-dessus affiche le texte de la commande avant son expansion : un curl -H "Authorization: $TOKEN" en échec montre donc $TOKEN, et non sa valeur.
Tester les chemins d’échec
Bats exécute des scripts shell et vérifie le résultat. Ces tests construisent un petit script avec les utilitaires chargés, puis lui envoient un signal. Extrait de tests/bats/04-cleanup.bats :
@test "cleanup runs on SIGTERM and SIGINT" {
script="$(write_script term.sh "cleanup_register rm -rf -- '${WORK}'
kill -TERM \$\$
sleep 5")"
run "$script"
[ "$status" -eq 143 ]
[ ! -e "$WORK" ]
mkdir -p "$WORK"
script="$(write_script int.sh "cleanup_register rm -rf -- '${WORK}'
kill -INT \$\$
sleep 5")"
run "$script"
[ "$status" -eq 130 ]
[ ! -e "$WORK" ]
}write_script vient de tests/bats/00-test-helper.bash. Exécutez tous les tests avec bash scripts/ci/02-test.sh bats.
Exécuter sur Runzivo
Runzivo, maintenant en accès anticipé, exécute chaque tâche admissible dans une microVM neuve, détruite après la tâche. Cela supprime les restes entre les tâches. Cela ne dispense pas des traps : votre script reste responsable du nettoyage à l’intérieur de la tâche, et de tout ce qu’il crée en dehors de l’exécuteur. Modifiez runs-on: rzv-ubuntu-24.04-x64-2vcpu-8gb pour l’essayer. Consultez la documentation de Runzivo.
Dans le dépôt
scripts/lib/03-errors.sh:die,errors_install_traps,require_cmd,require_env,retryscripts/lib/04-cleanup.sh:cleanup_install_trap,cleanup_register,cleanup_temp_dirscripts/lib/01-logging.sh: niveaux de journalisation, groupes, annotations et résumé de l’étapetests/bats/03-errors.batsettests/bats/04-cleanup.batsexamples/docker/ci/02-test.sh: tout ce qui précède dans un seul script réel
À suivre : les permissions, les secrets et les demandes de tirage non fiables.
Chaque exemple de cette série est testé dans rzv-ci-plb, the Runzivo CI playbook, fixé à l’étiquette v0.1.0.