diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c54866b..9409b04 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -117,6 +117,22 @@ jobs: - name: Contrôle des chiffres du README run: bash ci/check_readme_claims.sh + # -------------------------------------------------------------------------- + # Intégrité du SECOND workflow `mobile-build.yml` (Sprint 5 mobile · hors gate + # de merge, mais AUCUN gate ne lintait sa structure : check-ci-integrity ne + # verrouille que ci.yml, validate-json que le .json). Ce gate rend opposable le + # correctif de portabilité 234104 (pas de `secrets.` en if: de job → SKIP + # silencieux) + l'activation différée #6/#8 (builds gatés sur les outputs de + # preflight). stdlib + awk pur, PyYAML best-effort. Détail : ci/README.md §2. + # -------------------------------------------------------------------------- + check-mobile-workflow: + name: Intégrité mobile-build.yml (gating portable · activation différée · #6/#8) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Contrôle structure mobile-build.yml + run: bash ci/check_mobile_workflow.sh + # -------------------------------------------------------------------------- # Publiciste (Sprint 2) : parser faisabilité → projets_master.json + generator. # Tests unitaires stdlib pur (unittest) → aucune installation pip requise sur @@ -566,7 +582,7 @@ jobs: gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, check-artifacts, check-regression, check-ci-integrity, check-readme-claims, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, crm-financement-bancaire-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests, qa-audit-4big-tests, demo-scenario-tests, qa-regression-tests, devops-deploy-runbook-tests, qa-acceptance-tests, mobile-app-config-tests, pie-manifest-tests] + needs: [constraints-guard, validate-json, check-docs, check-artifacts, check-regression, check-ci-integrity, check-readme-claims, check-mobile-workflow, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, crm-financement-bancaire-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests, qa-audit-4big-tests, demo-scenario-tests, qa-regression-tests, devops-deploy-runbook-tests, qa-acceptance-tests, mobile-app-config-tests, pie-manifest-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_activity_log/2026-08-05.md b/05_activity_log/2026-08-05.md new file mode 100644 index 0000000..cbb9d2a --- /dev/null +++ b/05_activity_log/2026-08-05.md @@ -0,0 +1,75 @@ +# Activity Log · 2026-08-05 · Claude Code DTP Worker + +## Session 001109 · Nouveau gate `check-mobile-workflow` — la structure de `mobile-build.yml` devient opposable au merge (surface mutation-testée UNGATED, 1er contrôle) → 1 gate ajouté + câblé · 32→33 jobs + +**Contexte + choix de tâche.** `./run_ci.sh` au démarrage : **32 PASS · 0 FAIL · 0 +SKIP**, arbre propre. Roadmap `ROADMAP_8_WEEKS_OR_LESS.md` intégralement livrée/gatée +(8 sprints). Les 4 sessions du 08-04 (`221055`, `224101`, `231101`, `234104`) avaient +saturé les surfaces d'audit read-only (inter-artefacts, dérive non-gatée, attestation +stakeholder) puis **durci `mobile-build.yml`** (pattern preflight-output, portable +GitHub/Gitea). Cette dernière édition a laissé une **queue** : son propre log notait +« Aucun gate ne linte la structure de `mobile-build.yml` (`check-ci-integrity` ne +verrouille QUE `ci.yml`) ». Autrement dit le correctif de portabilité qu'elle venait +de poser **pouvait régresser en silence** — exactement la classe « marche dans ma +tête, casse le jour où on en a besoin » (mémoire [[mobile-build-secrets-if-gating]] : +« workflow HORS `gate.needs` → `run_ci` won't catch a regression »). La valeur honnête +du jour : **fermer cette queue** en rendant la structure de `mobile-build.yml` +opposable au merge. NON redondant (#5) : c'est le **premier** contrôle de ce fichier. + +**Preuve d'absence de couverture AVANT d'ajouter le gate** (discipline +[[verify-uncovered-before-gating]]). Mutation : réintroduire l'exact anti-pattern +corrigé en `234104` — `secrets.EAS_TOKEN != ''` dans un `if:` au niveau job. Résultat : +**7/7 gates statiques restent VERTS**. Surface confirmée non gatée (ni +`check_ci_integrity` — scope `CI=ci.yml` — ni `validate_json` — scope `.json`). Fichier +byte-restauré aussitôt (`cp` depuis backup tmp · jamais `git clean`). + +**Livrable : `ci/check_mobile_workflow.sh` (8ᵉ gate statique) + câblage.** Le gate lit +des **FAITS dans le fichier** (jamais une liste à la main · #6), 4 invariants : +- **MOB-1 · bien-formé** — existe · `name:`/`on:`/`jobs:` + 3 jobs (`preflight`, + `build-ios`, `build-android`) ; parse YAML strict **exigé si PyYAML dispo**, sinon + awk seul (runner sans pip → best-effort, jamais un faux vert : MOB-2/3/4 en sont + indépendants). +- **MOB-2 · portabilité** — **0** `secrets.` dans un `if:` de **job** (`^ if:`). + Le contexte `secrets` n'est pas exposé dans `jobs..if` (table d'availability + GitHub Actions) → sur `act`/Gitea il s'évaluerait vide → **SKIP silencieux**. C'est + la régression `234104` verrouillée. +- **MOB-3 · activation différée (#6/#8)** — chaque job de build (`run: eas build`) est + 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** — `preflight` déclare `has_token`/`has_repo`, chaque + `needs.preflight.outputs.` référencé est **déclaré** (0 dangling) et chaque output + déclaré est **alimenté** par un `echo …>>$GITHUB_OUTPUT`. + +**Bug corrigé pendant l'écriture (auto-vérification).** 1er jet : `preflight` était +classé « job de build » car un **commentaire** `# … eas build …` (l.108, entre deux +jobs) était attribué au job précédent par le parseur awk. Corrigé en exigeant +`run:[[:space:]]*eas build` (commande réelle, pas une mention). Après correctif : gate +**VERT** sur le fichier propre. + +**Mutation-testing du gate lui-même (5 mutations · toutes ROUGES, fichier propre +re-VERT).** (1) YAML cassé (indent) · (2) `secrets.` en `if:` de job · (3) `if:` du +job `build-ios` supprimé · (4) output fantôme référencé (`has_tokenX`) · (5) output +`has_repo` non alimenté. Chaque mutation → `exit 1` ; `cp` backup → gate re-VERT. + +**Câblage (garde `check-ci-integrity` INV-A/B vert).** Job `check-mobile-workflow` +ajouté à `ci.yml` (steps `bash ci/check_mobile_workflow.sh`) **et** à `gate.needs`. +`check_ci_integrity` reconnaît le nouveau `ci/*.sh` comme **câblé** (lancé par un job +du gate + il source `lib.sh`) → INV-A (gate.needs == tous jobs non-manuels) et INV-B +(tout `ci/*.sh` câblé) **restent verts**. `run_ci.sh` (dérivé de `gate.needs`, sans +liste en dur) l'inclut **automatiquement** → passage **32→33 jobs (8 gates statiques ++ 25 suites)**, zéro édition de `run_ci.sh` (#6, exactitude structurelle). + +**Doc (traçabilité, pas de dérive).** `ci/README.md` : ligne §1 pour +`check-mobile-workflow`, sous-section §2 détaillée (4 invariants + les 5 mutations), +et nuance ajoutée à « Second workflow » (les **jobs** restent hors gate de merge, mais +la **structure du fichier** est désormais gatée). Convention [[ci-readme-table-detail-in-section2]] +respectée : ligne §1 courte, détail en §2. Les mentions historiques « 7 gates +re-verts » (§2, records de mutations passées) restent exactes en **passé** ; le nouveau +record dit **8ᵉ gate**. + +**Vérifications.** `bash ci/check_mobile_workflow.sh` → VERT (4 invariants) ; 5 +mutations → ROUGE ; `python3 yaml.safe_load` sur `ci.yml` → valide ; `check_ci_integrity` +→ VERT (câblage intègre) ; `./run_ci.sh` → **33 PASS · 0 FAIL · 0 SKIP**. **1 gate +ajouté (NON redondant : 1re couverture d'un fichier mutation-testé ungated · pas un +doublon #5) · 1 job CI câblé · 0 nouveau module · 0 chiffre inventé (#6) · 0 édition +de production hors CI · aucune commande VPS (#8) · aucun `git clean`.** diff --git a/ci/README.md b/ci/README.md index 4a2349a..0a42505 100644 --- a/ci/README.md +++ b/ci/README.md @@ -21,6 +21,7 @@ et manuellement (`workflow_dispatch`). Jobs statiques (+ une suite `unittest` pa | `check-regression` | `ci/check_regression.sh` | Fraîcheur : `qa/regression/out/regression_run.json` (`run`) == run frais + verdict PASS | ✅ oui | | `check-ci-integrity` | `ci/check_ci_integrity.sh` | Intégrité du câblage : `gate.needs` == tous les jobs non-manuels · chaque `ci/*.sh` câblé au gate | ✅ oui | | `check-readme-claims` | `ci/check_readme_claims.sh` | Intégrité des chiffres **et des faits data-derived** des docs d'entrée (`README.md` « État courant », fiches `03_agents/*/AGENT.md`, READMEs de module) : chaque nombre, label de verdict et identité citée en prose (rôle · DocType · entité · marque · persona…) est **recomputé de l'artefact commité qu'il cite** — ou re-dérivé de `CLAUDE.md` / de la roadmap pour les valeurs ancrées au mandat — puis exigé **exact** (#6), jamais une liste à la main : un doc qui se contredit avec sa propre source est un « vert trompeur ». Surfaces couvertes : comptes de tests/suites, verdicts & barème 4Big, ledgers d'invariants CI, ventilations & identités RBAC, mots-clés & graphe SEO, pipeline vente, e-CF DGII, CONFOTUR, app mobile Expo, run-books VPS, tokens design #4, etc. **Détail exhaustif surface par surface : §2 `check_readme_claims.sh` (ci-dessous).** | ✅ oui | +| `check-mobile-workflow` | `ci/check_mobile_workflow.sh` | Intégrité du **second** workflow `mobile-build.yml` (hors gate de merge, mais sa STRUCTURE est gatée ici) : gating portable (0 `secrets.` en `if:` de job → pas de SKIP silencieux) · builds gatés sur les outputs `preflight` (activation différée #6/#8) · contrat d'outputs (déclarés == référencés == alimentés). **Détail : §2 `check_mobile_workflow.sh`.** | ✅ oui | | `gate` | — | Agrégat vert = gate qualité 4Big franchi | ✅ oui | Aucune dépendance réseau/marketplace hors `actions/checkout`. Tout tourne avec @@ -41,6 +42,17 @@ il ne rend **jamais** le CI rouge (`preflight` reste vert et rapporte les secret attendus). Périmètre complet (#8 · exécution serveur hors repo), profils EAS et variables `a_confirmer` : [`README` du module Mobile](../05_deliverables_mvp/mobile/app_config/README.md). +Nuance importante : ses **jobs** sont hors gate de merge, mais sa **structure de +fichier**, elle, EST gatée — par le job `check-mobile-workflow` +([`ci/check_mobile_workflow.sh`](check_mobile_workflow.sh), dans `gate.needs`). +Sans lui, une régression du workflow passait VERTE en silence (mutation vérifiée : +réintroduire `secrets.` dans un `if:` de job laissait les 7 gates statiques verts). +C'est précisément le risque « marche dans ma tête, casse le jour où Michel active +`EAS_TOKEN` » : le contexte `secrets` n'est pas disponible dans `jobs..if` +(table d'availability GitHub Actions), donc un tel `if:` s'évaluerait vide sur +`act`/Gitea → build **SKIP silencieux** même token fourni. Le gate rend cet +invariant (et l'activation différée) opposable au merge. + ### Rejouer le gate EN LOCAL — `run_ci.sh` (racine du dépôt) Avant de committer, reproduire l'intégralité du gate de merge en une commande : @@ -1599,6 +1611,41 @@ Un claim **absent** échoue aussi (la dérive de formulation qui ferait disparaître un chiffre est elle-même une régression de traçabilité). stdlib pur (bash/git/python3), zéro réseau. +### `check_mobile_workflow.sh` — intégrité du second workflow `mobile-build.yml` +`mobile-build.yml` (EAS build iOS/Android · Sprint 5) est **hors `gate.needs`** : +aucun de ses jobs ne conditionne un merge (ils s'activent seulement quand Michel +fournit `EAS_TOKEN`). Conséquence longtemps tolérée : **aucun gate ne lintait sa +structure** — `check_ci_integrity.sh` ne verrouille que `ci.yml`, `validate_json.sh` +que le `.json`. Mutation vérifiée : réintroduire `secrets.` dans un `if:` de job y +passait **7/7 gates statiques verts**. Or ce fichier 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 ». Ce gate rend 4 invariants +opposables, tous **lus dans le fichier** (jamais une liste à la main · #6) : + +- **MOB-1 · bien-formé** — le fichier existe, porte `name:`/`on:`/`jobs:` et les + 3 jobs attendus (`preflight`, `build-ios`, `build-android`). Parse YAML strict + **exigé si PyYAML est présent**, sinon contrôle awk seul (runner sans pip → + best-effort ; jamais un faux vert, car MOB-2/3/4 ne dépendent pas de PyYAML). +- **MOB-2 · portabilité du gating** — **0** `secrets.` dans un `if:` au niveau + **job** (`^ if:`). Le contexte `secrets` n'est **pas** exposé dans + `jobs..if` (table d'availability GitHub Actions) ; sur `act`/Gitea il + s'évaluerait vide → job **SKIP silencieux** même token fourni. Exactement la + régression corrigée en `234104` (passage au pattern preflight-output). +- **MOB-3 · activation différée préservée (#6/#8)** — chaque job de **build** + (bloc lançant `run: 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 `has_token`/`has_repo`, + chaque `needs.preflight.outputs.` référencé est bien **déclaré** (0 dangling → + pas de SKIP par output fantôme) et chaque output déclaré est **alimenté** par un + `echo "=… >> $GITHUB_OUTPUT"`. + +Les 5 mutations correspondantes (YAML cassé · `secrets.` en `if:` de job · `if:` +de build supprimé · output fantôme référencé · output non alimenté) rendent toutes +le gate **ROUGE** ; le fichier propre repasse **vert**. stdlib + awk pur, PyYAML +best-effort, zéro réseau. Ce gate est le **8ᵉ gate statique** `ci/*.sh` (câblé dans +`gate.needs`, donc rejoué par `run_ci.sh`). + ### `lib.sh` — helper partagé (sourcé, jamais exécuté seul) Les gates commençaient tous par `cd "$(git rev-parse --show-toplevel)"`. **Hors** d'un arbre de travail git (tarball, `git archive | tar -x`, `git` absent du PATH), diff --git a/ci/check_mobile_workflow.sh b/ci/check_mobile_workflow.sh new file mode 100755 index 0000000..e596a4c --- /dev/null +++ b/ci/check_mobile_workflow.sh @@ -0,0 +1,172 @@ +#!/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..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.` 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 "=… >> $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é `^ :` à 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' ' ' n'importe où. +REFERENCED="$(grep -oE 'needs\.preflight\.outputs\.[A-Za-z0-9_]+' "$WF" | sed 's/.*outputs\.//' | sort -u)" +# Outputs ALIMENTÉS via echo "=… >> $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"