Aller au contenu
Déboguer, analyser et tester

Déboguer, analyser et tester

À la fin, vous saurez

  • Localiser une erreur d'exécution avec set -x, un PS4 informatif et BASH_XTRACEFD, sans exposer de secret dans la trace
  • Analyser un dépôt de scripts avec ShellCheck, interpréter ses codes et ses gravités, et justifier chaque désactivation
  • Configurer ShellCheck pour un dépôt avec .shellcheckrc : fichiers chargés par source, vérifications facultatives
  • Écrire des tests Bats qui vérifient le code de sortie, la sortie et les effets d'un script, en remplaçant les commandes externes par des doublures
  • Vérifier qu'une suite de tests détecte réellement une régression
  • Automatiser l'analyse et les tests dans un Makefile et un workflow d'intégration continue
  • Mener la revue de code d'un script et décider, sur des critères explicites, s'il faut le réécrire dans un autre langage

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) bats 1.10.0 (Ubuntu), 1.11.1 (Debian) bats-assert 2.1.0 (Ubuntu et Debian) bats-support 0.3.0 (Ubuntu et Debian) shellcheck 0.9.0 (Ubuntu 24.04 et runner ubuntu-24.04 de GitHub), 0.10.0 (Debian 13) shfmt 3.8.0 (Ubuntu et Debian) , vérifié le 8 octobre 2026

Pourquoi

Le dépôt signalements-outils existe, les scripts de Camille ont été réécrits leçon après leçon, et publier-export est devenu un vrai outil : options, codes de sortie, verrou, nettoyage. Un vendredi soir, un collègue retouche l'analyse de --destination pour un essai avec un autre bucket. Il teste à la main, une fois, dans son terminal : tout va bien. Le lundi, la mairie signale qu'elle n'a rien reçu depuis trois jours. Le minuteur a bien tourné, le service est en échec, et le journal contient une ligne qui pointe une faute de frappe dans un nom de variable, sur une branche du code que l'essai manuel n'avait pas parcourue.

