Files
oto-enterprise-os-dtp/ci/check_mobile_workflow.sh
T
Claude Code DTP Worker b7ffb6448f [DTP-Worker 20260805_001109] Nouveau gate check-mobile-workflow : la structure de mobile-build.yml devient opposable au merge (surface mutation-testée UNGATED, 1re couverture) → 32→33 jobs
- ci/check_mobile_workflow.sh (8e gate statique) · 4 invariants lus dans le fichier (#6) :
  MOB-1 bien-formé (YAML strict si PyYAML) · MOB-2 aucun secrets. en if: de job
  (portabilité act/Gitea, verrouille le correctif 234104) · MOB-3 builds gatés sur
  needs.preflight.outputs.has_token (activation différée #6/#8) · MOB-4 contrat outputs
  (déclarés==référencés==alimentés, 0 dangling).
- Preuve UNGATED avant ajout : réintroduire secrets. en if: de job → 7/7 gates statiques verts.
- Gate mutation-testé : 5 mutations toutes ROUGES, fichier propre re-VERT.
- Câblé dans ci.yml + gate.needs ; check-ci-integrity INV-A/B verts ; run_ci auto → 33 PASS.
- ci/README.md : ligne §1 + détail §2 + nuance « Second workflow ».
- 0 nouveau module · 0 chiffre inventé · 0 edition prod hors CI · aucune commande VPS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-05 00:21:58 +00:00

173 lines
9.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# ============================================================================
# check_mobile_workflow.sh · OTO Enterprise OS DTP
# ----------------------------------------------------------------------------
# Intégrité du SECOND workflow Gitea Actions : `.gitea/workflows/mobile-build.yml`
# (EAS build iOS/Android · Sprint 5 l.56 · DIRECTIVE_MOBILE_STORES_20260803).
#
# POURQUOI ce gate. `mobile-build.yml` est HORS `gate.needs` (aucun de ses jobs
# ne conditionne un merge — il s'active seulement quand Michel fournit
# `EAS_TOKEN`). Conséquence : AUCUN gate ne lintait sa structure —
# `check_ci_integrity.sh` ne verrouille QUE `ci.yml`, `validate_json.sh` ne
# couvre que le `.json`. Une régression y passait donc VERTE en silence (mutation
# vérifiée : réintroduire `secrets.` dans un `if:` de job → 7/7 gates statiques
# restaient verts). Or ce workflow porte un correctif de PORTABILITÉ subtil
# (session 234104) qui, s'il régresse, ne casse RIEN au CI mais fait échouer les
# builds « le jour où on en a besoin » — exactement le « marche dans ma tête,
# casse en prod » que la série anti-dérive traque. Ce gate rend ces invariants
# opposables, en lisant des FAITS dans le fichier (jamais une liste à la main).
#
# INVARIANTS (tous re-dérivés du fichier · #6) :
# [MOB-1] Bien-formé : le fichier existe, porte `name:`/`on:`/`jobs:` et les
# 3 jobs attendus (preflight, build-ios, build-android). Si PyYAML est
# présent, le parse YAML strict est EXIGÉ ; sinon contrôle structurel
# awk seul (runner Gitea sans pip → best-effort, jamais un faux vert :
# les invariants portants MOB-2/3/4 ne dépendent pas de PyYAML).
# [MOB-2] Portabilité du gating (le correctif 234104) : AUCUN `if:` au niveau
# JOB ne référence le contexte `secrets.` — non exposé dans
# `jobs.<id>.if` par GitHub Actions ; sur `act`/Gitea il s'évalue vide
# → le job resterait SKIP en silence même EAS_TOKEN fourni.
# [MOB-3] Gating d'activation préservé (#6/#8) : chaque job de BUILD (bloc qui
# contient `eas build`) porte un `if:` job-level gaté sur
# `needs.preflight.outputs.has_token` → SKIP tant que le token absent,
# donc le workflow ne rend JAMAIS le CI rouge avant activation Michel.
# [MOB-4] Contrat d'outputs intact : `preflight` déclare les outputs
# (`has_token`, `has_repo`), chaque `needs.preflight.outputs.<X>` cité
# dans un `if:` est bien déclaré (0 dangling → pas de SKIP par output
# fantôme), et chaque output déclaré est ALIMENTÉ par un
# `echo "<X>=… >> $GITHUB_OUTPUT"` dans un step.
#
# Dépendances : bash, git, awk, grep, sort. python3+PyYAML utilisé si présent
# (best-effort). Aucun réseau (CLAUDE.md #2 · runner Gitea sans pip).
# ============================================================================
set -uo pipefail
# shellcheck source=ci/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" || exit 3
cd_repo_root # racine du dépôt, ou ROUGE honnête si hors arbre git (cf. lib.sh)
WF=".gitea/workflows/mobile-build.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 "$WF" ]]; then
report "workflow mobile introuvable : $WF"
echo -e "\n\033[31m❌ Mobile workflow absent.\033[0m"; exit 1
fi
# --- Extraction (awk · même philosophie de parseur minimal que check_ci_integrity)
# Un job = clé `^ <nom>:` à 2 espaces exactement, sous la ligne `jobs:`.
JOBS="$(awk '
/^jobs:[[:space:]]*$/ { injobs=1; next }
injobs && match($0, /^ ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { print m[1] }
' "$WF" | sort)"
# if: au niveau JOB = ligne indentée à EXACTEMENT 4 espaces ` if:` (un if: de
# step est plus profond ; un secrets. en `env:`/step est légitime — hors scope).
JOB_IF_SECRETS="$(grep -nE '^ if:.*secrets\.' "$WF" || true)"
# Jobs de BUILD = ceux dont le bloc lance une commande `run: eas build` (une
# mention `eas build` en COMMENTAIRE ne compte pas — sinon un `# … eas build …`
# placé entre deux jobs serait attribué au job précédent).
BUILD_JOBS="$(awk '
/^jobs:[[:space:]]*$/ { injobs=1; next }
injobs && match($0, /^ ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next }
injobs && job != "" && /run:[[:space:]]*eas build/ { print job }
' "$WF" | sort -u)"
# Pour chaque job, sa (ses) ligne(s) if: job-level agrégée(s).
job_if() { # $1 = nom du job → imprime le contenu de son if: job-level
awk -v want="$1" '
/^jobs:[[:space:]]*$/ { injobs=1; next }
injobs && match($0, /^ ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; next }
injobs && job==want && match($0, /^ if:[[:space:]]*(.+)$/, m) { print m[1] }
' "$WF"
}
# --- MOB-1 : bien-formé ------------------------------------------------------
echo "== MOB-1 · $WF bien-formé (name/on/jobs + 3 jobs attendus) =="
for key in 'name:' 'on:' 'jobs:'; do
grep -qE "^${key}" "$WF" || report "clé top-level manquante : ${key%:}"
done
for j in preflight build-ios build-android; do
printf '%s\n' "$JOBS" | grep -qx "$j" || report "job attendu absent : $j"
done
# Parse YAML strict SI PyYAML dispo (best-effort · runner sans pip → info seule).
if python3 -c 'import yaml' 2>/dev/null; then
if python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$WF" 2>/tmp/mobyaml; then
ok "YAML strict valide (PyYAML) · jobs = [$(printf '%s' "$JOBS" | paste -sd, -)]"
else
report "YAML invalide (PyYAML) : $(tr '\n' ' ' </tmp/mobyaml)"
fi
else
ok "structure awk OK · jobs = [$(printf '%s' "$JOBS" | paste -sd, -)] (PyYAML absent → parse strict sauté, invariants portants MOB-2/3/4 indépendants)"
fi
# --- MOB-2 : portabilité du gating (aucun secrets. dans un if: de job) -------
echo "== MOB-2 · aucun \`secrets.\` dans un \`if:\` au niveau job (portabilité act/Gitea) =="
if [[ -n "$JOB_IF_SECRETS" ]]; then
while IFS= read -r ln; do
report "\`secrets.\` dans un if: de job (contexte indisponible → SKIP silencieux) : L${ln%%:*}"
done <<< "$JOB_IF_SECRETS"
else
ok "0 référence \`secrets.\` en if: de job (le correctif preflight-output 234104 tient)"
fi
# --- MOB-3 : gating d'activation préservé sur chaque job de build -----------
echo "== MOB-3 · chaque job de build gaté sur needs.preflight.outputs.has_token =="
if [[ -z "$BUILD_JOBS" ]]; then
report "aucun job de build (\`eas build\`) détecté — le workflow ne builderait rien"
fi
for j in $BUILD_JOBS; do
cond="$(job_if "$j")"
if [[ -z "$cond" ]]; then
report "job de build '$j' SANS if: job-level → builderait même sans EAS_TOKEN (#6/#8)"
elif ! grep -q 'needs\.preflight\.outputs\.has_token' <<< "$cond"; then
report "job de build '$j' non gaté sur needs.preflight.outputs.has_token → activation non contrôlée"
else
ok "job de build '$j' gaté sur needs.preflight.outputs.has_token (SKIP tant que token absent)"
fi
done
# --- MOB-4 : contrat d'outputs intact (déclarés · référencés · alimentés) ---
echo "== MOB-4 · contrat outputs preflight (déclarés == référencés == alimentés) =="
# Outputs DÉCLARÉS dans le bloc `outputs:` de preflight.
DECLARED="$(awk '
/^jobs:[[:space:]]*$/ { injobs=1; next }
injobs && match($0, /^ ([A-Za-z0-9_-]+):[[:space:]]*$/, m) { job=m[1]; inout=0; next }
injobs && job=="preflight" && /^ outputs:[[:space:]]*$/ { inout=1; next }
injobs && job=="preflight" && inout && match($0, /^ ([A-Za-z0-9_]+):/, m) { print m[1]; next }
injobs && job=="preflight" && inout && /^ [A-Za-z]/ { inout=0 }
' "$WF" | sort -u)"
# Outputs RÉFÉRENCÉS via needs.preflight.outputs.<X> n'importe où.
REFERENCED="$(grep -oE 'needs\.preflight\.outputs\.[A-Za-z0-9_]+' "$WF" | sed 's/.*outputs\.//' | sort -u)"
# Outputs ALIMENTÉS via echo "<X>=… >> $GITHUB_OUTPUT" dans un step.
FED="$(grep -oE '(^|[^A-Za-z0-9_])[A-Za-z0-9_]+=.*>> *"?\$GITHUB_OUTPUT' "$WF" \
| grep -oE '[A-Za-z0-9_]+=' | sed 's/=$//' | sort -u)"
for x in has_token has_repo; do
printf '%s\n' "$DECLARED" | grep -qx "$x" || report "output attendu non déclaré dans preflight.outputs : $x"
done
# Chaque output référencé doit être déclaré (0 dangling → pas de SKIP fantôme).
dangling="$(comm -23 <(printf '%s\n' "$REFERENCED") <(printf '%s\n' "$DECLARED"))"
for x in $dangling; do
report "needs.preflight.outputs.$x référencé mais NON déclaré (output fantôme → if: toujours faux)"
done
# Chaque output déclaré doit être alimenté par un step (sinon toujours vide).
unfed="$(comm -23 <(printf '%s\n' "$DECLARED") <(printf '%s\n' "$FED"))"
for x in $unfed; do
report "output '$x' déclaré mais jamais écrit dans \$GITHUB_OUTPUT (resterait vide)"
done
if [[ -z "$dangling$unfed" ]] && printf '%s\n' "$DECLARED" | grep -qx has_token; then
ok "outputs [$(printf '%s' "$DECLARED" | paste -sd, -)] déclarés · référencés · alimentés (0 dangling)"
fi
echo
if [[ "$FAIL" -eq 0 ]]; then
echo -e "\033[32m✅ mobile-build.yml intègre (gating portable · activation différée préservée).\033[0m"
else
echo -e "\033[31m❌ Dérive mobile-build.yml — corriger avant merge (risque de SKIP silencieux des builds).\033[0m"
fi
exit "$FAIL"