[DTP-Worker] Sprint 8 · buffer L75 · Gate intégrité câblage CI (ci/check_ci_integrity.sh) : gate.needs ⊇ tous jobs non-manuels + chaque ci/*.sh verrouillé au gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-31 01:06:36 +00:00
parent 76fb0e768d
commit 59542e8f3c
4 changed files with 245 additions and 2 deletions
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# ============================================================================
# check_ci_integrity.sh · OTO Enterprise OS DTP
# ----------------------------------------------------------------------------
# Intégrité du câblage du workflow CI Gitea (gate anti-dérive · Sprint 8 buffer).
#
# Le job d'agrégat `gate` est le SEUL verrou de merge : un check qui n'y figure
# pas dans `needs:` ne bloque RIEN, même rouge. Les couvertures existantes
# (audit_4big/registry, qa/regression/discovery) ne prouvent l'appartenance au
# gate QUE pour les jobs de MODULE (ceux qui portent un `working-directory:` sous
# 05_deliverables_mvp). Les gates STATIQUES (`constraints-guard`, `validate-json`,
# `check-docs`, `check-artifacts`, `check-regression` → scripts `ci/*.sh`) n'ont
# pas de working-directory : rien ne garantissait qu'ils alimentent le gate.
# Retirer l'un d'eux de `needs:` rendait le gate vert en le court-circuitant —
# même classe de dérive silencieuse que INV4 (orphan_tests_dirs), côté câblage CI.
#
# Ce gate prouve, par des FAITS lus dans ci.yml (jamais une liste à la main) :
# [INV-A] `gate.needs` == { tous les jobs définis } { gate } { jobs manuels }
# (un job manuel = gardé par `if: … workflow_dispatch …`, ex. e2e-baseline,
# légitimement hors du gate push/PR car il exige un serveur live).
# → aucun job oublié du gate (MISSING) ni référence fantôme (DANGLING).
# [INV-B] Chaque script `ci/*.sh` du dépôt est lancé par exactement un job
# (`run: bash ci/<script>`), et ce job figure dans `gate.needs`.
# → un nouveau gate statique non câblé, ou décâblé, casse le check.
#
# Dépendances : bash, git, awk, grep, sort. Aucun réseau (CLAUDE.md #2).
# ============================================================================
set -uo pipefail
cd "$(git rev-parse --show-toplevel)"
CI=".gitea/workflows/ci.yml"
FAIL=0
report() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAIL=1; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
if [[ ! -f "$CI" ]]; then
report "workflow introuvable : $CI"
echo -e "\n\033[31m❌ CI absent.\033[0m"; exit 1
fi
# --- Extraction (awk · parseur YAML minimal, cohérent avec q4lib/registry) ---
# On ne considère que ce qui suit la ligne `jobs:` (le bloc `on:` a lui aussi des
# clés à 2 espaces : push/pull_request/workflow_dispatch — à ne pas confondre
# avec des jobs). Un job = clé `^ <nom>:` à 2 espaces exactement. Pour chaque
# job on note s'il est MANUEL (son bloc contient `workflow_dispatch` dans un
# garde `if:`), le job du bloc `gate` et sa ligne `needs: [...]`, et les scripts
# `ci/*.sh` lancés par un `run:` (avec le job appelant).
ALL_JOBS="$(awk '
/^jobs:[[:space:]]*$/ { injobs=1; next }
injobs && match($0, /^ ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { print m[1] }
' "$CI" | sort)"
MANUAL_JOBS="$(awk '
/^jobs:[[:space:]]*$/ { injobs=1; next }
injobs && match($0, /^ ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next }
injobs && job != "" && /if:/ && /workflow_dispatch/ { print job }
' "$CI" | sort -u)"
GATE_NEEDS="$(awk '
/^jobs:[[:space:]]*$/ { injobs=1; next }
injobs && match($0, /^ ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next }
injobs && job=="gate" && match($0, /needs:[[:space:]]*\[(.*)\]/, m) {
n=split(m[1], a, ",")
for (i=1;i<=n;i++) { gsub(/[[:space:]]/,"",a[i]); if (a[i]!="") print a[i] }
}
' "$CI" | sort -u)"
# scripts référencés : "<job> <chemin ci/xxx.sh>"
SCRIPT_REFS="$(awk '
/^jobs:[[:space:]]*$/ { injobs=1; next }
injobs && match($0, /^ ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next }
injobs && job != "" && match($0, /bash (ci\/[A-Za-z0-9_./-]+\.sh)/, m) { print job, m[1] }
' "$CI" | sort -u)"
# --- INV-A : gate.needs == jobs gate manuels -----------------------------
echo "== INV-A · gate.needs couvre tout job non-manuel (aucun oubli / fantôme) =="
if [[ -z "$GATE_NEEDS" ]]; then
report "job 'gate' sans liste needs: [...] exploitable"
fi
# Ensemble attendu = tous les jobs sauf 'gate' lui-même et les jobs manuels.
EXPECTED="$(comm -23 \
<(printf '%s\n' "$ALL_JOBS" | grep -vx 'gate') \
<(printf '%s\n' "$MANUAL_JOBS") )"
# MISSING : attendus mais absents de needs → checks qui ne bloquent pas.
missing="$(comm -23 <(printf '%s\n' "$EXPECTED") <(printf '%s\n' "$GATE_NEEDS"))"
# DANGLING : cités dans needs mais non définis (typo / job renommé/supprimé).
dangling="$(comm -23 <(printf '%s\n' "$GATE_NEEDS") <(printf '%s\n' "$ALL_JOBS"))"
# MANUAL-IN-GATE : un job manuel dans needs bloquerait le gate sur push (jamais
# déclenché) — le gate resterait en attente. Anomalie à signaler.
manual_in_gate="$(comm -12 <(printf '%s\n' "$MANUAL_JOBS") <(printf '%s\n' "$GATE_NEEDS"))"
for j in $missing; do report "job '$j' défini mais ABSENT de gate.needs → il ne bloque pas le merge"; done
for j in $dangling; do report "gate.needs référence '$j' qui n'est PAS un job défini (typo / job supprimé)"; done
for j in $manual_in_gate; do report "job manuel '$j' présent dans gate.needs → gate en attente sur push/PR"; done
if [[ -z "$missing$dangling$manual_in_gate" ]]; then
njobs="$(printf '%s\n' "$ALL_JOBS" | grep -c .)"
nneeds="$(printf '%s\n' "$GATE_NEEDS" | grep -c .)"
nman="$(printf '%s\n' "$MANUAL_JOBS" | grep -c . || true)"
ok "gate.needs = $nneeds jobs = $njobs définis 1 (gate) $nman manuel(s) [$(printf '%s' "$MANUAL_JOBS" | paste -sd, -)]"
fi
# --- INV-B : tout ci/*.sh est câblé à un job, lui-même dans le gate ----------
echo "== INV-B · chaque gate statique ci/*.sh est lancé par un job du gate =="
# Jobs référençant chaque script (via SCRIPT_REFS).
while IFS= read -r script; do
[[ -z "$script" ]] && continue
jobs_for="$(printf '%s\n' "$SCRIPT_REFS" | awk -v s="$script" '$2==s {print $1}' | sort -u)"
if [[ -z "$jobs_for" ]]; then
report "$script — présent dans le dépôt mais lancé par AUCUN job CI (gate mort)"
continue
fi
# Chaque job qui lance ce script doit alimenter le gate.
notgated=""
for j in $jobs_for; do
printf '%s\n' "$GATE_NEEDS" | grep -qx "$j" || notgated="$notgated $j"
done
if [[ -n "$notgated" ]]; then
report "$script — lancé par [$(echo $jobs_for | tr ' ' ',')] mais job(s) hors gate.needs :$notgated"
else
ok "$script — lancé par [$(echo $jobs_for | tr ' ' ',')] · dans le gate"
fi
done < <(git ls-files 'ci/*.sh' | grep -vx 'ci/check_ci_integrity.sh')
echo
if [[ "$FAIL" -eq 0 ]]; then
echo -e "\033[32m✅ Câblage CI intègre (gate agrège tout job non-manuel · gates statiques verrouillés).\033[0m"
else
echo -e "\033[31m❌ Dérive de câblage CI — corriger gate.needs / jobs avant merge.\033[0m"
fi
exit "$FAIL"