Ce scénario n'a rien d'exotique. Un script shell est du code, souvent du code critique (il déploie, il purge, il envoie des données à l'extérieur), mais il échappe presque toujours aux pratiques que l'on impose au code applicatif : pas d'analyse automatique, pas de tests, pas de revue sérieuse, parce qu'il est « trop petit pour être testé ». Il l'est, jusqu'au jour où il envoie un fichier vide à la mairie.

Cette leçon apporte les trois outils qui manquent, chacun à son moment :

  • observer un script qui se comporte mal, avec la trace d'exécution de Bash (set -x), réglée pour dire où l'on est sans divulguer de secret ;
  • lire le code avant qu'il ne tourne, avec ShellCheck, l'analyseur statique de référence pour le shell ;
  • prouver qu'il fait ce qu'on attend, et qu'il continuera de le faire après la prochaine modification, avec Bats, un cadre de tests écrit en Bash.

Puis elle branche le tout dans le dépôt (make verifier) et dans l'intégration continue, pour que plus aucune modification de publier-export n'atteigne sig-outils sans être passée par là. C'est la dernière leçon du cours : elle se termine par l'état final du dépôt et par la question qu'il faut savoir se poser, celle du moment où un script doit cesser d'être un script Bash.

Les concepts

Lire, observer, prouver

Quatre outils, quatre questions différentes. Les confondre, c'est croire qu'un script est correct parce qu'il passe l'un d'eux.

OutilQuestionExécute le script ?Ce qu'il ne voit pas
bash -nLe fichier est-il du Bash syntaxiquement valide ?nontout le reste : commandes absentes, variables mal nommées, logique
ShellCheckLe code contient-il des constructions connues pour être fausses ou fragiles ?nonce qui dépend des données réelles et de l'environnement
set -xQu'a fait Bash, exactement, lors de cette exécution ?ouiles branches qui n'ont pas été parcourues
BatsLe script se comporte-t-il comme prévu dans ces situations ?ouiles situations auxquelles personne n'a pensé

Les deux premiers relèvent de l'analyse statique : on examine le texte du programme sans l'exécuter. Les deux derniers de l'analyse dynamique. Aucun ne suffit seul ; ensemble, ils couvrent l'essentiel.

bash -n : la syntaxe, et rien d'autre

L'option -n (noexec) demande à Bash de lire les commandes sans les exécuter. Le manuel la présente comme un moyen de chercher les erreurs de syntaxe, et précise qu'elle est ignorée par un shell interactif. La leçon 1 l'a utilisée sur le premier squelette ; rappelons surtout ses limites. Un fi oublié ou un guillemet non fermé sont détectés :

$ bash -n casse.sh
casse.sh: line 3: syntax error: unexpected end of file
$ echo $?
2

Mais un script qui appelle une commande inexistante, qui lit une variable jamais définie ou qui supprime le mauvais répertoire passe sans un mot : ce n'est pas de la syntaxe. bash -n est utile comme premier filtre très rapide (un hook Git, par exemple), jamais comme preuve.

La trace d'exécution

L'option -x (xtrace) fait afficher par Bash, sur la sortie d'erreur, chaque commande simple après ses expansions et avant son exécution, précédée de la valeur développée de la variable PS4. C'est ce qu'on appelle une trace d'exécution (execution trace). Trois façons de l'activer :

  • bash -x ./script : tout le script, sans le modifier. Attention, cette forme ignore la ligne #! (c'est bash qui lit le fichier) ;
  • set -x dans le script, et set +x pour l'arrêter : une portion seulement ;
  • local - suivi de set -x dans une fonction : la trace s'arrête automatiquement au retour de la fonction, parce que local - rend les options du shell locales à la fonction (Bash 4.4 et plus).

Le préfixe par défaut est + . Le manuel précise que le premier caractère de PS4 est répété pour indiquer les niveaux d'imbrication : une commande lancée dans une substitution de commande apparaît avec ++, une substitution dans une substitution avec +++. La variable BASH_XTRACEFD, ajoutée dans Bash 4.1, désigne un descripteur de fichier vers lequel envoyer la trace au lieu de la sortie d'erreur.

set -v (verbose) est différent : il affiche les lignes telles qu'elles sont lues, avant toute expansion. Il montre le texte du script, pas ce que Bash en a fait ; c'est -x qui sert au diagnostic.

L'analyse statique avec ShellCheck

ShellCheck lit un script, le découpe avec son propre analyseur syntaxique (il n'utilise pas Bash), puis applique quelques centaines de règles. Chaque constat porte :

  • un code stable, SC suivi de quatre chiffres, qui renvoie à une page du wiki (https://www.shellcheck.net/wiki/SC2086) expliquant le problème, des exemples et les exceptions légitimes. Les codes SC1xxx concernent l'analyse syntaxique, SC2xxx les constats sur le code, SC3xxx la portabilité vers sh ;
  • une gravité, parmi quatre niveaux que la page de manuel ordonne ainsi : error, warning, info, style. L'option -S (--severity) fixe la gravité minimale signalée, style par défaut.

ShellCheck renvoie 0 si aucun constat, 1 s'il en a trouvé, 2 si un fichier n'a pas pu être traité, 3 et 4 pour une mauvaise invocation : on peut donc l'utiliser tel quel comme étape de CI.

Les codes que vous verrez le plus souvent :

CodeGravitéMessage (extrait)Ce qu'il signale
SC2086infoDouble quote to prevent globbing and word splitting.une variable non protégée par des guillemets (leçon 2)
SC2046warningQuote this to prevent word splitting.une substitution de commande non protégée
SC2068errorDouble quote array expansions to avoid re-splitting elements.${t[@]} ou $@ sans guillemets (leçon 7)
SC2155warningDeclare and assign separately to avoid masking return values.local x=$(cmd) : le code de cmd est perdu (leçon 9)
SC2164warningUse 'cd ... || exit' or 'cd ... || return' in case cd fails.un cd non vérifié, dans un script sans set -e
SC2115warningUse "${var:?}" to ensure this never expands to /* .un rm -r "$rep/"* qui viderait la racine si rep est vide
SC2181styleCheck exit code directly with e.g. 'if mycmd;', not indirectly with $?.un if [ $? -ne 0 ] (leçon 4)
SC2034warningfoo appears unused. Verify use (or export if used externally).une variable affectée et jamais lue, souvent une faute de frappe
SC2154warningvar is referenced but not assigned.l'inverse : une variable lue et jamais affectée
SC1091infoNot following: ...un fichier chargé par source que ShellCheck n'a pas suivi
SC2317infoCommand appears to be unreachable. Check usage (or ignore if invoked indirectly).du code apparemment jamais exécuté

Depuis la version 0.9.0, ShellCheck dispose d'un moteur d'analyse de flot de données : il suit les valeurs possibles des variables le long du programme. Une conséquence visible : SC2086 n'est signalé que pour une variable qui peut contenir des espaces ou des caractères de motif. n=3; echo $n ne déclenche rien, f=$(ls); echo $f déclenche l'avertissement.

Directives et fichier de configuration

On ne fait pas taire ShellCheck en ignorant ses messages, mais par une directive, un commentaire qu'il comprend :

# shellcheck disable=SC2086

La page Directive du wiki fixe la portée : une directive placée juste après la ligne #! s'applique à tout le fichier ; ailleurs, elle s'applique à la commande qui suit, y compris une commande composée entière (une fonction, une boucle, un case). Elle ne peut pas précéder un else ou une branche isolée d'un case. Les autres directives utiles :

  • source=chemin : indique quel fichier un source dynamique charge. Le préfixe spécial SCRIPTDIR désigne le répertoire du script analysé ;
  • source-path=chemin : ajoute un répertoire où chercher les fichiers chargés (SCRIPTDIR est accepté) ;
  • enable=nom : active une vérification facultative (voir plus bas) ;
  • shell=bash : indique le dialecte d'un fichier sans ligne #!, typiquement une bibliothèque comme lib/commun.sh.

Les mêmes clés, sans le préfixe shellcheck, peuvent figurer dans un fichier .shellcheckrc. ShellCheck le cherche dans le répertoire du script, puis dans chaque répertoire parent, puis dans ~/.shellcheckrc et ~/.config/shellcheckrc ; seul le premier trouvé est lu. Un .shellcheckrc à la racine du dépôt s'applique donc à tous ses scripts. Une clé n'y est disponible qu'en configuration : external-sources=true, qui autorise ShellCheck à suivre un fichier chargé par source même s'il n'est pas sur la ligne de commande (l'équivalent de l'option -x). Un script ne peut pas l'activer lui-même, et c'est voulu : suivre des fichiers arbitraires est une décision de projet.

Les vérifications facultatives sont des règles désactivées par défaut parce qu'elles relèvent du goût ou produisent du bruit. shellcheck --list-optional les énumère ; parmi elles, require-double-brackets (exiger [[ ]] en Bash), require-variable-braces (exiger ${var}), check-extra-masked-returns (davantage de codes de retour masqués), check-set-e-suppressed (signaler les fonctions dont set -e est neutralisé parce qu'on les appelle dans une condition, le piège de la leçon 9). Le wiki déconseille enable=all hors expérimentation : certaines se contredisent.

Formater avec shfmt

shfmt, du projet mvdan/sh, est au shell ce que gofmt est à Go : il réécrit l'indentation et la disposition sans changer le sens. Ses options règlent le style (-i 2 pour deux espaces, -ci pour indenter les branches de case, -bn pour placer && et | en début de ligne, -sr pour une espace après les redirections), et -d affiche la différence au lieu de réécrire le fichier, ce qui en fait une vérification de CI. Il ne remplace pas ShellCheck : un script mal formé et faux reste faux une fois bien formaté.

Tester avec Bats

Bats (Bash Automated Testing System, dans sa version maintenue bats-core) exécute des fichiers .bats qui ressemblent à du Bash, avec une seule syntaxe nouvelle :

@test "description lisible du comportement" {
  commande
  [[ condition ]]
}

Chaque bloc @test est un test. Il réussit si toutes ses commandes réussissent, et échoue à la première qui échoue : Bats exécute le corps avec set -e. Les vérifications s'écrivent donc comme n'importe quelle condition Bash.

La fonction run est le cœur de l'outil. Elle exécute une commande sans faire échouer le test, et range le résultat dans trois variables :

  • $status : le code de sortie ;
  • $output : la sortie standard et la sortie d'erreur, mêlées ;
  • ${lines[@]} : la même sortie découpée en lignes (les lignes vides sont omises par défaut).

Depuis Bats 1.5.0, run accepte des options placées avant la commande : run -3 cmd fait échouer le test si le code n'est pas 3, run ! cmd s'il est nul, --separate-stderr sépare la sortie d'erreur dans $stderr. Un fichier qui utilise ces options déclare bats_require_minimum_version 1.5.0, sinon Bats émet l'avertissement BW02.

Autour des tests, des fonctions d'accroche (hooks) : setup et teardown s'exécutent avant et après chaque test, setup_file et teardown_file une fois par fichier. Bats fournit à chaque test un répertoire temporaire qui lui est propre, $BATS_TEST_TMPDIR (depuis la version 1.4.0), supprimé ensuite, et $BATS_TEST_DIRNAME, le répertoire du fichier de test.

Deux bibliothèques complètent Bats : bats-support (fonctions communes) et bats-assert, qui fournit des assertions aux messages d'échec lisibles : assert_success, assert_failure 3, assert_output --partial "texte", assert_line --index 0 "texte", refute_output, assert_equal. Elles se chargent avec bats_load_library, qui les cherche dans BATS_LIB_PATH (par défaut /usr/lib/bats, là où les paquets Debian et Ubuntu les installent).

Les doublures de commandes

publier-export appelle aws pour déposer un fichier dans l'Object Storage. Un test qui appellerait le vrai aws aurait besoin de clés, du réseau, d'un bucket, et laisserait des objets derrière lui : il serait lent, fragile et dangereux. On remplace donc la commande par une doublure de test (test double) : un petit exécutable du même nom, placé dans un répertoire mis en tête du PATH, qui enregistre les arguments reçus et simule un succès ou un échec.

Cela fonctionne parce que le script trouve aws par le PATH (leçon 7 de Premiers pas). C'est une contrainte de conception à retenir : un script qui écrit /usr/local/bin/aws en dur ne se teste pas. Les autres dépendances au monde extérieur se traitent de la même façon : la date par une option (--date), le répertoire des exports par PUBLIER_EXPORT_REPERTOIRE, le verrou par PUBLIER_EXPORT_VERROU, le fichier de configuration par PUBLIER_EXPORT_CONFIG (leçon 8). C'est ce qui rend les tests hermétiques : ils ne dépendent que de ce qu'ils préparent (test hermétique).

En pratique

On travaille dans le dépôt signalements-outils, sur un poste ou une machine de développement Debian 13 ou Ubuntu 24.04 :

$ sudo apt install shellcheck bats bats-support bats-assert shfmt

Déboguer le script de Camille avec set -x

Avant la réécriture, la mairie avait signalé un mystère : pour le 1er octobre, le fichier .sha256 était arrivé, mais pas l'archive. Le journal du minuteur affichait un succès. Voici le script d'origine :

#!/bin/bash
# publier l'export du jour pour la mairie
cd /srv/donnees/exports
jour=$(date +%F)
f=signalements-$jour.csv
gzip -kf $f
sha256sum $f.gz > $f.gz.sha256
for x in $f.gz $f.gz.sha256; do
  aws s3 cp $x s3://sig-exports-mairie/$(date +%Y/%m)/ --endpoint-url https://s3.fr-par.scw.cloud
done
if [ $? -ne 0 ]; then echo "echec de l'envoi"; fi

On le relance sous trace, en remplaçant aws par une doublure qui refuse le premier envoi et accepte le second (la situation de ce jour-là), et date par une doublure qui renvoie le 1er octobre. Voici la trace, les messages de la doublure de aws étant omis :

$ bash -x /opt/signalements/bin/publier-export
+ cd /srv/donnees/exports
++ date +%F
+ jour=2026-10-01
+ f=signalements-2026-10-01.csv
+ gzip -kf signalements-2026-10-01.csv
+ sha256sum signalements-2026-10-01.csv.gz
+ for x in $f.gz $f.gz.sha256
++ date +%Y/%m
+ aws s3 cp signalements-2026-10-01.csv.gz s3://sig-exports-mairie/2026/10/ --endpoint-url https://s3.fr-par.scw.cloud
+ for x in $f.gz $f.gz.sha256
++ date +%Y/%m
+ aws s3 cp signalements-2026-10-01.csv.gz.sha256 s3://sig-exports-mairie/2026/10/ --endpoint-url https://s3.fr-par.scw.cloud
+ '[' 0 -ne 0 ']'

Tout est là. Chaque ligne est la commande après expansion : on lit le nom de fichier réel, le préfixe calculé, et les ++ signalent les substitutions de commande, exécutées un niveau plus bas. La dernière ligne donne la clé : [ reçoit 0, alors que le premier aws a échoué. $? après une boucle for vaut le code de la dernière commande exécutée dans la boucle, ici le second envoi, qui a réussi. Le test ne regarde jamais le premier. Sans la trace, on aurait soupçonné aws, le réseau, les droits ; avec elle, le défaut est dans le script, en dix secondes.

Notez aussi ce qui n'apparaît pas : la redirection > $f.gz.sha256 ne figure pas dans la ligne de sha256sum. La trace montre les commandes et leurs arguments, pas les redirections. Pour savoir où part une sortie, il faut lire le code.

Une trace qui dit où l'on est

Sur un script de près de 300 lignes découpé en fonctions, comme publier-export aujourd'hui, + ne suffit plus : on veut savoir de quel fichier, de quelle ligne et de quelle fonction vient chaque commande. PS4 est développée avant chaque ligne de trace, comme une invite ; on peut donc y mettre des variables :

PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
  • ${BASH_SOURCE[0]##*/} : le nom du fichier en cours, sans son chemin ; c'est lui qui distingue publier-export de commun.sh ;
  • ${LINENO} : le numéro de ligne ;
  • ${FUNCNAME[0]:-main} : la fonction en cours, ou main au niveau principal, où FUNCNAME n'existe pas.

Les apostrophes sont indispensables : on veut que ces variables soient développées à chaque ligne de trace, pas une fois pour toutes au moment de l'affectation. Sur un petit script d'essai, la différence est immédiate :

$ PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' bash -x essai.sh
+ essai.sh:8:main: jour=2026-10-07
+ essai.sh:9:main: fichier=exports/signalements-2026-10-07.csv
+ essai.sh:10:main: compter exports/signalements-2026-10-07.csv
+ essai.sh:3:compter: local fichier=exports/signalements-2026-10-07.csv
+ essai.sh:4:compter: local n
++ essai.sh:5:compter: wc -l
+ essai.sh:5:compter: n=2
+ essai.sh:6:compter: echo 2
2

On a passé PS4 par l'environnement, ce qui évite de modifier le script : Bash importe PS4 au démarrage (sauf, on le verra, quand il tourne en root). Pour un réglage permanent, on l'affecte dans le script juste avant set -x.

Envoyer la trace ailleurs : BASH_XTRACEFD

Mêlée à la sortie d'erreur, la trace noie les vrais messages et casse les tests qui les vérifient. BASH_XTRACEFD l'envoie vers un descripteur dédié, ouvert sur un fichier :

exec 9>> trace.txt
BASH_XTRACEFD=9
set -x
echo "sortie normale"
echo erreur >&2
$ ./trace-fd.sh
sortie normale
erreur
$ cat trace.txt
+ echo 'sortie normale'
+ echo erreur

La sortie standard et la sortie d'erreur sont intactes ; la trace est dans son fichier. Le manuel signale un piège : affecter BASH_XTRACEFD=2 puis la désaffecter ferme la sortie d'erreur. N'y mettez jamais 1 ou 2.

publier-export en tire une option de diagnostic, utile en production quand --verbeux ne suffit pas :

# --trace FICHIER : trace d'exécution complète dans FICHIER, lisible du seul propriétaire.
activer_trace() {
  local fichier=$1
  ( umask 077 && : >> "$fichier" ) || erreur_usage "trace impossible dans $fichier"
  exec 9>> "$fichier"
  BASH_XTRACEFD=9
  PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
  set -x
}

Le fichier est créé en 0600 avant d'être ouvert, pour la raison exposée dans la section Sécurité : une trace contient tout ce que le script manipule.

Tracer une seule fonction

Quand on soupçonne une fonction précise, inutile de tracer tout le script :

verifier_export() {
  local -
  set -x
  # ... corps inchangé ...
}

local - sauvegarde les options du shell (set -x compris) et les restaure au retour de la fonction. Pas besoin de set +x, et la trace s'arrête même si la fonction sort par un return anticipé.

À l'inverse, pour couper la trace autour d'une ligne sensible, set +x apparaît lui-même dans la trace (+ set +x), ce qui est disgracieux mais surtout trompeur dans un fichier de trace. La forme { set +x; } 2>/dev/null le rend muet : la redirection s'applique au groupe, donc à la ligne de trace de set +x. Elle ne fonctionne que si la trace va sur la sortie d'erreur ; avec BASH_XTRACEFD, la ligne part dans le fichier de trace quoi qu'il arrive.

Pas à pas avec le piège DEBUG

Le piège DEBUG exécute une commande avant chaque commande simple. Avec BASH_COMMAND, qui contient la commande sur le point de s'exécuter, on obtient un exécutant pas à pas rudimentaire :

trap 'read -rp "[$LINENO] $BASH_COMMAND ? " < /dev/tty' DEBUG

Chaque commande s'affiche et attend Entrée ; Ctrl+C interrompt. Le < /dev/tty lit au clavier même si l'entrée standard du script est redirigée. C'est un outil de poste de travail, à retirer avant tout commit ; pour un vrai débogueur (points d'arrêt, inspection), le projet bashdb existe, mais la trace et une bonne journalisation suffisent presque toujours.

Passer ShellCheck sur le script de Camille

Sur le script d'origine, ShellCheck relève notamment :

LigneCodeGravitéCe qu'il signale
3SC2164warningcd /srv/donnees/exports non vérifié : si le répertoire manque, tout le reste s'exécute dans le répertoire courant
6, 7, 8SC2086info$f non protégé ; sa valeur vient d'une substitution de commande, ShellCheck ne peut pas garantir qu'elle est sans espace
9SC2086info$x non protégé
9SC2046warning$(date +%Y/%m) non protégé
11SC2181style$? testé indirectement, au lieu de tester la commande elle-même

Rien de ce qui a causé l'incident n'est un « bug » au sens de ShellCheck : il n'a aucun moyen de savoir que vous vouliez vérifier chaque envoi. Mais SC2181 pointe exactement la ligne fautive, et la correction qu'il suggère (tester la commande dans le if) aurait forcé à placer la vérification dans la boucle. C'est typique : ShellCheck ne trouve pas votre erreur de logique, il signale les constructions où elle se cache.

En sortie de terminal, chaque constat se présente ainsi : la ligne en cause, un repère ^--^ sous la portion concernée, le code, la gravité entre parenthèses, le message, parfois une proposition de correction (Did you mean:), et à la fin les liens vers le wiki. Pour une CI ou un éditeur, -f gcc produit une ligne par constat, au format fichier:ligne:colonne: gravité: message [SCxxxx] (avec note pour info et style), que la plupart des outils savent lire.

Désactiver un avertissement, et le justifier

Les fonctions sur_sortie et sur_signal de la leçon 10 ne sont appelées que par trap : aucune ligne du script ne les appelle par leur nom. Le wiki de SC2317 reconnaît que ShellCheck peut alors juger leur corps inaccessible. Sur publier-export, ShellCheck 0.9.0 et 0.10.0 ne disent rien, et la raison est instructive : la garde finale if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; exit; fi est conditionnelle, donc ShellCheck considère que le fichier peut se terminer normalement, après quoi n'importe quelle fonction définie pourrait encore être appelée (par un fichier qui le charge, par exemple). Remplacez cette garde par un main "$@" suivi d'un exit inconditionnel, et ShellCheck 0.10.0 signale chaque ligne de sur_erreur, nettoyer, sur_sortie et sur_signal :

In bin/publier-export line 157:
  local code=$?
  ^-----------^ SC2317 (info): Command appears to be unreachable. Check usage (or ignore if invoked indirectly).

Dans ce cas, et seulement dans ce cas, on désactive à l'endroit précis, avec la raison :

# shellcheck disable=SC2317 # appelée par trap sur_sortie EXIT
sur_sortie() {
  local code=$?
  nettoyer
  exit "$code"
}

Placée avant la fonction, la directive couvre toute la fonction et rien d'autre. Trois règles d'équipe pour les désactivations :

  1. jamais sans commentaire : le texte après le second # explique pourquoi l'avertissement est faux ici ; en revue, une désactivation sans justification est refusée ;
  2. au plus près : sur la commande, pas sur le fichier. Une directive placée juste après la ligne #! couvre tout le fichier, souvent par accident ;
  3. jamais pour un SC2086 de confort : si une expansion doit vraiment être découpée, c'est presque toujours un tableau qu'il faut (leçon 7). La page de SC2086 liste les exceptions acceptables : options construites dans un tableau, ${debug:+"-x"} pour une option facultative, set -f quand on désactive volontairement les motifs.

On en verra un autre cas légitime dans les tests : une chaîne entre apostrophes qui doit contenir un $( ) littéral.

Le .shellcheckrc du dépôt

À la racine de signalements-outils :

# Configuration de ShellCheck pour tout le dépôt.
# Suivre les fichiers chargés par source (lib/commun.sh), même hors de la ligne de commande.
external-sources=true
# Chercher les fichiers chargés à partir du répertoire de chaque script.
source-path=SCRIPTDIR
# Vérifications facultatives retenues par l'équipe.
enable=require-double-brackets
enable=deprecate-which

Et dans chaque script, la ligne qui charge la bibliothèque porte sa directive, celle de la leçon 6 :

REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh"

Sans directive, ShellCheck ne peut pas deviner ce que vaut $REP_OUTILS : il émet SC1090 (Can't follow non-constant source). Avec la directive, il lui faut encore les deux clés du .shellcheckrc, et l'essai avec ShellCheck 0.10.0 le montre bien. Avec source-path=SCRIPTDIR seul, le chemin relatif est résolu depuis bin/, mais ShellCheck refuse de suivre un fichier absent de la ligne de commande :

In bin/publier-export line 11:
source "$REP_OUTILS/lib/commun.sh" || {
       ^-------------------------^ SC1091 (info): Not following: ../lib/commun.sh was not specified as input (see shellcheck -x).


In bin/publier-export line 99:
      -v|--verbeux)     VERBEUX=1 ;;
                        ^-----^ SC2034 (warning): VERBEUX appears unused. Verify use (or export if used externally).

Avec external-sources=true seul (ou l'option -x), il accepte de suivre, mais résout ../lib/commun.sh depuis le répertoire courant et ne trouve rien (openBinaryFile: does not exist). Avec les deux, plus aucun constat, et le second message disparaît aussi : ShellCheck lit maintenant lib/commun.sh, y voit que deboguer consulte VERBEUX, et sait que l'affectation n'est pas inutile. C'est tout l'intérêt de suivre la bibliothèque : le script et ses fonctions communes sont analysés comme un tout. lib/commun.sh, qui n'a pas de ligne #! puisqu'on ne l'exécute jamais, commence par # shellcheck shell=bash.

Écrire les tests de publier-export

La doublure d'abord, versionnée dans tests/doublures/aws (exécutable) :

#!/usr/bin/env bash
# Doublure de aws pour les tests : note chaque appel, puis simule « s3 cp » et
# « s3api head-object » dans un répertoire local au lieu de l'Object Storage.
# FAUX_AWS_CODE simule un échec de tous les appels.
printf '%s\n' "$*" >> "${FAUX_AWS_JOURNAL:?}"
if (( ${FAUX_AWS_CODE:-0} != 0 )); then
  echo "doublure aws : échec simulé" >&2
  exit "$FAUX_AWS_CODE"
fi
case "$1 $2" in
  "s3 cp")
    fichier=${*: -2:1}
    cible=${*: -1}
    mkdir -p -- "${FAUX_BUCKET:?}/$(dirname -- "${cible#s3://}")"
    cp -- "$fichier" "$FAUX_BUCKET/${cible#s3://}"
    ;;
  "s3api head-object")
    while (( $# > 0 )); do
      case $1 in
        --bucket) seau=$2; shift ;;
        --key) cle=$2; shift ;;
      esac
      shift
    done
    stat -c %s -- "${FAUX_BUCKET:?}/$seau/$cle"
    ;;
  *)
    echo "doublure aws : appel non prévu : $*" >&2
    exit 99
    ;;
esac

Elle ne cherche pas à imiter aws en entier : elle comprend juste assez de aws s3 cp <source> <cible> et de aws s3api head-object, le contrôle de taille de la leçon 9, pour que les tests puissent vérifier ce qui a été déposé et où. ${*: -2:1} et ${*: -1} sont l'avant-dernier et le dernier argument ; ${FAUX_AWS_JOURNAL:?} fait échouer la doublure si on l'appelle hors des tests, plutôt que d'écrire n'importe où ; un appel imprévu sort avec 99, ce qui fait échouer le test au lieu de passer inaperçu. Comme le vrai aws, elle reste un seul processus (pas de sleep au premier plan : la leçon 10 a montré pourquoi une doublure qui attend doit le faire par exec sleep).

Puis le fichier de tests, tests/publier-export.bats :

#!/usr/bin/env bats
# Tests de bin/publier-export. Aucun test ne contacte l'Object Storage :
# la doublure tests/doublures/aws remplace la vraie commande.

bats_require_minimum_version 1.5.0

setup() {
  bats_load_library bats-support
  bats_load_library bats-assert

  SCRIPT="$BATS_TEST_DIRNAME/../bin/publier-export"
  PATH="$BATS_TEST_DIRNAME/doublures:$PATH"

  export PUBLIER_EXPORT_REPERTOIRE="$BATS_TEST_TMPDIR/exports"
  export PUBLIER_EXPORT_VERROU="$BATS_TEST_TMPDIR/verrou"
  export PUBLIER_EXPORT_CONFIG="$BATS_TEST_TMPDIR/absente.conf"
  export FAUX_AWS_JOURNAL="$BATS_TEST_TMPDIR/aws.log"
  export FAUX_BUCKET="$BATS_TEST_TMPDIR/bucket"
  export HORODATER=0
  unset FAUX_AWS_CODE PUBLIER_EXPORT_DESTINATION VERBEUX

  mkdir -p "$PUBLIER_EXPORT_REPERTOIRE/publies"
  : > "$FAUX_AWS_JOURNAL"

  # Garde-fou : le aws que verra le script doit être la doublure, jamais le vrai.
  [[ $(command -v aws) == "$BATS_TEST_DIRNAME/doublures/aws" ]]
}

# creer_export JOUR [LIGNE...] : écrit un export avec l'en-tête attendu.
creer_export() {
  local jour=$1
  shift
  printf '%s\n' "id,type,commune,date" "$@" \
    > "$PUBLIER_EXPORT_REPERTOIRE/signalements-$jour.csv"
}

@test "publie l'archive et son empreinte sous AAAA/MM" {
  creer_export 2026-10-07 "1,nid-de-poule,Exempleville,2026-10-07"
  run -0 "$SCRIPT" --date 2026-10-07
  assert_output --partial "export du 2026-10-07 publié"
  [[ -f $FAUX_BUCKET/sig-exports-mairie/2026/10/signalements-2026-10-07.csv.gz ]]
  [[ -f $FAUX_BUCKET/sig-exports-mairie/2026/10/signalements-2026-10-07.csv.gz.sha256 ]]
}

@test "l'empreinte déposée vérifie l'archive déposée" {
  creer_export 2026-10-07 "1,nid-de-poule,Exempleville,2026-10-07"
  run -0 "$SCRIPT" -d 2026-10-07
  cd "$FAUX_BUCKET/sig-exports-mairie/2026/10" || return 1
  run -0 sha256sum --check --quiet signalements-2026-10-07.csv.gz.sha256
  run -0 zcat signalements-2026-10-07.csv.gz
  assert_line --index 0 "id,type,commune,date"
}

@test "n'écrit rien sur la sortie standard" {
  creer_export 2026-10-07 "1,nid-de-poule,Exempleville,2026-10-07"
  run --separate-stderr -0 "$SCRIPT" -d 2026-10-07
  assert_equal "$output" ""
}

@test "accepte un export aux fins de ligne CRLF" {
  printf 'id,type,commune,date\r\n1,nid-de-poule,Exempleville,2026-10-07\r\n' \
    > "$PUBLIER_EXPORT_REPERTOIRE/signalements-2026-10-07.csv"
  run -0 "$SCRIPT" -d 2026-10-07
}

@test "refuse un export vide sans rien déposer" {
  : > "$PUBLIER_EXPORT_REPERTOIRE/signalements-2026-10-07.csv"
  run -3 "$SCRIPT" -d 2026-10-07
  assert_output --partial "export vide"
  [[ ! -s $FAUX_AWS_JOURNAL ]]
}

@test "refuse un export sans ligne de données" {
  creer_export 2026-10-07
  run -3 "$SCRIPT" -d 2026-10-07
  assert_output --partial "aucune ligne de données"
}

@test "refuse un en-tête inattendu" {
  printf '%s\n' "id;type;commune;date" "1;nid-de-poule;Exempleville;2026-10-07" \
    > "$PUBLIER_EXPORT_REPERTOIRE/signalements-2026-10-07.csv"
  run -3 "$SCRIPT" -d 2026-10-07
  assert_output --partial "en-tête inattendu"
}

@test "signale un export absent" {
  run -3 "$SCRIPT" -d 2026-10-07
  assert_output --partial "export introuvable"
}

@test "refuse une date mal formée ou inexistante" {
  run -2 "$SCRIPT" -d 07/10/2026
  assert_output --partial "date invalide"
  run -2 "$SCRIPT" -d 2026-02-30
  assert_output --partial "date inexistante"
}

@test "refuse une option inconnue et renvoie vers l'aide" {
  run -2 "$SCRIPT" --frobnicate
  assert_line --index 0 "publier-export : option inconnue : --frobnicate"
  assert_output --partial "--help"
}

@test "prend la destination dans la configuration, sans l'exécuter" {
  creer_export 2026-10-07 "1,nid-de-poule,Exempleville,2026-10-07"
  # shellcheck disable=SC2016 # le $( ) doit rester littéral : c'est l'attaque simulée
  printf '%s\n' "DESTINATION=s3://sig-exports-recette" 'X=$(touch pirate)' \
    > "$PUBLIER_EXPORT_CONFIG"
  cd "$BATS_TEST_TMPDIR" || return 1
  run -0 "$SCRIPT" -d 2026-10-07
  assert_output --partial "clé inconnue « X »"
  [[ -f $FAUX_BUCKET/sig-exports-recette/2026/10/signalements-2026-10-07.csv.gz ]]
  [[ ! -e pirate ]]
}

@test "renvoie 4 si le dépôt échoue" {
  creer_export 2026-10-07 "1,nid-de-poule,Exempleville,2026-10-07"
  FAUX_AWS_CODE=1 run -4 "$SCRIPT" -d 2026-10-07
  assert_output --partial "échec du dépôt"
}

@test "--dry-run prépare l'archive sans appeler aws" {
  creer_export 2026-10-07 "1,nid-de-poule,Exempleville,2026-10-07"
  run -0 "$SCRIPT" --dry-run -d 2026-10-07
  assert_output --partial "seraient déposés dans s3://sig-exports-mairie/2026/10/"
  [[ ! -s $FAUX_AWS_JOURNAL ]]
}

@test "renvoie 5 quand une autre exécution tient le verrou" {
  creer_export 2026-10-07 "1,nid-de-poule,Exempleville,2026-10-07"
  exec 8> "$PUBLIER_EXPORT_VERROU"
  flock -n 8
  run -5 "$SCRIPT" -d 2026-10-07
  assert_output --partial "une autre exécution est en cours"
  exec 8>&-
}

@test "ne laisse aucun répertoire de travail, même en cas d'échec" {
  creer_export 2026-10-07 "1,nid-de-poule,Exempleville,2026-10-07"
  FAUX_AWS_CODE=1 run -4 "$SCRIPT" -d 2026-10-07
  run ! compgen -G "$PUBLIER_EXPORT_REPERTOIRE/publies/.travail.*"
}

Lisons les choix, car chacun répond à un piège :

  • Un test, un comportement, et un titre qui le décrit comme une règle (« refuse un export vide sans rien déposer »). Quand il échoue, le titre dit ce qui est cassé, sans ouvrir le fichier.
  • Le code de sortie ET le message. run -3 seul passerait si le script renvoyait 3 pour une autre raison ; assert_output --partial "export vide" vérifie que c'est la bonne. On le démontrera plus bas.
  • Les effets, pas seulement les messages. Le test de l'empreinte relit ce que la doublure a « déposé » et le vérifie avec sha256sum --check, exactement comme la mairie le fera. Les tests de refus vérifient que le journal de la doublure est vide : rien n'est parti.
  • Le bug de Camille a son test. « Refuse un export vide sans rien déposer » est l'incident de la mairie transformé en règle exécutable. C'est la meilleure source de tests d'un script existant : chaque incident en produit un, qui empêche le même de revenir. Le test des fins de ligne CRLF suit la même logique : le module csv de Python termine ses lignes par \r\n, et un en-tête comparé sans retirer le \r serait déclaré « inattendu » (leçon 4).
  • Rien ne vient du poste. PUBLIER_EXPORT_CONFIG pointe vers un fichier qui n'existe pas : un /etc/signalements/publier-export.conf présent sur la machine du développeur ne change pas le résultat. PUBLIER_EXPORT_DESTINATION, VERBEUX et FAUX_AWS_CODE sont effacés, HORODATER=0 rend les messages comparables.
  • La date est passée en option. Aucun test ne dépend du jour où il tourne. Un test qui utiliserait la date du jour échouerait au premier passage de minuit, ou pire, réussirait par hasard.
  • La configuration est lue, pas exécutée. Le test de la configuration y place un X=$(touch pirate) et vérifie que le fichier pirate n'apparaît pas, la règle de la leçon 8. ShellCheck signale les apostrophes autour de $(touch pirate) (SC2016, Expressions don't expand in single quotes), et il a raison en général ; ici, le littéral est voulu, d'où la directive et sa justification.
  • Le verrou est pris par le test lui-même, sur son propre descripteur 8 : le script ouvre le fichier de son côté et trouve le verrou occupé. Pas de processus en arrière-plan, donc pas de course entre le test et le script.
  • Les répertoires de travail. compgen -G motif réussit si le motif correspond à au moins un fichier, d'où run ! : on vérifie que le piège EXIT de la leçon 10 a supprimé le .travail.*, y compris sur le chemin d'échec.
  • Le garde-fou de setup. Si quelqu'un renomme le répertoire des doublures, le vrai aws serait appelé, avec les clés éventuellement présentes sur le poste. La dernière ligne de setup fait échouer tous les tests dans ce cas.

