Un bloc run: commence par une seule ligne. Puis quelqu’un ajoute une reprise. Puis une vérification. Six mois plus tard, ce sont 50 lignes de Bash dans du YAML, et personne ne peut les exécuter en dehors de la CI.

Cet article montre comment déplacer cette logique dans des scripts, à partir de l’exemple Docker de rzv-ci-plb, le guide CI de Runzivo.

L’erreur courante

À ne pas faire. Un test de fumée écrit en ligne :

yaml
- name: Smoke test image
  run: |
    docker run -d --name app -p 8080:8080 "app:${{ github.sha }}"
    for attempt in 1 2 3 4 5; do
      if curl -fsS http://127.0.0.1:8080/healthz; then
        break
      fi
      sleep 1
    done
    docker rm -f app

Ça paraît correct. Pourtant, il y a quatre problèmes :

  • Il réussit quand l’application est défaillante. Si chaque curl échoue, la boucle se termine en silence et docker rm réussit ; l’étape se termine donc avec le code 0. Je l’ai exécuté avec un curl en échec pour le confirmer.
  • Le nettoyage est ignoré en cas d’échec. Si docker run échoue, l’étape s’arrête avant docker rm.
  • Vous ne pouvez pas l’exécuter en local sans le copier hors du YAML.
  • ${{ github.sha }} est collé dans le texte du script avant même le démarrage du shell. C’est sans danger pour un SHA. La même habitude appliquée au titre d’une demande de tirage (pull request) constitue une injection de script.

Un détail de plus : sans shell: bash, GitHub exécute ceci avec bash -e {0}, qui n’a pas de pipefail. Avec shell: bash, il exécute bash --noprofile --norc -eo pipefail {0}. La référence de la syntaxe des flux de travail présente les deux. Le guide définit defaults.run.shell: bash dans chaque flux de travail.

La règle des trois lignes

Ma règle : un bloc run: de trois lignes ou moins peut rester en ligne. Déplacez-le dans un script dès qu’il comporte :

  • des conditions ou des boucles
  • des reprises
  • du nettoyage
  • de la gestion d’erreurs
  • plus de trois lignes

Il s’agit d’une convention de Runzivo, pas d’une exigence de GitHub. Elle est plus stricte que le Google Shell Style Guide, qui suggère un langage structuré dès qu’un script dépasse 100 lignes ou que son flux de contrôle n’est pas simple. L’objectif est le même : garder le shell court et facile à lire.

Une étape GitHub Actions et le terminal d’une personne développeuse exécutent tous deux examples/docker/ci/02-test.sh. Le script installe des traps d’erreur et de nettoyage, enregistre le nettoyage du conteneur, relance la vérification de santé et échoue en conservant le code de sortie. Il utilise les fonctions utilitaires partagées de scripts/lib, qui ont leurs propres tests Bats.

Les étapes du flux de travail deviennent de simples appels. Les valeurs arrivent par env:. Extrait de example-docker.yml :

yaml
- name: Build image
  run: bash "${GITHUB_WORKSPACE}/examples/docker/ci/01-build.sh"

- name: Smoke test image
  env:
    REPORT_DIR: ${{ runner.temp }}/reports
  run: bash "${GITHUB_WORKSPACE}/examples/docker/ci/02-test.sh"

Le nom de l’image provient d’un bloc env: au niveau de la tâche (IMAGE) ; aucune expression ne se retrouve donc dans un script.

Le script de test de fumée contient la logique. Cet extrait provient de examples/docker/ci/02-test.sh :

bash
container_id=""
save_logs_and_remove() {
  if [[ -n "$container_id" ]]; then
    # 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"
    docker rm -f "$container_id" >/dev/null
  fi
}
cleanup_register save_logs_and_remove

log_group_start "Start container"
container_id="$(docker run --detach --publish 127.0.0.1::8080 \
  --label "ci.example=docker" --read-only --cap-drop ALL "$IMAGE")"
host_port="$(docker port "$container_id" 8080/tcp)"
host_port="${host_port##*:}"
log_info "container ${container_id:0:12} listening on 127.0.0.1:${host_port}"
log_group_end

log_group_start "Check /healthz"
body="$(retry 10 1 curl --fail --silent --show-error "http://127.0.0.1:${host_port}/healthz")"
[[ "$body" == "ok" ]] || die "unexpected /healthz body: ${body}"
log_group_end

Chaque problème de la version en ligne est corrigé :

  • retry renvoie le dernier code de sortie lorsque toutes les tentatives échouent ; une application défaillante fait donc échouer l’étape. Voir retry dans scripts/lib/03-errors.sh.
  • cleanup_register supprime le conteneur en cas de réussite, d’échec ou d’annulation. Voir scripts/lib/04-cleanup.sh.
  • Le script s’exécute de la même façon sur un portable :
bash
bash scripts/ci/02-test.sh docker

Cette commande construit l’image avec 01-build.sh, puis exécute 02-test.sh.

Numérotez les scripts selon l’ordre d’exécution

Chaque script du guide commence par un nombre à deux chiffres : 01-build.sh s’exécute avant 02-test.sh, et 00 est réservé à l’installation. Les listes de répertoires montrent ainsi l’ordre, et un script inutilisé se remarque. Voir scripts numérotés.

Ce que vous obtenez

Exécutions locales. Une personne développeuse reproduit un échec de CI avec une seule commande.

Une vraie analyse statique. ShellCheck lit directement un fichier .sh. actionlint exécute aussi ShellCheck sur les blocs run:, mais un fichier de script est plus facile à vérifier, à formater et à réviser.

Des tests. Bats teste les fonctions utilitaires partagées, y compris les chemins d’échec. Voir tests/bats/03-errors.bats pour retry.

Des flux de travail portables. Le YAML ne fait que coordonner ; passer à un autre exécuteur compatible revient donc à changer runs-on, sans réécrire les étapes.

Commandes pour vérifier vos scripts

Tirées du README du dépôt :

bash
brew install actionlint shellcheck shfmt bats-core zizmor   # on a Mac
bash scripts/ci/01-validate.sh        # actionlint, shellcheck, shfmt, zizmor
bash scripts/ci/02-test.sh            # Bats tests and every example
bash scripts/ci/02-test.sh golang     # one suite

Quand le code en ligne convient

Les commandes courtes et à usage unique peuvent rester dans le YAML. Un exemple simplifié :

yaml
- name: Install dependencies
  run: npm ci

En cas de doute, demandez-vous : « Voudrais-je exécuter ceci sur mon portable s’il échoue ? » Si oui, faites-en un script.

Exécuter sur Runzivo

Runzivo est en accès anticipé. Il exécute chaque tâche GitHub Actions admissible dans une microVM neuve à usage unique. Comme la logique se trouve dans des scripts, déplacer une tâche revient à changer une seule ligne :

yaml
runs-on: rzv-ubuntu-24.04-x64-2vcpu-8gb

Voir examples/runzivo-runners et la documentation de Runzivo.

Dans le dépôt

La suite : la gestion des erreurs avec les traps, le nettoyage, la journalisation et les codes de sortie.

Chaque exemple de cette série est testé dans rzv-ci-plb, the Runzivo CI playbook, fixé à l’étiquette v0.1.0.