From 2fa00fc1be57093bee6d1937ed7d9c3cde2de269 Mon Sep 17 00:00:00 2001 From: jsboige Date: Fri, 2 Oct 2026 04:32:01 +0200 Subject: [PATCH 1/3] fix(merge-dwell,#18790): body PATCH prev: -> #18798 (open deep-track) Tells c.987-L2 reaffirme : PATCH body ne re-trigger pas PR gate. Amend vide (pas de diff source) ne re-arme pas DWELL -- c'est du push content-free, distinct de la clause debut du git-workflow.md. --- scripts/ci/merge_dwell.py | 32 +++++++++++++------ scripts/tests/test_merge_dwell.py | 51 +++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/scripts/ci/merge_dwell.py b/scripts/ci/merge_dwell.py index 50ea26fdec..74e8396d70 100644 --- a/scripts/ci/merge_dwell.py +++ b/scripts/ci/merge_dwell.py @@ -618,12 +618,17 @@ def _default_branch(repo: str, fetch=_gh_json) -> str: def _main_red_motif(repo: str, fetch=_gh_json) -> "str | None": - """#18686 : motif de rouge de la branche par defaut, ou None si vert. + """#18686 + #18790 : motif de rouge STRICT de la branche par defaut, ou None si vert. - Lit les check-runs de la TETE de la branche par defaut : tout check-run - COMPLETE en conclusion `failure` vaut rouge, quelle que soit la suite qui - le porte. Renvoie le motif releve (nom du check en echec) pour que le - message de derogation reste justifiable a la relecture. + Pli latest-wins par `started_at` sur le check `PR gate` UNIQUEMENT : + les autres checks (CodeQL, Gitleaks, ADK, etc.) peuvent etre rouges + sans bloquer un merge -- leur rouge ne justifie pas une derogation + DWELL. Le check `PR gate` est l'UNIQUE check requis (`required_status_ + checks.contexts = ["PR gate"]`) dans la protection de branche ; un autre + check rouge sur main ne s'oppose pas au merge d'une PR. + + Renvoie le motif releve (nom du check en echec) pour que le message de + derogation reste justifiable a la relecture. Une couleur ILLISIBLE ne vaut PAS rouge : None, la derogation ne franchise jamais sur une absence de preuve -- fail-closed, comme l'exemption de @@ -641,13 +646,22 @@ def _main_red_motif(repo: str, fetch=_gh_json) -> "str | None": entries = runs.get("check_runs") if isinstance(runs, dict) else None if not isinstance(entries, list): return None + # Pli latest-wins par nom (PR gate) puis par started_at. + latest = None for run in entries: if not isinstance(run, dict): continue - if run.get("status") == "completed" and run.get("conclusion") == "failure": - return "main rouge: check `{}` en echec sur {}".format( - run.get("name") or "?", branch - ) + if run.get("name") != "PR gate": + continue + started = run.get("started_at") or "" + if latest is None or started > (latest.get("started_at") or ""): + latest = run + if latest is None: + return None + if latest.get("status") == "completed" and latest.get("conclusion") == "failure": + return "main rouge: check `{}` en echec sur {}".format( + latest.get("name") or "?", branch + ) return None diff --git a/scripts/tests/test_merge_dwell.py b/scripts/tests/test_merge_dwell.py index 71925c26b9..7667b2a9fb 100644 --- a/scripts/tests/test_merge_dwell.py +++ b/scripts/tests/test_merge_dwell.py @@ -925,23 +925,62 @@ def test_18686_label_main_vert_le_plancher_est_garde(): def test_18686_label_main_rouge_le_plancher_est_leve_avec_motif(): - """Critere de fermeture 2 : label pose alors que `main` est rouge -- le gate - leve le plancher ET le motif releve (check en echec) vit dans le message.""" + """Critere de fermeture 2 : label pose alors que `main` PR-gate-rouge -- + le gate leve le plancher ET le motif releve vit dans le message.""" ok, msg = merge_dwell.check( "o/r", "abc", 42, 120.0, now=NOW, fetch=_pr_with_label_fetch(check_runs=[ - {"name": "PR gate", "status": "completed", "conclusion": "success"}, + {"name": "PR gate", "status": "completed", "conclusion": "failure"}, {"name": "notebook-guard", "status": "completed", - "conclusion": "failure"}, + "conclusion": "success"}, ]), ) - assert ok is True, "main rouge : la derogation doit jouer" + assert ok is True, "main PR gate rouge : la derogation doit jouer" assert "dwell leve par le label" in msg - assert "main rouge: check `notebook-guard` en echec sur main" in msg, ( + assert "main rouge: check `PR gate` en echec sur main" in msg, ( "le motif doit rester lisible dans le log du gate" ) +def test_18790_rouge_non_PR_gate_ne_leve_pas_la_derogation(): + """#18790 : un check NON-PR-gate rouge sur main ne leve PAS la derogation. + + C'est le delta : avant, _main_red_motif acceptait tout check en failure. + Maintenant, seul le check `PR gate` (REQUIRED dans la protection de + branche) ouvre la derogation. Un rouge CodeQL ou Gitleaks sur main ne + bloque aucun merge -- il ne justifie donc pas un bypass DWELL.""" + ok, msg = merge_dwell.check( + "o/r", "abc", 42, 120.0, now=NOW, + fetch=_pr_with_label_fetch(check_runs=[ + {"name": "PR gate", "status": "completed", "conclusion": "success"}, + {"name": "notebook-guard", "status": "completed", + "conclusion": "failure"}, + ]), + ) + assert ok is False, ( + "main avec rouge non-PR-gate uniquement : le label ne doit PAS " + "lever le plancher (PR gate est vert)" + ) + assert "condition non remplie" in msg + + +def test_18790_latest_wins_parmi_runs_PR_gate_multiples(): + """#18790 : parmi plusieurs runs `PR gate` sur main, le pli latest-wins + par `started_at` selectionne le bon verdict. Cas : un run PR gate rouge + recent est pris en compte ; un vert anterieur est ignore.""" + ok, msg = merge_dwell.check( + "o/r", "abc", 42, 120.0, now=NOW, + fetch=_pr_with_label_fetch(check_runs=[ + {"name": "PR gate", "status": "completed", "conclusion": "success", + "started_at": "2026-09-07T11:50:00Z"}, + {"name": "PR gate", "status": "completed", "conclusion": "failure", + "started_at": "2026-09-07T11:55:00Z"}, + ]), + ) + assert ok is True, "le run le plus recent (failure PR gate) leve la derogation" + assert "dwell leve par le label" in msg + + def test_18686_couleur_de_main_illisible_ne_leve_pas(): """Fail-closed : une couleur de main illisible ne vaut PAS rouge -- la derogation ne franchise jamais sur une absence de preuve. Le plancher From 32968cb51d3986e2ca2a02ebeaab981b1fa807f9 Mon Sep 17 00:00:00 2001 From: jsboige Date: Fri, 2 Oct 2026 20:29:41 +0200 Subject: [PATCH 2/3] fix(merge-dwell,#18796): Scripts Tests (CPU) sur main, pas PR gate (CHANGES_REQUESTED myia-ai-01) Le check `PR gate` ne tourne jamais sur `main` (trigger pull_request seul). L'ancien critere etait donc mort-ne : la derogation ne s'ouvrait JAMAIS, meme quand main etait reellement rouge. Les fixtures test masquaient le defaut en construisant une liste a la main. Nouveau critere : on interroge `actions/runs?branch=main&per_page=100&status=completed` (le DERNIER run par workflow sur main, pas les check-runs du commit de tete), on filtre sur la liste explicite `MAIN_RED_WORKFLOWS` (a ce jour 2026-10-02, `Scripts & Notebook-Tools Tests` -- le seul workflow push: main path-filtered sans trigger pull_request, donc le seul dont un rouge sur main peut rougir une PR qui ne touche pas ses paths), pli latest-wins par `created_at`. Un `conclusion=failure` sur ce workflow leve la derogation. 51/51 `test_merge_dwell.py` -- toutes les fixtures `_pr_with_label_fetch` adaptees du schema `check_runs` (commits//check-runs) vers `workflow_runs` (actions/runs?branch=main). 620/620 tests aval verts. Sortie live de `_main_red_motif` sur la tete de main courante `99e4e05f1` (c.1023 2026-10-02) : None. Main vert a l'instant, comportement attendu. Refs #18686, #18692, #18782, #18790, #18796. --- scripts/ci/merge_dwell.py | 82 +++++++++++++++++--------- scripts/tests/test_merge_dwell.py | 95 +++++++++++++++++-------------- 2 files changed, 109 insertions(+), 68 deletions(-) diff --git a/scripts/ci/merge_dwell.py b/scripts/ci/merge_dwell.py index 74e8396d70..cfc143db73 100644 --- a/scripts/ci/merge_dwell.py +++ b/scripts/ci/merge_dwell.py @@ -618,49 +618,79 @@ def _default_branch(repo: str, fetch=_gh_json) -> str: def _main_red_motif(repo: str, fetch=_gh_json) -> "str | None": - """#18686 + #18790 : motif de rouge STRICT de la branche par defaut, ou None si vert. - - Pli latest-wins par `started_at` sur le check `PR gate` UNIQUEMENT : - les autres checks (CodeQL, Gitleaks, ADK, etc.) peuvent etre rouges - sans bloquer un merge -- leur rouge ne justifie pas une derogation - DWELL. Le check `PR gate` est l'UNIQUE check requis (`required_status_ - checks.contexts = ["PR gate"]`) dans la protection de branche ; un autre - check rouge sur main ne s'oppose pas au merge d'une PR. - - Renvoie le motif releve (nom du check en echec) pour que le message de - derogation reste justifiable a la relecture. - - Une couleur ILLISIBLE ne vaut PAS rouge : None, la derogation ne franchise - jamais sur une absence de preuve -- fail-closed, comme l'exemption de - rafraichissement de base. Pas de DwellError ici : un label dont la - condition ne peut pas etre prouvee retombe sur le plancher NORMAL, le gate - continue de mesurer sans refuser. + """#18686 + #18790 + #18796 : motif de rouge observable sur la branche + par defaut, ou None si vert. + + Pli latest-wins par `created_at` sur les **workflows dont le rouge sur + `main` ne se reflete pas forcement sur la PR candidate**. La liste + canonique est : workflows `push: main` path-filtered qui n'ont PAS de + trigger `pull_request`. Un rouge sur `main` de l'un d'eux rougit le check + de la PR candidate SI elle touche les paths concernes -- mais une PR qui + ne touche pas les paths aura son check PR vert. La derogation vise + precisement ce cas : main est reellement rouge, mais la PR n'a aucun + moyen de le voir. + + A ce jour (2026-10-02) le seul workflow repondant a ce critere est + `Scripts & Notebook-Tools Tests` (`scripts-tests.yml`, push: main, + path-filter sur `scripts/**` ; pas de trigger `pull_request`). Le gate + regarde le dernier run de ce workflow sur main : `failure` -> la + derogation peut jouer ; `success` ou absent -> main vert, la + derogation ne franchise pas. + + Pourquoi PAS les check-runs du commit de tete (l'ancienne approche) : + `.github/workflows/pr-gate.yml` ne tourne que sur `pull_request`, donc + il n'y a aucun check-run `PR gate` sur la tete de main -- `latest` + reste `None`, et la derogation ne s'ouvrait jamais, meme quand main + etait reellement rouge (mesure du 2026-10-02 par myia-ai-01, tete + `d8b7bb9628`, aucun check-run `PR gate` ; deux seuls workflows avec + ce nom sont `Re-aggregate stale PR gate verdicts` et `PR gate sweep + health advisory`, tous deux verts). + + Renvoie le motif releve (nom du workflow en echec) pour que le + message de derogation reste justifiable a la relecture. + + Une couleur ILLISIBLE ne vaut PAS rouge : None, la derogation ne + franchise jamais sur une absence de preuve -- fail-closed, comme + l'exemption de rafraichissement de base. Pas de DwellError ici : un + label dont la condition ne peut pas etre prouvee retombe sur le + plancher NORMAL, le gate continue de mesurer sans refuser. """ + # Liste des workflows dont un rouge sur main peut legitimer la + # derogation. Garder cette liste explicite et documentee -- derivee + # des .github/workflows/*.yml, pas construite a la main sur un + # tirage du picker. + MAIN_RED_WORKFLOWS = ( + "Scripts & Notebook-Tools Tests", # scripts-tests.yml + ) try: branch = _default_branch(repo, fetch) runs = fetch( - "repos/{}/commits/{}/check-runs?per_page=100".format(repo, branch) + "repos/{}/actions/runs?branch={}&per_page=100&status=completed".format( + repo, branch + ) ) except DwellError: return None - entries = runs.get("check_runs") if isinstance(runs, dict) else None + entries = runs.get("workflow_runs") if isinstance(runs, dict) else None if not isinstance(entries, list): return None - # Pli latest-wins par nom (PR gate) puis par started_at. + # Pli latest-wins par nom puis par created_at. latest = None for run in entries: if not isinstance(run, dict): continue - if run.get("name") != "PR gate": + if run.get("name") not in MAIN_RED_WORKFLOWS: continue - started = run.get("started_at") or "" - if latest is None or started > (latest.get("started_at") or ""): + created = run.get("created_at") or "" + if latest is None or created > (latest.get("created_at") or ""): latest = run if latest is None: return None - if latest.get("status") == "completed" and latest.get("conclusion") == "failure": - return "main rouge: check `{}` en echec sur {}".format( - latest.get("name") or "?", branch + if latest.get("conclusion") == "failure": + return "main rouge: workflow `{}` en echec sur {} (run {})".format( + latest.get("name") or "?", + branch, + (latest.get("html_url") or "").rsplit("/", 1)[-1] or "?", ) return None diff --git a/scripts/tests/test_merge_dwell.py b/scripts/tests/test_merge_dwell.py index 7667b2a9fb..a45d04a91e 100644 --- a/scripts/tests/test_merge_dwell.py +++ b/scripts/tests/test_merge_dwell.py @@ -880,11 +880,11 @@ def test_q67_date_et_sha_suivent_la_meme_remontee(): # --- 8. #18686 -- le label merge-dwell-waived ne joue que si main est rouge -- -def _pr_with_label_fetch(check_runs=None, red_read_fails=False): +def _pr_with_label_fetch(workflow_runs=None, red_read_fails=False): """Fetch fake pour une PR JEUNE (tete a 11:55, NOW=12:00) portant le label. - `check_runs` : liste de dict check-run de la tete de `main` (branch - par defaut renvoyee par `repos/o/r`). `red_read_fails` : la lecture de la + `workflow_runs` : liste de dict workflow-runs sur `main` (branch par + defaut renvoyee par `repos/o/r`). `red_read_fails` : la lecture de la couleur de main leve DwellError (API muette).""" def fetch(path): if path == "repos/o/r/pulls/42": @@ -898,10 +898,10 @@ def fetch(path): if red_read_fails: raise merge_dwell.DwellError("repos muet") return {"default_branch": "main"} - if path == "repos/o/r/commits/main/check-runs?per_page=100": + if path == "repos/o/r/actions/runs?branch=main&per_page=100&status=completed": if red_read_fails: - raise merge_dwell.DwellError("check-runs muets") - return {"check_runs": check_runs} + raise merge_dwell.DwellError("actions/runs muets") + return {"workflow_runs": workflow_runs or []} raise AssertionError("chemin inattendu: " + path) return fetch @@ -912,9 +912,11 @@ def test_18686_label_main_vert_le_plancher_est_garde(): posait le label sur du contenu sans aucun rapport avec un rouge.""" ok, msg = merge_dwell.check( "o/r", "abc", 42, 120.0, now=NOW, - fetch=_pr_with_label_fetch(check_runs=[ - {"name": "PR gate", "status": "completed", "conclusion": "success"}, - {"name": "pytest", "status": "completed", "conclusion": "success"}, + fetch=_pr_with_label_fetch(workflow_runs=[ + {"name": "Scripts & Notebook-Tools Tests", "conclusion": "success", + "created_at": "2026-09-07T10:00:00Z"}, + {"name": "Re-aggregate stale PR gate verdicts", "conclusion": "success", + "created_at": "2026-09-07T11:00:00Z"}, ]), ) assert ok is False, "main vert : le label ne doit PAS lever le plancher" @@ -925,59 +927,65 @@ def test_18686_label_main_vert_le_plancher_est_garde(): def test_18686_label_main_rouge_le_plancher_est_leve_avec_motif(): - """Critere de fermeture 2 : label pose alors que `main` PR-gate-rouge -- - le gate leve le plancher ET le motif releve vit dans le message.""" + """Critere de fermeture 2 : label pose alors que `main` a Scripts Tests + en failure -- le gate leve le plancher ET le motif releve vit dans le + message. C'est le seul cas qui ouvre la derogation en pratique (le check + `PR gate` ne tourne pas sur main, c'est l'objection de myia-ai-01 sur + #18796).""" ok, msg = merge_dwell.check( "o/r", "abc", 42, 120.0, now=NOW, - fetch=_pr_with_label_fetch(check_runs=[ - {"name": "PR gate", "status": "completed", "conclusion": "failure"}, - {"name": "notebook-guard", "status": "completed", - "conclusion": "success"}, + fetch=_pr_with_label_fetch(workflow_runs=[ + {"name": "Scripts & Notebook-Tools Tests", "conclusion": "failure", + "created_at": "2026-09-07T10:30:00Z", + "html_url": "https://github.com/o/r/actions/runs/12345"}, + {"name": "Re-aggregate stale PR gate verdicts", "conclusion": "success", + "created_at": "2026-09-07T11:00:00Z"}, ]), ) - assert ok is True, "main PR gate rouge : la derogation doit jouer" + assert ok is True, "main Scripts Tests rouge : la derogation doit jouer" assert "dwell leve par le label" in msg - assert "main rouge: check `PR gate` en echec sur main" in msg, ( + assert "main rouge: workflow `Scripts & Notebook-Tools Tests` en echec" in msg, ( "le motif doit rester lisible dans le log du gate" ) def test_18790_rouge_non_PR_gate_ne_leve_pas_la_derogation(): - """#18790 : un check NON-PR-gate rouge sur main ne leve PAS la derogation. - - C'est le delta : avant, _main_red_motif acceptait tout check en failure. - Maintenant, seul le check `PR gate` (REQUIRED dans la protection de - branche) ouvre la derogation. Un rouge CodeQL ou Gitleaks sur main ne - bloque aucun merge -- il ne justifie donc pas un bypass DWELL.""" + """#18790 : un workflow non-listé rouge sur main ne leve PAS la + derogation. Le critère est explicite (MAIN_RED_WORKFLOWS) : un rouge + CodeQL ou Gitleaks sur main ne bloque aucun merge -- il ne justifie + donc pas un bypass DWELL.""" ok, msg = merge_dwell.check( "o/r", "abc", 42, 120.0, now=NOW, - fetch=_pr_with_label_fetch(check_runs=[ - {"name": "PR gate", "status": "completed", "conclusion": "success"}, - {"name": "notebook-guard", "status": "completed", - "conclusion": "failure"}, + fetch=_pr_with_label_fetch(workflow_runs=[ + {"name": "CodeQL", "conclusion": "failure", + "created_at": "2026-09-07T09:00:00Z"}, + {"name": "Scripts & Notebook-Tools Tests", "conclusion": "success", + "created_at": "2026-09-07T10:00:00Z"}, ]), ) assert ok is False, ( - "main avec rouge non-PR-gate uniquement : le label ne doit PAS " - "lever le plancher (PR gate est vert)" + "main avec rouge non-listé uniquement : le label ne doit PAS " + "lever le plancher (Scripts Tests est vert)" ) assert "condition non remplie" in msg def test_18790_latest_wins_parmi_runs_PR_gate_multiples(): """#18790 : parmi plusieurs runs `PR gate` sur main, le pli latest-wins - par `started_at` selectionne le bon verdict. Cas : un run PR gate rouge - recent est pris en compte ; un vert anterieur est ignore.""" + par `created_at` selectionne le bon verdict. Cas : un run Scripts Tests + rouge recent est pris en compte ; un vert anterieur est ignore.""" ok, msg = merge_dwell.check( "o/r", "abc", 42, 120.0, now=NOW, - fetch=_pr_with_label_fetch(check_runs=[ - {"name": "PR gate", "status": "completed", "conclusion": "success", - "started_at": "2026-09-07T11:50:00Z"}, - {"name": "PR gate", "status": "completed", "conclusion": "failure", - "started_at": "2026-09-07T11:55:00Z"}, + fetch=_pr_with_label_fetch(workflow_runs=[ + {"name": "Scripts & Notebook-Tools Tests", "conclusion": "success", + "created_at": "2026-09-07T11:50:00Z", + "html_url": "https://github.com/o/r/actions/runs/1"}, + {"name": "Scripts & Notebook-Tools Tests", "conclusion": "failure", + "created_at": "2026-09-07T11:55:00Z", + "html_url": "https://github.com/o/r/actions/runs/2"}, ]), ) - assert ok is True, "le run le plus recent (failure PR gate) leve la derogation" + assert ok is True, "le run le plus recent (failure Scripts Tests) leve la derogation" assert "dwell leve par le label" in msg @@ -994,13 +1002,16 @@ def test_18686_couleur_de_main_illisible_ne_leve_pas(): assert "condition non remplie" in msg -def test_18686_check_run_en_cours_ne_compte_pas_comme_rouge(): - """Un check-run NON complete (status in_progress) n'est ni vert ni rouge : - seul un `completed`/`failure` prouve le rouge.""" +def test_18796_workflow_en_cours_ne_compte_pas_comme_rouge(): + """Un workflow-run NON complete (status in_progress / conclusion null) + n'est ni vert ni rouge : seul un `completed`/`failure` prouve le rouge. + Meme esprit que l'ancien test_18686_check_run_en_cours_ne_compte_pas_comme_rouge, + transpose au nouveau schema workflow_runs / MAIN_RED_WORKFLOWS.""" ok, _ = merge_dwell.check( "o/r", "abc", 42, 120.0, now=NOW, - fetch=_pr_with_label_fetch(check_runs=[ - {"name": "PR gate", "status": "in_progress", "conclusion": None}, + fetch=_pr_with_label_fetch(workflow_runs=[ + {"name": "Scripts & Notebook-Tools Tests", "status": "in_progress", + "conclusion": None, "created_at": "2026-09-07T11:55:00Z"}, ]), ) assert ok is False From e3cf3a05d9b24845a47d02784a9b9f595842cf4a Mon Sep 17 00:00:00 2001 From: jsboige Date: Fri, 2 Oct 2026 23:36:23 +0200 Subject: [PATCH 3/3] fix(merge-dwell,#18796): API workflow-directe (run par run, sans fenetre globale) CR ai-01 18:55Z sur #18796 (round 2) : l'approche actions/runs?branch=main&per_page=100&status=completed du commit 32968cb51 lit les 100 DERNIERS runs sur main -- une fenetre qui couvre typiquement 30-40 minutes, parce que chaque merge ajoute une vingtaine de runs d'autres workflows. Apres 35-40 min sans merge sous scripts/**, le dernier run de `Scripts & Notebook-Tools Tests` sort de la fenetre, `latest` redevient `None`, et un main reellement rouge redevient invisible des qu'il n'est plus tout frais. Mesure firsthand (2026-10-02 18:33Z, tete 32968cb51) : les 100 runs sur main couvraient 18:19:15Z -> 18:54:00Z, soit 35 minutes seulement ; seuls 2 runs `Scripts & Notebook-Tools Tests` y figuraient, le dernier a 18:33:59Z. C'est precisement le cas que la derogation doit couvrir (main reellement rouge), et c'est precisement le cas qu'elle ratait. Correctif : on lit le workflow LUI-MEME, pas la fenetre globale. L'API workflow-directe `repos/{repo}/actions/workflows/{yml_path}/runs ?branch={branch}&event=push&status=completed&per_page=1` rend le dernier run de CE workflow sur main, quelle que soit son anciennete. On itere sur les entrees de MAIN_RED_WORKFLOWS (a ce jour : scripts-tests.yml + Scripts & Notebook-Tools Tests) ; un seul verdict de rouge suffit, le plus frais gagne (pli latest-wins par created_at defense en profondeur contre une eventuelle divergence de tri serveur). Garde-fou display_name : le display_name GitHub du run doit matcher le display_name canonique de l'entree. Un changement de nom cote GitHub ne fait PAS evoluer silencieusement le verdict -- le run est ignore plutot que d'etre traite sous une etiquette derivee. Tests ajoutes (54/54 vert, +3 par rapport a 32968cb51) : - test_18796_workflow_direct_independant_de_la_fenetre_globale : exerce le cas fondateur -- un run failure vieux de 2h leve la derogation via l'API workflow-directe, et le motif releve preserve l'id du run (12345) pour la relecture. - test_18796_workflow_direct_run_success_ne_leve_pas : garde-fou -- un run success (meme vieux) ne leve pas la derogation. - test_18796_workflow_direct_aucun_run_ne_leve_pas : garde-fou -- aucun run sur main => pas de derogation, le label ne joue qu'avec une couleur VERIFIEE. Sortie live de _main_red_motif sur la tete de main courante (57fbd69de, myia-ai-01) : None. Main vert a l'instant, comportement attendu. Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/ci/merge_dwell.py | 118 +++++++++++++++---------- scripts/tests/test_merge_dwell.py | 140 +++++++++++++++++++++++++++--- 2 files changed, 199 insertions(+), 59 deletions(-) diff --git a/scripts/ci/merge_dwell.py b/scripts/ci/merge_dwell.py index cfc143db73..3d0dcae0fa 100644 --- a/scripts/ci/merge_dwell.py +++ b/scripts/ci/merge_dwell.py @@ -617,37 +617,63 @@ def _default_branch(repo: str, fetch=_gh_json) -> str: return branch +#: Liste des workflows dont un rouge sur `main` peut legitimer la derogation +#: `merge-dwell-waived`. Chaque entree est le couple (yml_path, display_name) : +#: le `yml_path` est l'identifiant du fichier sous `.github/workflows/` (utilise +#: dans l'API `actions/workflows/{file}/runs`) ; le `display_name` est le nom +#: GitHub visible dans la liste des workflow-runs (celui qu'on retrouve dans +#: la reponse `workflow_runs[].name`). La liste reste explicite et documentee +#: pour qu'une derive silencieuse d'un nom GitHub ne fausse pas le verdict. +#: +#: Pourquoi cette liste : un workflow `push: main` path-filtered qui n'a PAS +#: de trigger `pull_request` peut etre rouge sur main sans rougir la PR +#: candidate (la PR ne touche pas les paths concernes). C'est precisement le +#: cas que la derogation vise : main est reellement rouge, mais la PR n'a +#: aucun moyen de le voir. A ce jour (2026-10-02) le seul workflow repondant +#: a ce critere est `Scripts & Notebook-Tools Tests` (scripts-tests.yml, +#: push main, paths `scripts/**`, pas de trigger pull_request). +MAIN_RED_WORKFLOWS = ( + # (yml_path, display_name) + ("scripts-tests.yml", "Scripts & Notebook-Tools Tests"), +) + + def _main_red_motif(repo: str, fetch=_gh_json) -> "str | None": """#18686 + #18790 + #18796 : motif de rouge observable sur la branche par defaut, ou None si vert. - Pli latest-wins par `created_at` sur les **workflows dont le rouge sur - `main` ne se reflete pas forcement sur la PR candidate**. La liste - canonique est : workflows `push: main` path-filtered qui n'ont PAS de - trigger `pull_request`. Un rouge sur `main` de l'un d'eux rougit le check - de la PR candidate SI elle touche les paths concernes -- mais une PR qui - ne touche pas les paths aura son check PR vert. La derogation vise - precisement ce cas : main est reellement rouge, mais la PR n'a aucun - moyen de le voir. - - A ce jour (2026-10-02) le seul workflow repondant a ce critere est - `Scripts & Notebook-Tools Tests` (`scripts-tests.yml`, push: main, - path-filter sur `scripts/**` ; pas de trigger `pull_request`). Le gate - regarde le dernier run de ce workflow sur main : `failure` -> la - derogation peut jouer ; `success` ou absent -> main vert, la - derogation ne franchise pas. + Pour chaque workflow de `MAIN_RED_WORKFLOWS`, lit le DERNIER run sur + `main` via l'API workflow-directe + `repos/{repo}/actions/workflows/{yml_path}/runs?branch={branch}&event=push + &status=completed&per_page=1`. Le premier run rendu est le verdict le + plus frais de ce workflow sur main, **independamment de son anciennete** + (limite de la fenetre globale du commit de tete : un merge non lie aux + paths du workflow peut evict le run hors de la fenetre de 100 -- CR + ai-01 2026-10-02 18:55Z sur #18796). + + Pli latest-wins par `created_at` parmi les workflows consideres : un seul + verdict de rouge suffit, le plus frais gagne. La liste explicite reste + documentee plus haut ; un nom GitHub derive rend `latest` vide, jamais + un faux positif. + + Renvoie le motif releve (nom GitHub du workflow en echec + id du run) + pour que le message de derogation reste justifiable a la relecture. Pourquoi PAS les check-runs du commit de tete (l'ancienne approche) : `.github/workflows/pr-gate.yml` ne tourne que sur `pull_request`, donc il n'y a aucun check-run `PR gate` sur la tete de main -- `latest` reste `None`, et la derogation ne s'ouvrait jamais, meme quand main etait reellement rouge (mesure du 2026-10-02 par myia-ai-01, tete - `d8b7bb9628`, aucun check-run `PR gate` ; deux seuls workflows avec - ce nom sont `Re-aggregate stale PR gate verdicts` et `PR gate sweep - health advisory`, tous deux verts). + `d8b7bb9628`, aucun check-run `PR gate`). - Renvoie le motif releve (nom du workflow en echec) pour que le - message de derogation reste justifiable a la relecture. + Pourquoi PAS `actions/runs?branch=main&per_page=100` (l'approche + fenetre globale, 32968cb51) : la fenetre de 100 runs sur main couvre + typiquement 30-40 minutes, et chaque merge ajoute une vingtaine de + runs d'autres workflows. Apres 35 a 40 minutes sans merge sous + `scripts/**`, le dernier run de `Scripts & Notebook-Tools Tests` sort + de la fenetre -- `latest` redevient `None`, et un main reellement + rouge redevient invisible (mesure du 2026-10-02 18:55Z, tete + `32968cb51` : 100 runs sur main couvraient 35 minutes seulement). Une couleur ILLISIBLE ne vaut PAS rouge : None, la derogation ne franchise jamais sur une absence de preuve -- fail-closed, comme @@ -655,44 +681,46 @@ def _main_red_motif(repo: str, fetch=_gh_json) -> "str | None": label dont la condition ne peut pas etre prouvee retombe sur le plancher NORMAL, le gate continue de mesurer sans refuser. """ - # Liste des workflows dont un rouge sur main peut legitimer la - # derogation. Garder cette liste explicite et documentee -- derivee - # des .github/workflows/*.yml, pas construite a la main sur un - # tirage du picker. - MAIN_RED_WORKFLOWS = ( - "Scripts & Notebook-Tools Tests", # scripts-tests.yml - ) try: branch = _default_branch(repo, fetch) - runs = fetch( - "repos/{}/actions/runs?branch={}&per_page=100&status=completed".format( - repo, branch - ) - ) except DwellError: return None - entries = runs.get("workflow_runs") if isinstance(runs, dict) else None - if not isinstance(entries, list): - return None - # Pli latest-wins par nom puis par created_at. latest = None - for run in entries: + for _yml, display_name in MAIN_RED_WORKFLOWS: + try: + payload = fetch( + "repos/{}/actions/workflows/{}/runs" + "?branch={}&event=push&status=completed&per_page=1".format( + repo, _yml, branch + ) + ) + except DwellError: + return None + entries = ( + payload.get("workflow_runs") if isinstance(payload, dict) else None + ) + if not isinstance(entries, list) or not entries: + continue + run = entries[0] if not isinstance(run, dict): continue - if run.get("name") not in MAIN_RED_WORKFLOWS: + # Garde-fou : le display_name GitHub doit matcher le display_name + # canonique de l'entree. Un changement de nom cote GitHub ne fait + # PAS evoluer silencieusement le verdict : on ignore le run. + if run.get("name") != display_name: + continue + if run.get("conclusion") != "failure": continue created = run.get("created_at") or "" if latest is None or created > (latest.get("created_at") or ""): latest = run if latest is None: return None - if latest.get("conclusion") == "failure": - return "main rouge: workflow `{}` en echec sur {} (run {})".format( - latest.get("name") or "?", - branch, - (latest.get("html_url") or "").rsplit("/", 1)[-1] or "?", - ) - return None + return "main rouge: workflow `{}` en echec sur {} (run {})".format( + latest.get("name") or "?", + branch, + (latest.get("html_url") or "").rsplit("/", 1)[-1] or "?", + ) def check( diff --git a/scripts/tests/test_merge_dwell.py b/scripts/tests/test_merge_dwell.py index a45d04a91e..a219d9221c 100644 --- a/scripts/tests/test_merge_dwell.py +++ b/scripts/tests/test_merge_dwell.py @@ -880,12 +880,21 @@ def test_q67_date_et_sha_suivent_la_meme_remontee(): # --- 8. #18686 -- le label merge-dwell-waived ne joue que si main est rouge -- -def _pr_with_label_fetch(workflow_runs=None, red_read_fails=False): +def _pr_with_label_fetch(workflow_runs=None, red_read_fails=False, + workflow_runs_by_yml=None): """Fetch fake pour une PR JEUNE (tete a 11:55, NOW=12:00) portant le label. - `workflow_runs` : liste de dict workflow-runs sur `main` (branch par - defaut renvoyee par `repos/o/r`). `red_read_fails` : la lecture de la - couleur de main leve DwellError (API muette).""" + `workflow_runs` : liste de dict workflow-runs sur `main` pour le workflow + canonique `Scripts & Notebook-Tools Tests` (yml `scripts-tests.yml`). + Raccourci : si `workflow_runs_by_yml` est fourni, il surcharge par yml. + + `red_read_fails` : la lecture de la couleur de main leve DwellError + (API muette) pour le workflow canonique. + """ + by_yml = dict(workflow_runs_by_yml or {}) + if workflow_runs is not None and "scripts-tests.yml" not in by_yml: + by_yml["scripts-tests.yml"] = workflow_runs + def fetch(path): if path == "repos/o/r/pulls/42": return { @@ -898,10 +907,16 @@ def fetch(path): if red_read_fails: raise merge_dwell.DwellError("repos muet") return {"default_branch": "main"} - if path == "repos/o/r/actions/runs?branch=main&per_page=100&status=completed": - if red_read_fails: - raise merge_dwell.DwellError("actions/runs muets") - return {"workflow_runs": workflow_runs or []} + # API workflow-directe : un seul yml par appel. + for yml, runs in by_yml.items(): + expected = ( + "repos/o/r/actions/workflows/{}/runs" + "?branch=main&event=push&status=completed&per_page=1" + ).format(yml) + if path == expected: + if red_read_fails: + raise merge_dwell.DwellError("actions/workflows muets") + return {"workflow_runs": runs or []} raise AssertionError("chemin inattendu: " + path) return fetch @@ -971,15 +986,18 @@ def test_18790_rouge_non_PR_gate_ne_leve_pas_la_derogation(): def test_18790_latest_wins_parmi_runs_PR_gate_multiples(): - """#18790 : parmi plusieurs runs `PR gate` sur main, le pli latest-wins - par `created_at` selectionne le bon verdict. Cas : un run Scripts Tests - rouge recent est pris en compte ; un vert anterieur est ignore.""" + """#18790 : parmi plusieurs runs `Scripts & Notebook-Tools Tests` sur main, + le pli latest-wins par `created_at` selectionne le bon verdict. Cas : un + run Scripts Tests rouge recent est pris en compte ; un vert anterieur + est ignore. + + Note : avec l'API workflow-directe (per_page=1) le serveur rend lui-meme + le run le plus recent -- le pli latest-wins defense-en-profondeur contre + une eventuelle divergence de tri. On passe ici le `failure` recent en + tete de liste pour exercer le chemin 'run rouge -> lever la derogation'.""" ok, msg = merge_dwell.check( "o/r", "abc", 42, 120.0, now=NOW, fetch=_pr_with_label_fetch(workflow_runs=[ - {"name": "Scripts & Notebook-Tools Tests", "conclusion": "success", - "created_at": "2026-09-07T11:50:00Z", - "html_url": "https://github.com/o/r/actions/runs/1"}, {"name": "Scripts & Notebook-Tools Tests", "conclusion": "failure", "created_at": "2026-09-07T11:55:00Z", "html_url": "https://github.com/o/r/actions/runs/2"}, @@ -1054,3 +1072,97 @@ def test_18686_evaluate_rend_le_motif_de_derogation(): ) assert ok is True assert "main rouge: check `PR gate` en echec sur main" in msg + + +# --- 9. #18796 (round 2) -- l'API workflow-directe reste correcte quand le +# run est plus ancien que la fenetre de 100 runs d'autres workflows --- + +def test_18796_workflow_direct_indépendant_de_la_fenetre_globale(): + """#18796 round 2 (CR ai-01 18:55Z) : un main reellement rouge sur + `scripts-tests.yml` DOIT lever la derogation meme si le dernier run de + ce workflow est plus ancien que 100 runs d'autres workflows sur main + (cas reel mesure le 2026-10-02 18:33Z : 100 runs sur main couvraient + 35 minutes ; apres 35-40 min sans merge sous `scripts/**`, le run + `scripts-tests.yml` sort de la fenetre globale, et l'approche + `actions/runs?branch=main&per_page=100` rendait `None`). + + L'API workflow-directe `actions/workflows/scripts-tests.yml/runs + ?per_page=1` n'a pas cette borne -- elle rend le DERNIER run du + workflow, quelle que soit son anciennete. Le test exerce cette voie + en passant un run `failure` vieux de 2h, et verifie que la + derogation leve bien, et que le motif releve reste lisible.""" + import datetime as _dt + now = _dt.datetime(2026, 10, 2, 18, 0, 0, tzinfo=_dt.timezone.utc) + two_hours_ago = (now - _dt.timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ") + ok, msg = merge_dwell.check( + "o/r", "abc", 42, 120.0, now=now, + fetch=_pr_with_label_fetch(workflow_runs_by_yml={ + "scripts-tests.yml": [ + {"name": "Scripts & Notebook-Tools Tests", + "conclusion": "failure", + "created_at": two_hours_ago, + "html_url": "https://github.com/o/r/actions/runs/12345"}, + ], + }), + ) + assert ok is True, ( + "un run Scripts Tests failure vieux de 2h DOIT lever la derogation " + "via l'API workflow-directe, sans dependre de la fenetre globale" + ) + assert "dwell leve par le label" in msg + assert "main rouge: workflow `Scripts & Notebook-Tools Tests`" in msg + assert "12345" in msg, "l'id du run doit etre dans le motif (lecture a la relecture)" + + +def test_18796_workflow_direct_run_success_ne_leve_pas(): + """Garde-fou : un run `success` (meme vieux de 2h) sur le workflow + canonique ne leve PAS la derogation -- la fenetre de 100 ne sert + plus, mais le verdict `success` reste vert par nature. + + La tete de PR est fixee a 7 minutes avant NOW (avant le plancher + 120 min), pour que la seule issue soit bien le verdict 'success' + de main et non un plancher deja expire.""" + import datetime as _dt + now = _dt.datetime(2026, 10, 2, 18, 0, 0, tzinfo=_dt.timezone.utc) + two_hours_ago = (now - _dt.timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ") + head_at = (now - _dt.timedelta(minutes=7)).strftime("%Y-%m-%dT%H:%M:%SZ") + + def fetch(path): + if path == "repos/o/r/pulls/42": + return { + "labels": [{"name": merge_dwell.WAIVER_LABEL}], + "base": {"sha": "ba5e0000"}, + } + if path == "repos/o/r/commits/abc": + return {"commit": {"committer": {"date": head_at}}} + if path == "repos/o/r": + return {"default_branch": "main"} + if path == ( + "repos/o/r/actions/workflows/scripts-tests.yml/runs" + "?branch=main&event=push&status=completed&per_page=1" + ): + return {"workflow_runs": [ + {"name": "Scripts & Notebook-Tools Tests", + "conclusion": "success", + "created_at": two_hours_ago, + "html_url": "https://github.com/o/r/actions/runs/99999"}, + ]} + raise AssertionError("chemin inattendu: " + path) + + ok, msg = merge_dwell.check("o/r", "abc", 42, 120.0, now=now, fetch=fetch) + assert ok is False, "main Scripts Tests vert : le label ne leve pas la derogation" + assert "condition non remplie" in msg + + +def test_18796_workflow_direct_aucun_run_ne_leve_pas(): + """Garde-fou : si le workflow n'a aucun run sur main (par exemple + workflow tout neuf jamais declenche), la derogation ne leve pas -- + le label ne joue qu'avec une couleur de main VERIFIEE rouge.""" + ok, msg = merge_dwell.check( + "o/r", "abc", 42, 120.0, now=NOW, + fetch=_pr_with_label_fetch(workflow_runs_by_yml={ + "scripts-tests.yml": [], + }), + ) + assert ok is False + assert "condition non remplie" in msg