Lancer les tests et lire un échec

$ bats tests/

Dans un terminal, Bats affiche une ligne par test, ✓ devant ceux qui passent, ✗ devant ceux qui échouent, puis un bilan, ici 15 tests, 0 failures. Son code de sortie est 0 si tout passe, 1 sinon. Quand la sortie n'est pas un terminal, ou quand la variable CI est définie (c'est le cas sur les runners de GitHub), Bats bascule de lui-même sur le format TAP (Test Anything Protocol, un format texte simple, ok 1 ... ou not ok 2 ... par ligne, que la plupart des outils de CI savent agréger) ; bats --tap le demande explicitement.

1..15
ok 1 publie l'archive et son empreinte sous AAAA/MM
ok 2 l'empreinte déposée vérifie l'archive déposée
...
ok 15 ne laisse aucun répertoire de travail, même en cas d'échec

Quand un test échoue, Bats indique le fichier, la ligne et la commande fautive, puis la sortie capturée. Avec run -3, si le code ne correspond pas, voici ce qu'affiche Bats 1.11.1 (en TAP) quand mourir a été appelée sans son -c :

not ok 1 refuse un export vide sans rien déposer
# (in test file tests/publier-export.bats, line 68)
#   `run -3 "$SCRIPT" -d 2026-10-07' failed, expected exit code 3, got 1
# publier-export : erreur : export vide : /tmp/bats-run-jJtUTS/test/1/exports/signalements-2026-10-07.csv

