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