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 :

bash
# 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-db

Le mode strict et ce qu’il laisse passer

Commencez chaque script Bash par :

bash
set -Eeuo pipefail
OptionEffet
-eQuitte lorsqu’une commande échoue.
-EFait hériter le trap ERR aux fonctions et aux sous-shells.
-uTraite les variables non définies comme des erreurs.
-o pipefailUn 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’un while ou d’un until
  • 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 :

bash
# 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 :

text
2026-10-18T22:55:46Z [ERROR] command failed (exit 1) at u.sh:5: false

INT 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

Deux flux. Une commande en échec déclenche le trap ERR, qui journalise une ligne ERROR avec le fichier et la ligne, puis le trap EXIT exécute le nettoyage et la tâche échoue avec le code de sortie 1. Un SIGTERM déclenche le trap TERM avec le code de sortie 143, puis le nettoyage. Une note indique qu’aucun trap ne s’exécute après un SIGKILL ou la perte d’un exécuteur, il faut donc garder les ressources jetables.

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 :

bash
_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_exit lit d’abord $?, ce qui lui permet de conserver le code qui a causé la sortie.
  • trap - EXIT empêche le nettoyage de s’exécuter deux fois.
  • cleanup_run journalise 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 :

bash
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_trap

Deux 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 :

bash
    # 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 :

bash
[[ "$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 :

bash
_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 :

bash
  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 :

bash
@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

À 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.