Les assertions de bats-assert encadrent leur diagnostic, ce qui permet de lire d'un coup ce qui était attendu et ce qui a été obtenu :

-- output does not contain substring --
substring : export vide
output    : publier-export : erreur : en-tête inattendu dans /tmp/bats-run-lUF2o0/test/5/exports/signalements-2026-10-07.csv :
--

Pour afficher quelque chose pendant un test qui réussit (une valeur intermédiaire, par exemple), écrivez sur le descripteur 3, que Bats relie au terminal : echo "# jour=$jour" >&3. Tout ce qui part sur la sortie standard ou d'erreur n'est montré qu'en cas d'échec.

Vérifier que les tests peuvent échouer

Une suite de tests qui n'échoue jamais ne prouve rien. Le moyen le plus simple de s'en assurer est de casser volontairement le script et de constater qu'un test le voit. Remplaçons par : le contrôle du fichier vide dans verifier_export :

  [[ -s $csv ]] || mourir -c "$EX_EXPORT" "export vide : $csv"

Relancés, les tests signalent un seul échec, « refuse un export vide sans rien déposer », avec le diagnostic de bats-assert montré plus haut. Et la raison est instructive : le script renvoie toujours 3, parce qu'un fichier vide n'a pas d'en-tête et tombe sur le contrôle suivant. Un test limité à run -3 aurait continué de passer ; c'est l'assertion sur le message qui détecte la régression. Cette technique a un nom, le test de mutation (mutation testing) : modifier le code exprès et vérifier que les tests « tuent » chaque mutant. Il existe des outils pour l'automatiser dans d'autres langages ; pour un script, quelques mutations à la main sur les contrôles critiques suffisent.

make verifier

Le Makefile du dépôt réunit les vérifications sous une seule commande, la même sur un poste et en CI :

# Vérifications de signalements-outils : « make verifier » avant chaque poussée.
SCRIPTS := $(wildcard bin/*) lib/commun.sh tests/doublures/aws

.PHONY: verifier analyser tester formater

verifier: analyser tester

analyser:
	shellcheck $(SCRIPTS) tests/*.bats

tester:
	bats tests/

formater:
	shfmt --diff -i 2 -ci -bn -sr $(SCRIPTS)

ShellCheck sait analyser les fichiers .bats (depuis sa version 0.7.0) : il comprend la syntaxe @test, sait que run définit $status, $output et $lines (mais pas $stderr : il signale SC2154 sur un $stderr produit par run --separate-stderr), et relève un piège propre à Bats, le ! qui ne fait pas échouer un test (SC2314, voir Pièges). Le formatage reste une cible séparée : on l'adopte d'un coup sur tout le dépôt (shfmt -w), dans un commit qui ne fait que cela, puis on l'ajoute à verifier. Sur le publier-export du cours, shfmt 3.8.0 avec ces options réécrirait par exemple (( $# > 0 )) en (($# > 0)) et alignerait autrement les branches du case : ce sont des choix de style, que l'outil tranche une fois pour toutes. Il détermine le dialecte d'après la ligne #! ou l'extension du fichier, et retient Bash à défaut ; -ln bash le force si besoin.

Le workflow d'intégration continue

.github/workflows/scripts.yml :

name: Scripts shell

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  verifier:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Installer Bats et ses bibliothèques
        run: |
          sudo apt-get update
          sudo apt-get install --yes --no-install-recommends bats bats-support bats-assert
      - name: Analyser et tester
        run: make verifier

L'image ubuntu-24.04 des runners de GitHub fournit déjà ShellCheck (0.9.0, le paquet d'Ubuntu) et Bash 5.2.21 ; Bats et ses bibliothèques s'installent depuis les paquets de la distribution. Le reste suit les règles du cours GitHub Actions : action épinglée par empreinte, permissions minimales, pas de jeton persistant (Sécuriser ses workflows). Le job ne reçoit aucun secret : les tests n'en ont pas besoin, et c'est ce qui permet de le lancer sans crainte sur les pull requests. Il reste à rendre la vérification obligatoire avant toute fusion sur main, dans les règles de protection de la branche.

L'état final du dépôt

signalements-outils/
├── .github/
│   └── workflows/
│       └── scripts.yml
├── bin/
│   ├── deployer
│   ├── publier-export
│   ├── purger-pieces-jointes
│   ├── rapport-journaux
│   └── verifier-sante
├── lib/
│   └── commun.sh
├── tests/
│   ├── doublures/
│   │   └── aws
│   └── publier-export.bats
├── .shellcheckrc
└── Makefile

La bibliothèque commune est celle de la leçon 6, inchangée depuis :

# shellcheck shell=bash
# lib/commun.sh : fonctions partagées par les outils de Signalements.
# Ce fichier se charge par « source » ; il ne s'exécute pas seul.

# Ne charger qu'une fois, même si plusieurs fichiers le demandent.
[[ -n ${_COMMUN_SH_CHARGE:-} ]] && return 0
readonly _COMMUN_SH_CHARGE=1

# Nom affiché dans les messages : celui du script principal, sauf s'il est déjà fixé.
NOM_OUTIL=${NOM_OUTIL:-${0##*/}}

