Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 96 additions & 24 deletions scripts/ci/merge_dwell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
210 changes: 186 additions & 24 deletions scripts/tests/test_merge_dwell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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

Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading