From b9efcda318a914defe8fcdf0fb3c78e8aab197a8 Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 25 Aug 2026 02:56:47 +0200 Subject: [PATCH] fix(ci,#11835): une panne de la voie ombre ne retient plus une PR saine Second mecanisme de blocage par la phase ombre, distinct de celui ferme par #12868. Celui-la ne passe pas par les check-runs mais par le CODE DE RETOUR du job. Quand l'arbre n'est pas revenu a son etat apres la bascule base/HEAD, la voie ombre refuse de publier des verdicts -- c'est correct, ils porteraient sur un arbre dont on ne sait plus ce qu'il contient. Mais elle le faisait par `raise SystemExit(...)`, donc exit 1. Or le check du job s'appelle `Fast lane (ombre) -- N gardes, 1 checkout`, qui ne contient pas `advisory` : `pr_gate` le compte comme un defaut et BLOQUE la PR. Une panne de la voie ombre bloquait donc une PR saine -- exactement ce que la phase pilote promet de ne pas faire. Mesure : #12820 etait retenue par ce seul chemin (run 32774643069, 45 checks verts, aucun defaut propre), en plus de son `PR gate` perime. Correctif : en mode ombre, imprimer le diagnostic sur stderr et rendre 0. Le fail-closed est PRESERVE dans les deux modes -- aucun verdict n'est publie. Hors ombre, l'arret net est inchange. Tests, deux moities et un controle positif : - l'ombre rend 0 ET ne publie rien (un test qui ne verifierait que le code de retour laisserait passer un correctif publiant des verdicts faux) ; - hors ombre, `SystemExit` est toujours levee -- sans quoi un correctif rendant 0 dans les deux modes desarmerait la voie rapide le jour de sa bascule, en passant pour un deblocage. Les deux tests sont verifies par MUTATION : retirer le court-circuit ombre, puis le faire publier quand meme, font rougir le test dans les deux cas. Un test qui n'atteindrait pas le chemin passerait vert tout aussi bien. python -m pytest scripts/tests/test_fast_lane.py -q -> 41 passed Grain: MED/guard - lane myia-ai-01:CoursIA - prev: MED/guard #12868 See #11835 Co-Authored-By: Claude-Code --- scripts/ci/fast_lane.py | 26 ++++++++--- scripts/tests/test_fast_lane.py | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/scripts/ci/fast_lane.py b/scripts/ci/fast_lane.py index 613941e5ed..1128d345ea 100644 --- a/scripts/ci/fast_lane.py +++ b/scripts/ci/fast_lane.py @@ -354,11 +354,27 @@ def main(argv: list[str] | None = None) -> int: clean, dirt = tree_is_clean(swap) if not clean: # Arret net : les verdicts suivants porteraient sur un arbre - # dont on ne sait plus ce qu'il contient. - raise SystemExit( - "[fast-lane] l'arbre n'est PAS revenu a son etat apres la " - f"bascule -- aucun verdict ne sera publie.\n{dirt}" - ) + # dont on ne sait plus ce qu'il contient. On ne publie RIEN, + # dans les DEUX modes -- c'est la partie fail-closed, et + # elle ne bouge pas. + msg = ("[fast-lane] l'arbre n'est PAS revenu a son etat " + "apres la bascule -- aucun verdict ne sera publie." + "\n" + dirt) + if args.shadow: + # ... mais en OMBRE le job ne doit pas rougir pour + # autant. Le check du job s'appelle `Fast lane (ombre) + # -- N gardes, 1 checkout` : il ne contient pas + # `advisory`, donc `pr_gate` le compte comme un defaut + # et BLOQUE la PR. Une panne de la voie ombre bloque + # alors une PR saine -- exactement ce que la phase + # pilote promet de ne pas faire. Mesure du 2026-08-25 : + # #12820 etait retenue par ce chemin (run 32774643069) + # sans porter aucun defaut propre. + print(msg, file=sys.stderr) + print("[fast-lane] mode ombre : panne interne signalee," + " job non bloquant (exit 0).", file=sys.stderr) + return 0 + raise SystemExit(msg) print("[fast-lane] phase 2 : arbre restaure et verifie") # -- phase 3 : comparaisons delta --------------------------------------- diff --git a/scripts/tests/test_fast_lane.py b/scripts/tests/test_fast_lane.py index 72373e1735..17a3c2a018 100644 --- a/scripts/tests/test_fast_lane.py +++ b/scripts/tests/test_fast_lane.py @@ -140,6 +140,84 @@ def test_shadow_failure_cannot_block_the_required_pr_gate(): "rougir -- sinon le correctif a desarme le gate") assert fast_lane.conclusion_for(guard, 0, shadow=True) == "success" +# --------------------------------------------------------------------------- +# 2bis. Panne interne de la voie ombre -- ne publie rien, ne bloque pas +# --------------------------------------------------------------------------- + +def _arm_unrestorable_tree(monkeypatch, published): + """Amene `main()` jusqu'a l'arbre non restaure, sans depot reel. + + On neutralise ce qui precede (fichiers changes, execution des gardes, + appels git) et on force `tree_is_clean` a repondre faux -- la situation + exacte du run 32774643069 sur #12820. + """ + delta = [g for g in fast_lane.PILOT if g.delta_argv] + assert delta, ("le registre ne porte plus aucun garde delta : ce test " + "n'exerce plus rien, le corriger plutot que le supprimer") + cible = delta[0] + + monkeypatch.setattr(fast_lane, "changed_files", lambda _ref: ["x.ipynb"]) + monkeypatch.setattr(fast_lane, "guard_applies", lambda g, c: True) + monkeypatch.setattr(fast_lane, "run_argv", lambda argv, ctx: (0, "{}")) + monkeypatch.setattr(fast_lane, "run_iter", + lambda argv, paths, ctx: (0, "{}")) + monkeypatch.setattr(fast_lane, "git", + lambda *a: subprocess.CompletedProcess(a, 0, "", "")) + monkeypatch.setattr(fast_lane, "stale_added_paths", lambda _p: []) + monkeypatch.setattr(fast_lane, "tree_is_clean", + lambda _paths: (False, " M scripts/fantome.py")) + monkeypatch.setattr(fast_lane, "emit_check_run", + lambda *a, **k: published.append(a)) + return cible + + +def test_shadow_internal_failure_publishes_nothing_and_does_not_block( + monkeypatch, tmp_path): + """Une panne de la voie ombre ne doit pas retenir une PR saine. + + Le check du job s'appelle `Fast lane (ombre) -- N gardes, 1 checkout` : il + ne contient pas `advisory`, donc `pr_gate` le compte comme un defaut. Un + job rouge ici BLOQUE la PR, alors que la phase se declare observationnelle. + Mesure du 2026-08-25 : #12820 etait retenue par ce seul chemin. + + Les deux moities comptent : + - ne publie RIEN (le fail-closed est preserve : les verdicts porteraient + sur un arbre inconnu) ; + - rend 0 (l'ombre ne juge pas). + Un test qui ne verifierait que le code de retour laisserait passer un + correctif qui publierait des verdicts faux en silence. + """ + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + published = [] + _arm_unrestorable_tree(monkeypatch, published) + + rc = fast_lane.main(["--base-ref", "main", "--base-sha", "dead" * 10, + "--head-sha", "beef" * 10, "--pr-number", "12820", + "--repo", "jsboige/CoursIA"]) + + assert rc == 0, "en ombre, une panne interne ne doit pas rougir le job" + assert published == [], ( + "aucun verdict ne doit etre publie sur un arbre non restaure -- " + "le fail-closed prime sur le deblocage") + + +def test_non_shadow_internal_failure_still_stops_hard(monkeypatch, tmp_path): + """CONTROLE POSITIF : hors ombre, la panne doit toujours arreter net. + + Sans lui, un correctif qui rendrait 0 dans les DEUX modes desarmerait la + voie rapide le jour de sa bascule, et passerait pour un deblocage. + """ + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + published = [] + _arm_unrestorable_tree(monkeypatch, published) + + with pytest.raises(SystemExit) as exc: + fast_lane.main(["--base-ref", "main", "--base-sha", "dead" * 10, + "--head-sha", "beef" * 10, "--pr-number", "12820", + "--repo", "jsboige/CoursIA", "--no-shadow"]) + assert "PAS revenu" in str(exc.value) + assert published == [] + # --------------------------------------------------------------------------- # 3. Coherence du registre avec les workflows d'origine