# journaliser MESSAGE...
#   Écrit une ligne sur la sortie d'erreur, horodatée sauf si HORODATER vaut 0
#   (sous systemd, le journal horodate déjà chaque ligne).
journaliser() {
  if [[ ${HORODATER:-1} == 0 ]]; then
    printf '%s : %s\n' "$NOM_OUTIL" "$*" >&2
  else
    printf '%(%Y-%m-%dT%H:%M:%S%z)T %s : %s\n' -1 "$NOM_OUTIL" "$*" >&2
  fi
}

# deboguer MESSAGE...
#   Comme journaliser, seulement si VERBEUX vaut 1. Renvoie toujours 0.
deboguer() {
  [[ ${VERBEUX:-0} == 1 ]] || return 0
  journaliser "débogage : $*"
}

# avertir MESSAGE...
#   Signale un problème qui n'arrête pas le script.
avertir() {
  journaliser "attention : $*"
}

# mourir [-c CODE] MESSAGE...
#   Journalise une erreur et termine le script avec CODE (1 par défaut).
#   Attention : appelée dans $(...), elle ne termine que le sous-shell.
mourir() {
  local code=1
  if [[ ${1:-} == -c ]]; then
    code=$2
    shift 2
  fi
  journaliser "erreur : $*"
  exit "$code"
}

# exiger COMMANDE...
#   Vérifie que chaque commande est disponible ; sinon, les signale toutes et meurt.
exiger() {
  local commande manque=0
  for commande in "$@"; do
    if ! command -v -- "$commande" > /dev/null 2>&1; then
      avertir "commande introuvable : $commande"
      manque=1
    fi
  done
  (( manque == 0 )) || mourir "prérequis manquants, installez les commandes ci-dessus"
}

Et bin/publier-export en version 1.4.0, aboutissement du fil rouge : l'interface de la leçon 8, le mode strict et le piège ERR de la leçon 9, le verrou, les pièges de sortie et le répertoire de travail de la leçon 10, plus le \r final retiré de l'en-tête et une ligne de débogage sur le nombre de lignes. ShellCheck 0.9.0 et 0.10.0 n'y trouvent rien, et les quinze tests passent :

#!/usr/bin/env bash
# publier-export : dépose l'export CSV d'une journée dans le bucket de la mairie.
# Vérifie le fichier, le compresse, calcule son SHA-256, dépose les deux, puis
# garde une copie locale 30 jours. Codes de sortie : voir --help.

set -Eeuo pipefail
shopt -s inherit_errexit

REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh" || {
  printf '%s : impossible de charger lib/commun.sh\n' "${0##*/}" >&2
  exit 1
}

readonly VERSION=1.4.0
readonly EX_OK=0 EX_ERREUR=1 EX_USAGE=2 EX_EXPORT=3 EX_DEPOT=4 EX_VERROU=5
readonly EN_TETE='id,type,commune,date'
readonly POINT_ACCES=https://s3.fr-par.scw.cloud
readonly CONSERVATION_JOURS=30
# Surchargeables par l'environnement, pour les tests.
readonly CONFIGURATION=${PUBLIER_EXPORT_CONFIG:-/etc/signalements/publier-export.conf}
readonly REPERTOIRE_EXPORTS=${PUBLIER_EXPORT_REPERTOIRE:-/srv/donnees/exports}
readonly FICHIER_VERROU=${PUBLIER_EXPORT_VERROU:-/var/lib/signalements/publier-export.lock}

# Réglages (défauts) et état lu par les pièges : globaux, car les pièges
# s'exécutent après le retour de main.
date_export=""
destination=s3://sig-exports-mairie
simulation=0
csv=""
aws_s3=()
rep_travail=""
enfant=""

