#!/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 # # 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 # --- 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; } MODE="all" case "${1:-}" in --static) MODE="static" ;; --list) MODE="list" ;; "") MODE="all" ;; *) echo "run_ci.sh : option inconnue « $1 » (attendu : --static | --list)" >&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 '?')" echo "gate.needs dérivé : $n_jobs jobs = $n_gates gates ci/*.sh + $n_suites suites de module" 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