diff --git a/scripts/ci/merge_dwell.py b/scripts/ci/merge_dwell.py index 50ea26fdec..3d0dcae0fa 100644 --- a/scripts/ci/merge_dwell.py +++ b/scripts/ci/merge_dwell.py @@ -617,38 +617,110 @@ 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 : motif de rouge 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. - - 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. + + 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`). + + 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 + 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. """ try: branch = _default_branch(repo, fetch) - runs = fetch( - "repos/{}/commits/{}/check-runs?per_page=100".format(repo, branch) - ) except DwellError: return None - entries = runs.get("check_runs") if isinstance(runs, dict) else None - if not isinstance(entries, list): - return None - for run in entries: + latest = None + 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("status") == "completed" and run.get("conclusion") == "failure": - return "main rouge: check `{}` en echec sur {}".format( - run.get("name") or "?", branch - ) - return None + # 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 + 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 71925c26b9..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(check_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. - `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 - 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/commits/main/check-runs?per_page=100": - if red_read_fails: - raise merge_dwell.DwellError("check-runs muets") - return {"check_runs": check_runs} + # 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 @@ -912,9 +927,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,23 +942,71 @@ 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` 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": "success"}, - {"name": "notebook-guard", "status": "completed", - "conclusion": "failure"}, + 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 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 `notebook-guard` 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 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(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-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 `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": "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 Scripts Tests) 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 @@ -955,13 +1020,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 @@ -1004,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