usage() {
  cat <<FIN
Usage : $NOM_OUTIL [OPTIONS]

Dépose l'export CSV d'une journée dans le bucket de la mairie,
avec son empreinte SHA-256.

Options :
  -d, --date AAAA-MM-JJ     journée à publier (défaut : aujourd'hui)
      --destination URL     préfixe S3 (défaut : $destination)
  -n, --dry-run             préparer et vérifier l'archive, sans rien déposer
  -v, --verbeux             messages détaillés sur la sortie d'erreur
  -h, --help                afficher cette aide et sortir
      --version             afficher la version et sortir

Configuration : $CONFIGURATION (clé=valeur : DESTINATION)
Environnement : PUBLIER_EXPORT_DESTINATION remplace la configuration.

Codes de sortie :
  0 succès                          3 export introuvable ou invalide
  1 erreur générale                 4 échec du dépôt
  2 mauvaise utilisation            5 une autre exécution est en cours

Exemple :
  $NOM_OUTIL --date 2026-10-07 --dry-run
FIN
}

erreur_usage() {
  printf '%s : %s\n' "$NOM_OUTIL" "$*" >&2
  printf "Essayez « %s --help » pour plus d'informations.\n" "$NOM_OUTIL" >&2
  exit "$EX_USAGE"
}

exiger_valeur() {
  (( $2 >= 2 )) || erreur_usage "l'option $1 attend une valeur"
}

lire_configuration() {
  local fichier=$1 cle valeur n=0
  [[ -e $fichier ]] || return 0
  [[ -r $fichier ]] || mourir -c "$EX_ERREUR" "configuration illisible : $fichier"
  while IFS='=' read -r cle valeur || [[ -n $cle ]]; do
    n=$((n + 1))
    [[ $cle =~ ^[[:space:]]*(#|$) ]] && continue
    case $cle in
      DESTINATION) destination=$valeur ;;
      *) avertir "$fichier:$n : clé inconnue « $cle », ignorée" ;;
    esac
  done < "$fichier"
}

# analyser_options ARGUMENTS...
#   Défauts, puis fichier, puis environnement, puis options ; remplit les
#   globales date_export, destination, simulation, VERBEUX, csv et aws_s3.
analyser_options() {
  lire_configuration "$CONFIGURATION"
  destination=${PUBLIER_EXPORT_DESTINATION:-$destination}
  while (( $# > 0 )); do
    case $1 in
      -h|--help)        usage; exit "$EX_OK" ;;
      --version)        printf '%s %s\n' "$NOM_OUTIL" "$VERSION"; exit "$EX_OK" ;;
      -n|--dry-run)     simulation=1 ;;
      -v|--verbeux)     VERBEUX=1 ;;
      -d|--date)        exiger_valeur "$1" "$#"; date_export=$2; shift ;;
      --date=*)         date_export=${1#*=} ;;
      -d?*)             date_export=${1#-d} ;;
      --destination)    exiger_valeur "$1" "$#"; destination=$2; shift ;;
      --destination=*)  destination=${1#*=} ;;
      -[nvh]?*)         set -- "${1:0:2}" "-${1:2}" "${@:2}"; continue ;;
      --)               shift; break ;;
      -?*)              erreur_usage "option inconnue : $1" ;;
      *)                break ;;
    esac
    shift
  done
  (( $# == 0 )) || erreur_usage "argument inattendu : $1"

  [[ -n $date_export ]] || date_export=$(date +%F)
  [[ $date_export =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \
    || erreur_usage "date invalide : « $date_export » (attendu : AAAA-MM-JJ)"
  date -d "$date_export" +%F > /dev/null 2>&1 \
    || erreur_usage "date inexistante : $date_export"
  [[ $destination =~ ^s3://[a-z0-9][a-z0-9.-]*(/.*)?$ ]] \
    || erreur_usage "destination invalide : « $destination » (attendu : s3://bucket/...)"
  destination=${destination%/}

  csv=$REPERTOIRE_EXPORTS/signalements-$date_export.csv
  aws_s3=(aws s3 cp --only-show-errors --endpoint-url "$POINT_ACCES")
  deboguer "date $date_export, destination $destination, simulation $simulation"
}

sur_erreur() {
  local code=$1 ligne=$2 commande=$3 i
  # Dans un sous-shell, sortir sans rien dire : le shell parent signalera l'échec.
  if (( BASH_SUBSHELL > 0 )); then
    exit "$code"
  fi
  avertir "échec inattendu (code $code) ligne $ligne : $commande"
  for (( i = 1; i < ${#FUNCNAME[@]} - 1; i++ )); do
    avertir "  dans ${FUNCNAME[i]}(), appelée ligne ${BASH_LINENO[i]}"
  done
  exit "$EX_ERREUR"
}

# Tolérante et idempotente : peut être appelée deux fois, ne s'arrête sur aucune erreur.
nettoyer() {
  trap - ERR
  set +e
  if [[ -n $enfant ]]; then
    kill -TERM "$enfant" 2>/dev/null
    wait "$enfant" 2>/dev/null
    enfant=""
  fi
  if [[ -n $rep_travail ]]; then
    rm -rf -- "$rep_travail"
    rep_travail=""
  fi
}

sur_sortie() {
  local code=$?
  nettoyer
  exit "$code"
}

sur_signal() {
  local signal=$1
  avertir "signal SIG$signal reçu : arrêt et nettoyage"
  nettoyer
  trap - "$signal" EXIT
  kill -s "$signal" "$$"
}

# lancer_interruptible COMMANDE...
#   Lance la commande en arrière-plan et l'attend avec wait, pour qu'un signal
#   soit traité sans attendre la fin de la commande.
lancer_interruptible() {
  local code=0
  "$@" &
  enfant=$!
  wait "$enfant" || code=$?
  enfant=""
  return "$code"
}

prendre_verrou() {
  exec {fd_verrou}>>"$FICHIER_VERROU"
  flock -n "$fd_verrou" \
    || mourir -c "$EX_VERROU" "une autre exécution est en cours (verrou $FICHIER_VERROU)"
}

compter_lignes() {
  local n
  n=$(tail -n +2 -- "$1" | wc -l)
  printf '%d\n' "$n"
}

verifier_export() {
  local csv=$1 premiere
  [[ -f $csv ]] || mourir -c "$EX_EXPORT" "export introuvable : $csv"
  [[ -s $csv ]] || mourir -c "$EX_EXPORT" "export vide : $csv"
  # read renvoie 1 sur une ligne sans saut de ligne final, mais la remplit quand même.
  IFS= read -r premiere < "$csv" || true
  # Le module csv de Python termine les lignes par CRLF : on retire le \r final.
  [[ ${premiere%$'\r'} == "$EN_TETE" ]] \
    || mourir -c "$EX_EXPORT" "en-tête inattendu dans $csv : $premiere"
}

deposer() {
  local fichier=$1 cible=$2 code=0 taille_locale taille_distante
  local seau=${cible#s3://}
  local cle=${seau#*/}
  seau=${seau%%/*}
  deboguer "dépôt de ${fichier##*/} dans $cible"
  lancer_interruptible "${aws_s3[@]}" "$fichier" "$cible" || {
    code=$?
    mourir -c "$EX_DEPOT" "échec du dépôt de ${fichier##*/} (aws, code $code)"
  }
  taille_locale=$(stat -c %s -- "$fichier")
  taille_distante=$(aws s3api head-object --endpoint-url "$POINT_ACCES" \
    --bucket "$seau" --key "$cle" --query ContentLength --output text) || {
    code=$?
    mourir -c "$EX_DEPOT" "dépôt de ${fichier##*/} invérifiable (aws, code $code)"
  }
  [[ $taille_distante == "$taille_locale" ]] \
    || mourir -c "$EX_DEPOT" "taille distante $taille_distante pour ${fichier##*/}, $taille_locale attendus"
}

publier() {
  local publies="$REPERTOIRE_EXPORTS/publies"
  local annee=${date_export:0:4} mois=${date_export:5:2}
  local nom="signalements-$date_export.csv.gz" nb
  local prefixe="$destination/$annee/$mois"

  prendre_verrou
  verifier_export "$csv"
  nb=$(compter_lignes "$csv")
  (( nb > 0 )) || mourir -c "$EX_EXPORT" "aucune ligne de données dans $csv"
  deboguer "$csv : lignes de données : $nb"

  # Restes d'une exécution tuée par SIGKILL : sûr, puisque l'on détient le verrou.
  find "$publies" -maxdepth 1 -name '.travail.*' -exec rm -rf -- {} +

  # Tout se prépare dans le système de fichiers de la destination.
  rep_travail=$(mktemp -d -p "$publies" .travail.XXXXXX)
  gzip -9 -c -- "$csv" > "$rep_travail/$nom"
  gzip -t -- "$rep_travail/$nom"
  ( cd -- "$rep_travail" && sha256sum -- "$nom" ) > "$rep_travail/$nom.sha256"

  if (( simulation )); then
    journaliser "simulation : $nom et $nom.sha256 seraient déposés dans $prefixe/"
    return 0
  fi

  # L'archive d'abord, l'empreinte ensuite : sa présence signale un dépôt complet.
  deposer "$rep_travail/$nom" "$prefixe/$nom"
  deposer "$rep_travail/$nom.sha256" "$prefixe/$nom.sha256"

  # Publication locale par renommage, puis purge par âge.
  mv -f -- "$rep_travail/$nom" "$publies/$nom"
  mv -f -- "$rep_travail/$nom.sha256" "$publies/$nom.sha256"
  find "$publies" -maxdepth 1 -name 'signalements-*.csv.gz*' \
    -mtime +"$CONSERVATION_JOURS" -delete
  journaliser "export du $date_export publié, lignes de données : $nb"
}

main() {
  trap 'sur_erreur "$?" "$LINENO" "$BASH_COMMAND"' ERR
  analyser_options "$@"
  exiger aws flock gzip sha256sum stat
  umask 027
  trap sur_sortie EXIT
  trap 'sur_signal INT' INT
  trap 'sur_signal TERM' TERM
  trap 'sur_signal HUP' HUP
  publier
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
  exit
fi

Comparez avec les onze lignes de Camille : chaque ajout répond à une panne réelle ou probable, et les plus importants sont maintenant vérifiés par un test. Quelques détails se lisent mieux après toute cette leçon :

  • analyser_options regroupe les quatre sources de réglages dans l'ordre de la leçon 8 : valeurs par défaut (en tête du fichier), fichier, environnement, options. Elle remplit les globales que lisent ensuite publier et les pièges.
  • Le piège ERR est posé en premier dans main, avant même l'analyse des options, et les autres pièges juste avant publier : un test ou un outil qui charge le fichier par source récupère les fonctions, sans les pièges ni le main.
  • exec {fd_verrou}>> dans prendre_verrou : si le répertoire du verrou n'existe pas, la redirection échoue ; sous set -e, Bash arrête alors le script avec le code 1 (sans set -e, et hors mode POSIX, il afficherait l'erreur et continuerait). Le piège ERR dit où, la pile d'appels dit par où.
  • Aucune directive SC2317 : grâce à la garde conditionnelle, ShellCheck ne juge pas les fonctions de piège inaccessibles (voir plus haut).

Sous le capot

Comment Bash produit la trace

La trace n'est pas une relecture du fichier : Bash l'écrit au moment où il s'apprête à exécuter une commande, avec les mots tels qu'ils seront passés au programme, après toutes les expansions de la leçon 2. Pour que la ligne reste lisible et sans ambiguïté, il remet des guillemets là où un mot en a besoin : un nom de fichier qui contient une espace apparaît 'photo du 12.jpg', un [ apparaît '['. On peut donc copier une ligne de trace et la rejouer telle quelle dans un terminal, ce qui est précieux pour isoler une commande fautive.

Trois conséquences pratiques :

  • PS4 est développée avant chaque ligne. Une substitution de commande dans PS4 ($(date +%T), par exemple) crée un sous-processus par commande tracée : la trace devient lente et pollue elle-même ce qu'elle observe. Préférez les variables ($EPOCHREALTIME donne l'heure avec les microsecondes, sans processus) ;
  • les niveaux d'imbrication se lisent au nombre de répétitions du premier caractère de PS4 : avec PS4='+ ', ++ pour une commande dans une substitution, +++ un niveau plus bas. Si vous commencez PS4 par une espace ou une lettre, ce repère devient illisible ;
  • les mots-clés composés sont tracés à leur manière : for x in ... apparaît à chaque tour de boucle, tel qu'il est écrit ; case $n in aussi, sans développer le mot ; [[ ... ]] avec ses opérandes développés ; (( ... )) après l'expansion des $ mais avant l'évaluation, si bien que (( n > 0 )) reste tracé (( n > 0 )) alors que (( $n > 0 )) devient (( 3 > 0 )). Les déclarations local sont tracées avec leur valeur ; les redirections et les définitions de fonctions n'apparaissent pas.

Comment ShellCheck raisonne sans exécuter

ShellCheck est écrit en Haskell. Il construit un arbre syntaxique du script avec son propre analyseur (qui accepte sh, bash, dash, ksh et busybox, d'où l'option --shell), puis parcourt cet arbre avec ses règles. Depuis la version 0.9.0, un graphe de flot de contrôle permet de suivre l'état des variables d'une commande à la suivante, et de savoir si un point du programme est atteignable : c'est ce graphe qui rend SC2086 sensible à la valeur des variables, et qui produit SC2317. L'analyse de flot de données peut se désactiver (extended-analysis=false, depuis la 0.10.0) sur des scripts très longs où elle coûte cher.

Cette approche a des limites structurelles, qu'il faut connaître pour ne pas surinterpréter un rapport vide :

  • ShellCheck ne connaît pas l'environnement : il ne sait pas si aws est installé, si /srv/donnees existe, ce que contiendra une variable lue dans un fichier ;
  • il ne suit pas les appels indirects de façon fiable : une fonction appelée par trap, par un nom contenu dans une variable, ou par xargs peut lui sembler morte ;
  • il ne connaît pas votre intention : un script qui supprime le mauvais répertoire avec des guillemets parfaits est, pour lui, un excellent script.

D'où la complémentarité avec les tests, qui, eux, exécutent.

Comment Bats exécute un fichier

Un fichier .bats n'est pas du Bash valide : @test "..." { n'existe pas dans le langage. Bats commence donc par le prétraiter. Dans bats-core, l'étape bats-preprocess repère chaque ligne @test et la remplace par la définition d'une fonction ordinaire dont le nom est dérivé du titre (test_ suivi du titre encodé : espaces en _, autres caractères en code hexadécimal). Le fichier devient un script Bash qui définit une fonction par test.

Ensuite, d'après la documentation, le fichier est évalué n + 1 fois pour n tests : une fois pour dénombrer les tests, puis une fois par test, chaque test tournant dans son propre processus. Ce processus active set -eET (sortie à la première erreur, pièges ERR et DEBUG/RETURN hérités par les fonctions), et installe des pièges ERR et EXIT qui notent la commande et la ligne en échec avant d'appeler teardown. C'est la mécanique des leçons 9 et 10, mise au service d'un cadre de test.

Ce modèle explique plusieurs comportements :

  • le code placé hors des blocs @test et des fonctions s'exécute à chaque évaluation, donc n + 1 fois : jamais d'effet de bord à cet endroit ;
  • une variable modifiée dans un test n'affecte pas les autres : chaque test part d'un processus neuf, après setup ;
  • run exécute sa commande dans un sous-shell : une variable affectée par une fonction appelée via run est perdue ensuite ;
  • set -e ne réagit pas à ! commande (règle du langage, leçon 9), donc un test qui « vérifie » par ! commande en milieu de bloc ne peut pas échouer ;
  • Bats attend la fermeture du descripteur 3 avant de conclure : un processus lancé en arrière-plan qui en hérite bloque la suite jusqu'à sa fin. La documentation recommande de le fermer (3>&-) pour tout processus long lancé depuis un test.

Pièges courants

Le ! qui ne teste rien. Dans un test Bats, ! grep -q erreur fichier suivi d'autres commandes ne fait jamais échouer le test, parce que set -e ignore une commande précédée de !. ShellCheck le signale, avec la gravité error (SC2314 : In Bats, ! does not cause a test failure. Use 'run ! ' (on Bats >= 1.5.0) instead.). Écrivez run ! grep -q erreur fichier, ou intégrez la négation dans la condition : [[ ! -s $fichier ]].

run dans un tube. run publier-export -n | grep simulation ne fait pas ce qu'on croit : le shell découpe le tube avant run, qui ne capture rien d'utile. La documentation de Bats le signale explicitement ; utilisez run puis une assertion sur $output, ou la fonction bats_pipe (Bats 1.10.0 et plus).

Un run sans vérification. run cmd réussit toujours, quel que soit le code de cmd. Un test qui s'arrête après run ne vérifie rien. Toujours run -N, run !, ou une assertion sur $status juste après.

Un test qui passe pour une mauvaise raison. Vérifier le code de sortie seul laisse passer une régression qui change la cause de l'erreur sans changer le code : c'est exactement ce que la mutation du contrôle « fichier vide » a montré. Associez code et message.

Des tests qui dépendent du monde. Date du jour, fuseau, contenu de /tmp, langue des messages des outils externes, présence du réseau : chaque dépendance non maîtrisée produit un test instable, qui échoue un jour sur dix et finit ignoré. Passez la date en option, les chemins et la configuration par des variables pointées dans $BATS_TEST_TMPDIR, et n'assertez pas sur les messages traduits des commandes externes (sha256sum --check affiche OK ou Réussi selon la langue : on teste son code avec --quiet).

La doublure contournée. Un script qui appelle /usr/bin/aws, ou qui réinitialise son PATH au démarrage, échappe à la doublure et appelle le vrai service pendant les tests. Le garde-fou dans setup le détecte pour aws ; la règle de conception reste de trouver les commandes par le PATH.

Une directive qui couvre tout le fichier. # shellcheck disable=SC2086 placé juste sous la ligne #! désactive SC2086 pour tout le script, alors qu'on visait souvent une ligne. Relisez la position des directives en revue.

Des versions différentes de ShellCheck. Ubuntu 24.04 et le runner ubuntu-24.04 fournissent la 0.9.0, Debian 13 la 0.10.0 ; une version plus récente ajoute des vérifications (SC2324 à SC2326 dans la 0.10.0, SC2327 à SC2332 dans la 0.11.0, dont SC2329 pour une fonction jamais appelée). Un script propre sur un poste peut échouer en CI, ou l'inverse. Fixez une version pour le projet (celle de la CI fait foi) et montez-la volontairement.

set -x oublié. Une trace laissée active dans un script planifié remplit le journal à chaque exécution, et y écrit tout ce que le script manipule. Ajoutez la recherche de set -x à la revue, ou mieux, n'activez la trace que par une option (--trace).

bash -x et la ligne #!. bash -x ./script lance Bash quel que soit l'interpréteur déclaré. Pour un script écrit pour sh, vous déboguez donc un autre langage ; utilisez sh -x ./script.

Sécurité

  • La trace divulgue les secrets. set -x affiche les arguments après expansion : un en-tête X-Auth-Token: $JETON apparaît en clair dans la trace, comme toute variable lue dans /etc/signalements/env. Sur un poste, c'est un écran ; dans une CI, c'est un journal conservé et souvent largement lisible ; GitHub ne masque que les secrets qu'il connaît, pas une valeur dérivée (un jeton échangé, une URL signée). Règles : jamais de set -x actif autour d'une manipulation de secret (le couper par { set +x; } 2>/dev/null ou tracer seulement les fonctions qui n'en manipulent pas, avec local -), un fichier de trace créé en 0600 et supprimé après usage, et aucun secret en argument de commande, ce qui protège aussi de ps (leçon 8).
  • PS4 exécute du code. PS4 est développée comme une invite, substitutions de commande comprises. Si un attaquant contrôle l'environnement d'un script qui active set -x, il peut faire exécuter une commande à chaque ligne tracée. C'est pourquoi Bash, depuis la version 4.4, n'importe plus PS4 de l'environnement quand il tourne en root (fichier NEWS de Bash). La même prudence s'applique à SHELLOPTS, que Bash lit au démarrage s'il est dans l'environnement (env SHELLOPTS=xtrace bash script active la trace) : un script lancé par sudo profite de la remise à zéro de l'environnement (env_reset), ne la désactivez pas.
  • Les tests ne touchent jamais un vrai service. Pas de clés d'Object Storage dans le job de tests, doublures pour toutes les commandes qui sortent de la machine, et un garde-fou qui fait échouer les tests si la doublure n'est pas celle qu'on appelle. Un test qui supprime de vrais objets « pour nettoyer » finira par en supprimer de précieux.
  • ShellCheck est un filet, pas un audit. Il attrape des fautes aux conséquences graves (SC2115 sur un rm -rf "$rep/"* dont la variable peut être vide, SC2086 et SC2046 qui ouvrent la porte à l'injection de motifs et d'options par des noms de fichiers), mais il ne juge pas l'usage d'eval, la confiance accordée à un fichier de configuration, ni les droits avec lesquels tourne le script. La revue de code reste indispensable.
  • Les outils s'installent comme du code. ShellCheck, Bats et shfmt exécutent ou lisent votre code avec vos droits. Installez-les depuis les paquets de la distribution, ou, pour une version précise, depuis une version publiée dont vous vérifiez l'empreinte ; jamais par un curl ... | bash. Dans le workflow, l'action checkout est épinglée par empreinte, les permissions sont en lecture seule et le job n'a aucun secret, ce qui le rend sûr pour les contributions externes (Sécuriser ses workflows).

En production

  • La CI est une barrière, pas un conseil. make verifier est une vérification obligatoire de la protection de main : aucun script n'arrive sur sig-outils sans être passé par ShellCheck et Bats. Un hook Git local (pre-commit) qui lance bash -n et ShellCheck sur les fichiers modifiés fait gagner un aller-retour, sans remplacer la CI, puisqu'on peut le contourner.

  • Reprendre un existant progressivement. Sur un dépôt de scripts hérités, la première analyse produit des centaines de constats. Commencez par shellcheck -S warning dans la CI, corrigez, puis descendez à info, puis à style. Les tests suivent la même logique : un test par incident passé et par comportement critique (ce qui sort de la machine, ce qui supprime), avant de viser la couverture.

  • Diagnostiquer en production sans improviser. Le service oneshot qui lance la publication ne doit jamais tourner sous set -x. Quand il échoue, on lit d'abord le journal (journalctl -u signalements-publication.service), on rejoue ensuite à la main sous le compte du service, en simulation et sur la date en cause : sudo -u signalements /opt/signalements/bin/publier-export -n -v -d 2026-10-01. Si cela ne suffit pas, l'option --trace écrit une trace dans un fichier privé, que l'on supprime une fois le problème compris.

  • Relire un script comme du code. En revue, une grille courte évite les oublis : le script dit-il ce qu'il fait (en-tête, --help) ? Ses codes de sortie sont-ils documentés et honnêtes ? Toutes les expansions sont-elles protégées, les listes dans des tableaux ? Que se passe-t-il si chaque commande externe échoue, si un fichier est vide, si deux exécutions se chevauchent, si on l'interrompt ? Les fichiers temporaires et les secrets sont-ils protégés et nettoyés ? Un test couvre-t-il le changement proposé ? Les désactivations de ShellCheck sont-elles justifiées ?

  • Un guide de style partagé. Le Google Shell Style Guide est une base raisonnable et publique : deux espaces d'indentation, 80 colonnes, messages d'erreur sur la sortie d'erreur, [[ ]] plutôt que [, $( ) plutôt que les accents graves, fonctions en minuscules et constantes en majuscules, une fonction main appelée par main "$@". Adoptez-le tel quel ou avec quelques écarts écrits ; l'essentiel est qu'il soit le même pour tout le dépôt, et vérifié par shfmt plutôt que par la mémoire des relecteurs.

  • Savoir quand quitter Bash. Le guide de Google fixe une limite volontairement basse : au-delà d'environ 100 lignes, ou dès que le flot de contrôle devient compliqué, réécrire dans un langage plus structuré. On peut la discuter, mais les critères derrière sont solides. Réécrivez en Python (ou en Go pour un outil distribué en binaire) quand le script doit :

    • manipuler des données structurées : JSON au-delà de quelques appels à jq, CSV avec des champs entre guillemets (leçon 5), structures imbriquées (leçon 7) ;
    • faire des calculs : décimaux, dates au-delà de date -d, statistiques ;
    • gérer finement les erreurs : relances avec délai croissant, erreurs partielles à agréger, transactions ;
    • appeler des API plutôt que des commandes : un SDK vaut mieux qu'un curl dont on analyse la sortie ;
    • grossir : plusieurs centaines de lignes, plusieurs personnes qui y contribuent, des tests unitaires de fonctions internes plutôt que des tests de bout en bout.

    Appliqué au dépôt : publier-export approche les 300 lignes, bien au-delà du seuil de Google, mais un bon tiers est fait d'aide, de commentaires et de gestion d'erreurs, et il reste en Bash parce qu'il orchestre des commandes (gzip, sha256sum, aws, flock) sans transformer de données ; c'est le terrain du shell. Il est à la limite : la prochaine fonctionnalité qui demanderait de manipuler des données (lire le CSV champ par champ, appeler une API) le ferait basculer. L'export lui-même, qui interroge sig-db et produit le CSV, est déjà en Python, et c'est le bon choix. Si demain rapport-journaux doit calculer des percentiles de temps de réponse et publier du JSON pour un tableau de bord, il changera de langage.

Exercices

1. Lire une trace (niveau 100). Voici la trace d'une exécution de verifier-sante :

+ hotes=(sig-app-1 sig-app-2)
+ for h in "${hotes[@]}"
++ curl --silent --output /dev/null --write-out '%{http_code}' --max-time 5 http://sig-app-1:8000/sante
+ code=200
+ [[ 200 == 200 ]]
+ for h in "${hotes[@]}"
++ curl --silent --output /dev/null --write-out '%{http_code}' --max-time 5 http://sig-app-2:8000/sante
+ code=000
+ [[ 000 == 200 ]]
+ echo 'sig-app-2 : injoignable'
sig-app-2 : injoignable

(a) Pourquoi certaines lignes commencent-elles par ++ ? (b) Que vaut code pour sig-app-2, et qu'en déduisez-vous sur la nature de la panne ? (c) Pourquoi la ligne sig-app-2 : injoignable n'a-t-elle pas de + ?

Solution

(a) Ce sont les commandes exécutées dans une substitution de commande (code=$(curl ...)) : un niveau d'imbrication de plus, donc le premier caractère de PS4 répété. La ligne + code=... qui suit est l'affectation, au niveau du script. (b) 000 : avec --write-out '%{http_code}', curl écrit 000 quand il n'a reçu aucune réponse HTTP (connexion refusée, délai dépassé, nom introuvable). La panne n'est donc pas une erreur de l'API (qui donnerait 500 ou 503) mais un problème de réseau, de résolution ou de service arrêté : on vérifiera systemctl status signalements sur sig-app-2, puis le pare-feu. (c) C'est la sortie de echo, pas une ligne de trace : la trace est la ligne précédente, + echo 'sig-app-2 : injoignable'.

2. Corriger ce que voit ShellCheck (niveau 100). Un collègue propose cette fonction pour purger-pieces-jointes. Sans exécuter ShellCheck, indiquez les constats qu'il fera (code et raison), puis corrigez.

purger() {
  local rep=$(lire_config repertoire)
  cd $rep
  rm -rf $rep/*
  if [ $? -eq 0 ]; then journaliser "purge faite"; fi
}
Solution
  • local rep=$(...) : SC2155, le code de lire_config est masqué par celui de local, toujours 0 ; si la lecture échoue, rep est vide et le script continue.
  • cd $rep : SC2086 ($rep non protégé) et, dans un script sans set -e, SC2164 (cd non vérifié).
  • rm -rf $rep/* : SC2086, et surtout SC2115 (Use "${var:?}" to ensure this never expands to /* .) : si rep est vide, la commande devient rm -rf /*.
  • if [ $? -eq 0 ] : SC2181.

Version corrigée :

purger() {
  local rep
  rep=$(lire_config repertoire) || mourir "configuration illisible"
  [[ -d $rep ]] || mourir "répertoire absent : $rep"
  if rm -rf -- "${rep:?}"/*; then
    journaliser "purge faite dans $rep"
  fi
}

Le cd disparaît (inutile), ${rep:?} arrête le script si la variable est vide, -- protège d'un nom commençant par un tiret. Et sur le fond : une purge des pièces jointes ne vide pas tout un répertoire, elle sélectionne par âge (leçon 5). ShellCheck rend le code sûr, pas juste.

3. Une option --trace sûre (niveau 200). Ajoutez à publier-export une option --trace FICHIER qui écrit une trace complète dans FICHIER (fichier, ligne et fonction sur chaque ligne), sans rien changer à la sortie d'erreur, avec un fichier lisible du seul propriétaire. Expliquez pourquoi l'activation doit avoir lieu dans analyser_options, et pas en tête de script. Comment vérifier, dans un test Bats, que la trace ne contient pas la valeur d'une variable d'environnement sensible comme AWS_SECRET_ACCESS_KEY ?

Solution

Dans la boucle de analyser_options, une branche de plus :

      --trace)          exiger_valeur "$1" "$#"; activer_trace "$2"; shift ;;

avec la fonction activer_trace de la pratique (umask 077 dans un sous-shell pour créer le fichier en 0600, exec 9>>, BASH_XTRACEFD=9, PS4 informatif, set -x). On ne peut pas l'activer en tête de script : le nom du fichier n'est connu qu'une fois les options lues. Conséquence : l'analyse des options elle-même n'est tracée qu'à partir de --trace, ce qui est acceptable. Le descripteur 9 ne gêne pas le verrou, que prendre_verrou ouvre sur un descripteur choisi par Bash ({fd_verrou}, 10 ou plus).

Test :

@test "--trace écrit la trace à part, sans le secret" {
  creer_export 2026-10-07 "1,nid-de-poule,Exempleville,2026-10-07"
  export AWS_SECRET_ACCESS_KEY="valeur-qui-ne-doit-pas-fuir"
  run --separate-stderr -0 "$SCRIPT" --trace "$BATS_TEST_TMPDIR/trace" -d 2026-10-07
  [[ -s $BATS_TEST_TMPDIR/trace ]]
  run ! grep -q "valeur-qui-ne-doit-pas-fuir" "$BATS_TEST_TMPDIR/trace"
  [[ $(stat -c %a "$BATS_TEST_TMPDIR/trace") == 600 ]]
}

Le script ne lit jamais AWS_SECRET_ACCESS_KEY lui-même (c'est aws qui la lit dans son environnement), donc la trace ne l'affiche pas ; le test le garantit pour l'avenir, le jour où quelqu'un écrirait --secret-key "$AWS_SECRET_ACCESS_KEY".

4. Tester verifier-sante (niveau 200). verifier-sante interroge http://<hôte>:8000/sante sur sig-app-1 et sig-app-2 par curl --fail --silent --max-time 5, affiche une ligne par hôte en panne et sort en 1 si au moins un hôte est en panne, 0 sinon. Écrivez une doublure de curl et trois tests : tout va bien ; sig-app-2 en panne ; les deux en panne.

Solution

tests/doublures/curl :

#!/usr/bin/env bash
# Doublure de curl : échoue comme curl --fail (code 22) pour les hôtes listés
# dans FAUX_CURL_EN_PANNE, réussit pour les autres.
url=${*: -1}
hote=${url#http://}
hote=${hote%%:*}
if [[ " ${FAUX_CURL_EN_PANNE:-} " == *" $hote "* ]]; then
  exit 22
fi
echo '{"etat":"ok"}'

tests/verifier-sante.bats :

#!/usr/bin/env bats

bats_require_minimum_version 1.5.0

setup() {
  bats_load_library bats-support
  bats_load_library bats-assert
  SCRIPT="$BATS_TEST_DIRNAME/../bin/verifier-sante"
  PATH="$BATS_TEST_DIRNAME/doublures:$PATH"
  unset FAUX_CURL_EN_PANNE
  [[ $(command -v curl) == "$BATS_TEST_DIRNAME/doublures/curl" ]]
}

@test "réussit quand les deux machines répondent" {
  run -0 "$SCRIPT"
}

@test "échoue et nomme la machine en panne" {
  FAUX_CURL_EN_PANNE="sig-app-2" run -1 "$SCRIPT"
  assert_output --partial "sig-app-2"
  refute_output --partial "sig-app-1"
}

@test "nomme toutes les machines en panne" {
  FAUX_CURL_EN_PANNE="sig-app-1 sig-app-2" run -1 "$SCRIPT"
  assert_output --partial "sig-app-1"
  assert_output --partial "sig-app-2"
}

Le code 22 est celui que curl renvoie avec --fail sur une réponse HTTP d'erreur ; la doublure l'imite pour que le script soit testé dans les conditions réelles. Le test « nomme toutes les machines » vérifie que le script ne s'arrête pas au premier hôte en panne.

5. Tester une purge sans attendre un an (niveau 300). purger-pieces-jointes a reçu à la leçon 8 l'interface [-j|--jours N] [-n|--dry-run] [-y|--oui] : il supprime, dans le répertoire PURGER_REPERTOIRE (par défaut /srv/donnees/pieces-jointes), les fichiers modifiés il y a plus de N jours (365 par défaut, de 30 à 3650), y compris ceux dont le nom contient des espaces ; il ne supprime rien en simulation, et sans --oui il refuse d'agir quand personne n'est là pour confirmer (code 2). Écrivez les tests, en expliquant comment vous fabriquez des fichiers « anciens » et quelles limites a cette approche.

Solution
#!/usr/bin/env bats

bats_require_minimum_version 1.5.0

setup() {
  bats_load_library bats-support
  bats_load_library bats-assert
  SCRIPT="$BATS_TEST_DIRNAME/../bin/purger-pieces-jointes"
  export PURGER_REPERTOIRE="$BATS_TEST_TMPDIR/pj"
  export HORODATER=0
  mkdir -p "$PURGER_REPERTOIRE"
  touch -d "400 days ago" "$PURGER_REPERTOIRE/ancienne.jpg" \
                          "$PURGER_REPERTOIRE/photo du 12 mars.jpg"
  touch -d "40 days ago" "$PURGER_REPERTOIRE/moyenne.jpg"
  touch -d "10 days ago" "$PURGER_REPERTOIRE/recente.jpg"
}

@test "supprime les fichiers de plus de 365 jours, espaces compris" {
  run -0 "$SCRIPT" --oui
  [[ ! -e $PURGER_REPERTOIRE/ancienne.jpg ]]
  [[ ! -e "$PURGER_REPERTOIRE/photo du 12 mars.jpg" ]]
  [[ -e $PURGER_REPERTOIRE/moyenne.jpg ]]
  [[ -e $PURGER_REPERTOIRE/recente.jpg ]]
}

@test "--jours règle le seuil" {
  run -0 "$SCRIPT" --jours 30 --oui
  [[ ! -e $PURGER_REPERTOIRE/moyenne.jpg ]]
  [[ -e $PURGER_REPERTOIRE/recente.jpg ]]
}

@test "--dry-run ne supprime rien et annonce ce qu'il ferait" {
  run -0 "$SCRIPT" --dry-run
  assert_output --partial "ancienne.jpg"
  [[ -e $PURGER_REPERTOIRE/ancienne.jpg ]]
  [[ -e "$PURGER_REPERTOIRE/photo du 12 mars.jpg" ]]
}

@test "sans --oui et sans terminal, refuse et ne supprime rien" {
  run -2 "$SCRIPT" < /dev/null
  assert_output --partial "relancez avec --oui"
  [[ -e $PURGER_REPERTOIRE/ancienne.jpg ]]
}

@test "refuse un nombre de jours invalide" {
  run -2 "$SCRIPT" --jours zéro --oui
  run -2 "$SCRIPT" --jours 3 --oui
  [[ -e $PURGER_REPERTOIRE/recente.jpg ]]
}

touch -d "400 days ago" fixe la date de modification, celle que lit find -mtime. Le fichier au nom contenant des espaces est le bug historique de Camille : il a son test. Le test sans --oui redirige l'entrée depuis /dev/null : lancé depuis un terminal, Bats laisse l'entrée standard au terminal, et le script poserait sa question au lieu de refuser, ce qui bloquerait la suite de tests. Le dernier test vérifie aussi --jours 3, la faute de frappe que la borne de 30 jours de la leçon 8 doit arrêter. Limites : on teste la date de modification, pas la date métier du signalement (si la règle RGPD porte sur la date de création du signalement, c'est en base qu'il faut la lire) ; et les seuils sont vérifiés loin de la frontière (400, 40 et 10 jours). Un test à 365 et 366 jours vérifierait l'arrondi de -mtime, qui compte en périodes entières de 24 heures depuis l'instant présent : c'est précisément le genre de détail qu'un test aux bornes révèle.

6. Bash ou Python (niveau 300). L'équipe veut faire évoluer rapport-journaux : au lieu de compter les codes HTTP par hôte, il devra calculer, par heure, les 50e, 95e et 99e percentiles du temps de réponse, détecter les heures anormales par rapport à la moyenne des sept jours précédents, et publier le résultat en JSON vers une API interne avec relance en cas d'échec. Argumentez la décision de langage avec les critères de la leçon, et proposez un découpage qui garde le shell là où il est utile.

Solution

Tous les critères pointent vers Python : calculs (percentiles, moyennes glissantes, flottants que Bash ne sait pas manipuler), état sur sept jours (à stocker et relire), données structurées en sortie (JSON), appel d'API avec gestion fine des erreurs (relances avec délai croissant, distinction des erreurs définitives et temporaires). En Bash, chaque point demande un outil externe (awk, jq, curl) et une colle fragile entre eux, difficile à tester autrement que de bout en bout.

Découpage : un module Python signalements.rapport, testé avec pytest (tests unitaires sur les calculs, doublure de l'API), qui lit les journaux et publie. Le shell garde ce qu'il fait bien : l'unité systemd (oneshot sous un compte de service, minuteur, OnFailure=), et éventuellement un court script d'enveloppe qui choisit les fichiers à lire, prend un verrou et appelle le module. La règle générale : Bash orchestre des programmes, il ne remplace pas un langage pour traiter des données.

Récapitulatif

  • Quatre outils, quatre questions : bash -n (syntaxe), ShellCheck (constructions fautives, sans exécuter), set -x (ce que Bash a fait lors d'une exécution), Bats (comportements vérifiés à chaque modification).
  • Trace : bash -x script ou set -x ; commandes après expansion, sans les redirections ; PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' entre apostrophes ; BASH_XTRACEFD pour l'envoyer dans un fichier ; local - pour tracer une fonction ; { set +x; } 2>/dev/null pour couper sans bruit.
  • Secrets : la trace affiche les valeurs ; fichier de trace en 0600, jamais de trace autour d'un secret, rien de sensible en argument.
  • ShellCheck : codes SCxxxx documentés sur le wiki, gravités error > warning > info > style (-S), code de sortie utilisable en CI ; directives disable au plus près avec justification, source=../lib/commun.sh pour la bibliothèque ; .shellcheckrc à la racine avec à la fois external-sources=true et source-path=SCRIPTDIR, plus les enable= retenus.
  • Bats : @test, run avec $status, $output, $lines ; run -N, run ! et --separate-stderr avec bats_require_minimum_version 1.5.0 ; setup/teardown ; $BATS_TEST_TMPDIR ; bats-assert pour des messages d'échec lisibles.
  • Tests utiles : un comportement par test, code et message, effets vérifiés, un test par incident, aucune dépendance au monde (date, réseau, /tmp) ; doublures placées en tête du PATH, avec un garde-fou.
  • Vérifier que les tests peuvent échouer : casser exprès un contrôle (test de mutation).
  • make verifier sur le poste et en CI ; workflow minimal, épinglé, sans secret, obligatoire avant fusion.
  • Quitter Bash quand le script traite des données plutôt qu'il n'orchestre des commandes.

Pour aller plus loin

  • Le wiki de ShellCheck, page par page : chaque code a ses exemples et ses exceptions ; la page Directive et la page Optional pour la configuration d'un projet.
  • La documentation de bats-core, en particulier Writing tests (run, bats_pipe, les accroches de suite setup_suite) et Gotchas.
  • Le Google Shell Style Guide, à lire en entier une fois, puis à adopter ou à amender par écrit pour l'équipe.
  • La page Debugging du BashGuide de Greg's Wiki, et la BashFAQ/105 sur les limites de set -e, à relire maintenant que vous voyez comment Bats s'en sert.
  • Le cours CI/CD : les principes, et sa leçon Tests et qualité dans le pipeline, pour situer ces vérifications dans une chaîne de livraison complète ; et l'outil actionlint, qui passe aussi ShellCheck sur les blocs run: de vos workflows.
  • Le cours Python : les fondamentaux, pour le jour où un de vos scripts franchira la ligne.
+30 XP Carte du ciel →Mon cosmonaute →

Sources