#!/usr/bin/env bash # ============================================================================ # run_ci.sh · OTO Enterprise OS DTP · miroir LOCAL du gate CI # ---------------------------------------------------------------------------- # Reproduit en une commande, EN LOCAL, exactement l'ensemble de checks que le # gate de merge Gitea Actions agrège — sans runner, sans réseau (Sprint 8 · # DevOps « monitoring / regression exhaustifs »). # # POURQUOI ce script existe : chaque session rejouait à la main une boucle # ad-hoc « for g in guard_constraints validate_json … ; do bash ci/$g.sh ». Une # telle liste écrite à la main DÉRIVE en silence de ce que la CI enforce # réellement (un gate ajouté/retiré dans `.gitea/workflows/ci.yml` n'y était # jamais répercuté) — la même classe de « vert trompeur » que les gates du # dépôt traquent. Ce script n'a AUCUNE liste en dur : il DÉRIVE la liste des # checks depuis `gate.needs` de ci.yml (la source de vérité que # `ci/check_ci_integrity.sh` verrouille déjà) et exécute, pour chaque job, la # commande `run:` que ci.yml déclare, dans son `working-directory:`. Il ne peut # donc ni oublier ni inventer un check : ajouter un gate à la CI le fait # apparaître ici automatiquement, zéro édition. # # PÉRIMÈTRE : uniquement les jobs de `gate.needs` — ce qui exclut, par # construction et à dessein, les jobs manuels `workflow_dispatch` hors gate # (ex. `e2e-baseline`/`npm test`, qui exige Node) et le job `gate` lui-même. # Les jobs restants sont tous soit `bash ci/*.sh` (gates statiques), soit # `python3 -m unittest` (suites de module) → stdlib pur, aucune install pip, # aucune commande touchant le VPS. Le décompte exact (jobs / gates / suites) # est DÉRIVÉ de gate.needs et imprimé à l'exécution — jamais figé dans ce # commentaire, où il se périmerait en silence (anti-invention CLAUDE.md #6). # # CE N'EST PAS un gate (aucun nouveau `ci/*.sh`, aucune nouvelle assertion) : # c'est un LANCEUR de commodité. Son exactitude est STRUCTURELLE (il exécute ce # que ci.yml déclare), pas une re-transcription à protéger — inutile donc d'y # adjoindre un gate N+1 (CLAUDE.md #5). # # Usage : # bash run_ci.sh # exécute tout gate.needs (miroir CI complet) # bash run_ci.sh --static # seulement les gates statiques ci/*.sh (rapide) # bash run_ci.sh --list # liste les jobs qui SERAIENT lancés, sans exécuter # bash run_ci.sh --help # affiche cette aide et sort # # Sortie : une ligne PASS/FAIL par job + un récapitulatif. Code de sortie 0 ssi # tous les jobs passent (identique à la sémantique du gate de merge). # ============================================================================ set -uo pipefail usage() { cat <<'EOF' run_ci.sh · miroir LOCAL du gate CI (dérivé de gate.needs, sans liste en dur). Rejoue en une commande, sans runner ni réseau, l'ensemble de checks que le gate de merge Gitea Actions agrège. Usage : bash run_ci.sh exécute tout gate.needs (miroir CI complet) bash run_ci.sh --static seulement les gates statiques ci/*.sh (rapide) bash run_ci.sh --list liste les jobs qui SERAIENT lancés, sans exécuter bash run_ci.sh --help affiche cette aide et sort Sortie : une ligne PASS/FAIL par job + un récapitulatif. Code de sortie : 0 ssi tous les jobs passent · 1 si un job échoue · 2 option inconnue · 3 hors checkout git / ci.yml illisible. EOF } # --- aide : disponible AVANT toute résolution git (n'exige aucun checkout) --- case "${1:-}" in -h|--help) usage; exit 0 ;; esac # --- racine du dépôt (marche depuis n'importe quel sous-dossier) ------------- ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "run_ci.sh : hors d'un checkout git — impossible de localiser ci.yml." >&2 exit 3 } cd "$ROOT" || exit 3 CI_YML=".gitea/workflows/ci.yml" [ -f "$CI_YML" ] || { echo "run_ci.sh : $CI_YML introuvable." >&2; exit 3; } # Miroir de l'`env:` workflow de ci.yml — force le mode UTF-8 de Python pour # tous les jobs rejoués ici, de sorte que ce miroir local reproduise fidèlement # le gate même si le poste tourne sous une locale ASCII (LC_ALL=C). Sans cela, # les générateurs (print ✅/accents en fin de `build`) et les suites planteraient # ici alors qu'ils passent en CI (cf. ci.yml env PYTHONUTF8 · check_ci_integrity INV-C). export PYTHONUTF8=1 MODE="all" case "${1:-}" in --static) MODE="static" ;; --list) MODE="list" ;; "") MODE="all" ;; *) echo "run_ci.sh : option inconnue « $1 » (attendu : --static | --list | --help)" >&2; exit 2 ;; esac # --- parse ci.yml : gate.needs (ordre) + par job {working-directory, run} ---- # Les commentaires de ligne sont retirés d'abord (un « working-directory: » ou # « bash ci/… » cité en commentaire ne doit JAMAIS être capturé). Émet, sur # stdout, une ligne par job de gate.needs : "\t\t". parse_jobs() { awk ' { line=$0; sub(/^[[:space:]]*#.*$/, "", line) } # drop full-line comments /^jobs:[[:space:]]*$/ { inj=1 } # début d’un job : clé à exactement 2 espaces d’indentation inj && match(line, /^ ([a-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next } # gate.needs : la liste des jobs bloquants (source de vérité) inj && job=="gate" && match(line, /needs:[[:space:]]*\[(.*)\]/, g) { n=split(g[1], a, ","); for (i=1;i<=n;i++){ gsub(/[[:space:]]/,"",a[i]); if(a[i]!="") needs[++nn]=a[i] } } inj && job!="" && match(line, /^[[:space:]]+working-directory:[[:space:]]*(.+)$/, w) { wd[job]=w[1] } # commande run: (avec un argument — écarte la clé « run: » nue de defaults) inj && job!="" && match(line, /[[:space:]]run:[[:space:]]+(.+)$/, r) { cmd[job]=r[1] } END { for (i=1;i<=nn;i++){ j=needs[i]; printf "%s\t%s\t%s\n", j, (j in wd ? wd[j] : "-"), (j in cmd ? cmd[j] : "") } } ' "$CI_YML" } mapfile -t ROWS < <(parse_jobs) [ "${#ROWS[@]}" -gt 0 ] || { echo "run_ci.sh : gate.needs vide ou illisible dans $CI_YML." >&2; exit 3; } is_static() { case "$1" in bash\ ci/*) return 0 ;; *) return 1 ;; esac; } # --- décompte DÉRIVÉ (jamais figé) : gates statiques ci/*.sh vs suites module - # Ventilé depuis les mêmes ROWS que l'exécution, donc toujours exact : ajouter # un job à gate.needs le reclasse ici automatiquement, zéro édition (#6). n_gates=0; n_suites=0 for row in "${ROWS[@]}"; do IFS=$'\t' read -r _j _w _c <<<"$row" [ -n "$_c" ] || continue if is_static "$_c"; then n_gates=$((n_gates+1)); else n_suites=$((n_suites+1)); fi done n_jobs=$((n_gates + n_suites)) # --- --list : montre le plan, n’exécute rien -------------------------------- if [ "$MODE" = "list" ]; then printf '%-28s %-42s %s\n' "JOB" "WORKING-DIR" "RUN" for row in "${ROWS[@]}"; do IFS=$'\t' read -r job wd cmd <<<"$row" printf '%-28s %-42s %s\n' "$job" "$wd" "$cmd" done printf -- '-- %d jobs dérivés de gate.needs : %d gates statiques ci/*.sh + %d suites de module.\n' \ "$n_jobs" "$n_gates" "$n_suites" exit 0 fi # --- exécution --------------------------------------------------------------- LOGDIR="$(mktemp -d)" trap 'rm -rf "$LOGDIR"' EXIT pass=0; fail=0; skip=0; failed_jobs=() echo "run_ci.sh · miroir du gate CI (mode=$MODE) · $(git rev-parse --short HEAD 2>/dev/null || echo '?')" if [ "$MODE" = "static" ]; then echo "gate.needs dérivé : $n_jobs jobs · mode --static → $n_gates gates ci/*.sh seuls ($n_suites suites de module ignorées)" else echo "gate.needs dérivé : $n_jobs jobs = $n_gates gates ci/*.sh + $n_suites suites de module" fi echo "------------------------------------------------------------------------" for row in "${ROWS[@]}"; do IFS=$'\t' read -r job wd cmd <<<"$row" [ -n "$cmd" ] || { printf ' SKIP %-28s (aucune commande run: dans ci.yml)\n' "$job"; skip=$((skip+1)); continue; } if [ "$MODE" = "static" ] && ! is_static "$cmd"; then continue; fi log="$LOGDIR/$job.log" if [ "$wd" = "-" ]; then dir="$ROOT"; else dir="$ROOT/$wd"; fi if ( cd "$dir" && eval "$cmd" ) >"$log" 2>&1; then printf ' PASS %-28s %s\n' "$job" "$cmd"; pass=$((pass+1)) else printf ' FAIL %-28s %s\n' "$job" "$cmd"; fail=$((fail+1)); failed_jobs+=("$job") sed 's/^/ | /' "$log" | tail -n 12 fi done echo "------------------------------------------------------------------------" printf 'Récapitulatif : %d PASS · %d FAIL · %d SKIP\n' "$pass" "$fail" "$skip" if [ "$fail" -ne 0 ]; then printf 'Jobs en échec : %s\n' "$(IFS=', '; echo "${failed_jobs[*]}")" exit 1 fi echo "✅ Tous les checks du gate franchis en local." exit 0