From eafec2426d95cf5f054da0f2c479478cce3a1c66 Mon Sep 17 00:00:00 2001 From: jsboige Date: Fri, 23 Oct 2026 12:28:16 +0200 Subject: [PATCH 1/3] Add(ci,#22166): organe list_closed_unmerged_prs -- 5 verdicts sur les PRs fermees sans merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge_ready, le tapis, les dossiers de prevalidation et B.0 ne lisent que des PRs ouvertes : une PR fermee sans merge sort de toutes les surfaces au moment meme ou son contenu devient orphelin. Cas fondateur #20374 (force- push d'une tete identique a la base, diff vide, fermeture automatique, vrai commit une seconde plus tard) -- onze jours sans que rien ne le voie. L'organe enumere les PRs fermees sans merge sur 14 j et nomme chacune : ACCIDENTAL_EMPTY_CLOSE / ORPHANED_CONTENT / SUPERSEDED / INTEGRATED / UNKNOWN (jamais un vert). Il lit et nomme, ne rouvre ni ne ferme rien. Reutilise orphan_branch_scan.content_diff pour l'identite de contenu (robuste au squash) plutot que de reimplementer. Deux garde-faux-positifs mesures sur le cas reel : le force-push doit venir du MEME acteur que la fermeture ; la raison d'abandon ne se lit que dans un commentaire de fermeture, jamais dans le body (le body de #20374 porte « duplicate » comme descripteur de contenu et le faisait sortir SUPERSEDED). Cablage : closed-unmerged-pr-scan.yml (daily, advisory, exit 0 toujours) upserte le rapport marker-guarde sur #22166. Premier run reel : 1 ACCIDENTAL (#20374, 17 fichiers), 12 ORPHANED, 23 SUPERSEDED, 1 INTEGRATED, 0 UNKNOWN. Tests : 30 passed, dont controle positif sur le cas reel #20374. Co-Authored-By: Claude Haiku 4.5 (1M context) --- .github/workflows/closed-unmerged-pr-scan.yml | 72 +++ scripts/ci/list_closed_unmerged_prs.py | 456 ++++++++++++++++++ .../ci/tests/test_list_closed_unmerged_prs.py | 285 +++++++++++ 3 files changed, 813 insertions(+) create mode 100644 .github/workflows/closed-unmerged-pr-scan.yml create mode 100644 scripts/ci/list_closed_unmerged_prs.py create mode 100644 scripts/ci/tests/test_list_closed_unmerged_prs.py diff --git a/.github/workflows/closed-unmerged-pr-scan.yml b/.github/workflows/closed-unmerged-pr-scan.yml new file mode 100644 index 00000000000..12a7d4b0d0e --- /dev/null +++ b/.github/workflows/closed-unmerged-pr-scan.yml @@ -0,0 +1,72 @@ +name: Closed-unmerged PR scan + +# The POST-CLOSE organ for issue #22166. `merge_ready`, the belt, the +# prevalidation dossiers and B.0 only read OPEN PRs. A PR closed WITHOUT a +# merge leaves every surface at the very moment its content becomes orphaned: +# the branch survives, the commits survive, and nobody reads them any more. +# +# Founding case #20374: a lane force-pushed a head identical to its base, the +# diff went empty, GitHub closed the PR, and the real commit landed one second +# later -- a closed PR does not reopen itself. For eleven days nothing saw it. +# +# This scan enumerates closed-without-merge PRs over a window and names each +# one: ACCIDENTAL_EMPTY_CLOSE / ORPHANED_CONTENT / SUPERSEDED / INTEGRATED / +# UNKNOWN. It READS AND NAMES. It never reopens, recreates, closes, or labels +# -- the decision stays with the coordinator. +# +# ADVISORY, never blocking (same posture as orphan-branch-scan.yml): +# - The job ALWAYS exits 0. The actionable payload is the marker-guarded +# report upserted on issue #22166, NEVER a red conclusion. +# - `UNKNOWN` is explicitly never a green: it means the measurement failed. +# +# Scheduled sweep (daily) is the trigger. #22166 is the report home: it is the +# organ's own spec, and its daily output is the organ's output. + +on: + schedule: + # Daily, off-:00 minute (anti-stampede). 05:41 UTC. + - cron: '41 5 * * *' + workflow_dispatch: + +permissions: + issues: write + contents: read + +concurrency: + group: closed-unmerged-pr-scan + cancel-in-progress: false + +jobs: + sweep: + name: "Scan closed-without-merge PRs (advisory)" + # Routage #14283 tranche 4 (ai-01 2026-09-02) : balayage cron pur-Python. + # Declencheur `schedule` uniquement -- il ne tourne que sur la branche par + # defaut, donc aucun code de fork ne peut l'atteindre et aucune garde + # same-repo n'est requise. Retour arriere = `runs-on: ubuntu-latest`. + runs-on: [self-hosted, coursia-ephemeral, coursia-linux] + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch ALL remote heads + # The scan diffs each closed PR's branch against main for content + # identity, so every remote head's objects must be present locally. + run: | + git fetch origin '+refs/heads/*:refs/remotes/origin/*' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Run scan and upsert report + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Always exit 0: an advisory that could turn the build red is a gate, + # and this is not a gate. The report is the payload, not the exit. + python scripts/ci/list_closed_unmerged_prs.py --window-days 14 --post-to 22166 \ + || echo "scan advisory: non bloquant" + exit 0 diff --git a/scripts/ci/list_closed_unmerged_prs.py b/scripts/ci/list_closed_unmerged_prs.py new file mode 100644 index 00000000000..8314d5931f7 --- /dev/null +++ b/scripts/ci/list_closed_unmerged_prs.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +"""PRs fermees SANS merge dont la branche porte encore du contenu absent de main. + +Pourquoi cet organe existe (#22166) +----------------------------------- +`merge_ready`, le tapis, les dossiers de prevalidation et B.0 ne lisent que des +PRs **ouvertes**. Une PR fermee sans merge sort de toutes les surfaces au moment +meme ou son contenu devient orphelin : la branche survit, les commits aussi, et +personne ne les regarde plus. + +Cas fondateur #20374 (mesure le 2026-10-23) : la lane avait pousse une tete +identique a sa base (`reset --hard` vers la tete de la PR de base) ; le diff est +devenu vide, GitHub a ferme la PR, et le vrai commit est arrive une seconde plus +tard -- une PR fermee ne se rouvre pas d'elle-meme. Pendant onze jours, rien ne +l'a vu : la lane a lu un « cache GitHub obsolete », un dossier `READY` a ete +ecrit avec `diff-files: 0`, et l'audit suivant a adapte la navigation a l'etat +casse. + +Verdicts (un seul par PR) +------------------------- +``ACCIDENTAL_EMPTY_CLOSE`` + Evenement ``closed`` dans les 60 s d'un ``head_ref_force_pushed`` du meme + acteur, **et** branche distante encore presente avec du contenu absent de + ``main``. C'est la signature exacte de #20374. +``ORPHANED_CONTENT`` + Fermee sans merge, aucun commentaire de fermeture qui nomme une raison ou un + successeur, branche presente avec du contenu absent de ``main``. +``SUPERSEDED`` + Le body ou un commentaire de fermeture nomme une PR successeure, **ou** donne + une raison explicite d'abandon (obsolete, doublon, sans objet, abandonne). + Le contenu a ete ecarte deliberement -- ce n'est pas une perte. +``INTEGRATED`` + Le contenu de la branche est present sur ``main`` (identite de contenu via + l'organe ``orphan_branch_scan``, robuste au squash). +``UNKNOWN`` + API ou depot illisible, branche supprimee alors que le contenu n'est pas + verifiable, ou merge-base introuvable. **Ce n'est jamais un vert.** + +Ce que l'organe ne fait pas +--------------------------- +Il **lit et nomme**. Il ne rouvre, ne recree et ne ferme rien : la decision +revient au coordinateur. Il sort 0 dans tous les cas -- ce n'est pas un gate CI. + +Ordre d'evaluation +------------------ +``UNKNOWN`` (illisible) -> ``INTEGRATED`` (contenu sur main) -> ``SUPERSEDED`` +(ecarte deliberement) -> ``ACCIDENTAL_EMPTY_CLOSE`` (signature du force-push) -> +``ORPHANED_CONTENT``. Un contenu deja sur ``main`` n'est jamais un accident, et +une fermeture expliquee n'est jamais un orphelin. + +Usage +----- + python scripts/ci/list_closed_unmerged_prs.py + python scripts/ci/list_closed_unmerged_prs.py --window-days 30 --json +""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Callable + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import orphan_branch_scan as obs # noqa: E402 + +REPO = "jsboige/CoursIA" +DEFAULT_WINDOW_DAYS = 14 +#: Fenetre d'accident : un `closed` qui suit de moins de 60 s un force-push. +FORCE_PUSH_WINDOW_S = 60.0 +#: Fenetre de lecture des commentaires de fermeture (avant/apres `closed_at`). +CLOSING_COMMENT_WINDOW_S = 1800.0 + +ACCIDENTAL = "ACCIDENTAL_EMPTY_CLOSE" +ORPHANED = "ORPHANED_CONTENT" +SUPERSEDED = "SUPERSEDED" +INTEGRATED = "INTEGRATED" +UNKNOWN = "UNKNOWN" +VERDICTS = (ACCIDENTAL, ORPHANED, SUPERSEDED, INTEGRATED, UNKNOWN) + +#: Un successeur doit etre nomme explicitement. ``See #N`` et ``Part of #N`` sont +#: volontairement EXCLUS : dans le body d'une PR empilee ils designent le parent, +#: pas un successeur -- #20374 porte « Part of #20316 » et n'est pas superseded. +_SUCCESSOR_RE = re.compile( + r"(?:superseded\s+by|replaced\s+by|successor\s*:?|closed\s+in\s+favou?r\s+of|" + r"remplac[ée]e?s?\s+par|reprise?s?\s+(?:dans|par)|reprend\s+(?:dans|par)|" + r"duplicate\s+of|doublon\s+de|en\s+faveur\s+de)" + r"\s*:?\s*#(\d+)", + re.IGNORECASE, +) + +#: Une raison d'abandon explicite. Liste close : elle ne s'elargit pas a la prose +#: ordinaire, qui classerait n'importe quelle fermeture comme deliberee. +_ABANDON_RE = re.compile( + r"\b(?:abandonn[ée]e?|obsolet[e]?s?|sans\s+objet|plus\s+d'actualit[ée]|" + r"won'?t\s+fix|wontfix|not\s+needed|no\s+longer\s+needed|" + r"doublon|duplicate|revert[ée]e?|annul[ée]e?)\b", + re.IGNORECASE, +) + + +# --------------------------------------------------------------------------- +# Classification pure (testable sans reseau) +# --------------------------------------------------------------------------- + +def verdict_of( + *, + api_error: bool = False, + integrated: bool | None = None, + successor: int | None = None, + abandon_reason: bool = False, + force_push_to_close_s: float | None = None, + branch_present: bool | None = None, + content_lost: int | None = None, +) -> tuple[str, str]: + """Classe UNE PR fermee sans merge. + + Tous les faits sont injectes : la fonction ne lit ni le reseau ni le depot. + + Args: + api_error: l'API n'a pas repondu pour cette PR -> ``UNKNOWN``. + integrated: ``True`` si le contenu de la branche est sur ``main``, + ``False`` s'il en manque, ``None`` si non verifiable. + successor: numero de la PR successeure nommee, s'il y en a une. + abandon_reason: un commentaire de fermeture donne une raison d'abandon. + force_push_to_close_s: delai en secondes entre le dernier + ``head_ref_force_pushed`` et la fermeture (``None`` si aucun). + branch_present: la branche distante existe-t-elle encore ? + content_lost: nombre de fichiers absents/divergents de ``main``. + + Returns: + ``(verdict, detail)`` -- ``verdict`` parmi :data:`VERDICTS`. + """ + if api_error: + return (UNKNOWN, "API illisible pour cette PR -- jamais un vert") + if integrated is True: + return (INTEGRATED, "contenu de la branche present sur main") + if successor is not None: + return (SUPERSEDED, f"successeur nomme : #{successor}") + if abandon_reason: + return (SUPERSEDED, "raison d'abandon explicite dans un commentaire de fermeture") + if integrated is None: + return (UNKNOWN, "contenu non verifiable (merge-base introuvable ou branche supprimee)") + if branch_present and content_lost: + if force_push_to_close_s is not None and 0.0 <= force_push_to_close_s <= FORCE_PUSH_WINDOW_S: + return ( + ACCIDENTAL, + f"fermee {force_push_to_close_s:.0f} s apres un head_ref_force_pushed ; " + f"{content_lost} fichier(s) absent(s)/divergent(s) de main", + ) + return ( + ORPHANED, + f"{content_lost} fichier(s) absent(s)/divergent(s) de main, " + f"aucune raison de fermeture nommee", + ) + if branch_present and not content_lost: + return (INTEGRATED, "branche presente, contenu byte-identique a main") + return (UNKNOWN, "branche supprimee et contenu non verifiable") + + +# --------------------------------------------------------------------------- +# Lecture gh / git +# --------------------------------------------------------------------------- + +def _run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, check=False, encoding="utf-8") + + +def _gh_json(args: list[str]): + proc = _run(["gh", *args]) + if proc.returncode != 0: + raise RuntimeError(f"gh failed ({proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}") + if not proc.stdout.strip(): + return None + return json.loads(proc.stdout) + + +def _parse_ts(value: str | None) -> datetime | None: + if not value: + return None + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def closed_unmerged(repo: str, since: datetime) -> list[dict]: + """PRs fermees sans merge dont ``closed_at`` tombe apres ``since``. + + Pagine ``pulls?state=closed&sort=updated&direction=desc`` et s'arrete des que + la page depasse la fenetre (le tri par date de mise a jour garantit qu'aucune + PR plus recente ne suit). + """ + out: list[dict] = [] + page = 1 + while page <= 50: + data = _gh_json([ + "api", + f"repos/{repo}/pulls?state=closed&sort=updated&direction=desc" + f"&per_page=100&page={page}", + ]) or [] + if not data: + break + oldest = None + for pr in data: + closed = _parse_ts(pr.get("closed_at")) + if closed is not None and (oldest is None or closed < oldest): + oldest = closed + if pr.get("merged_at") or closed is None: + continue + if closed >= since: + out.append(pr) + if len(data) < 100 or (oldest is not None and oldest < since): + break + page += 1 + return out + + +def timeline(repo: str, number: int) -> list[dict]: + return _gh_json(["api", f"repos/{repo}/issues/{number}/timeline?per_page=100", "--paginate"]) or [] + + +def comments(repo: str, number: int) -> list[dict]: + return _gh_json(["api", f"repos/{repo}/issues/{number}/comments?per_page=100", "--paginate"]) or [] + + +def force_push_to_close_seconds(events: list[dict], closed_at: datetime) -> float | None: + """Delai du dernier ``head_ref_force_pushed`` a la fermeture, meme acteur. + + L'acteur compte : un force-push d'une autre lane suivi d'une fermeture n'est + pas la signature d'un ``reset --hard`` malheureux. + """ + closer = next((e.get("actor") or {} for e in events if e.get("event") == "closed"), {}) + closer_login = closer.get("login") + best: float | None = None + for ev in events: + if ev.get("event") != "head_ref_force_pushed": + continue + actor = (ev.get("actor") or {}).get("login") + if closer_login and actor != closer_login: + continue + ts = _parse_ts(ev.get("created_at")) + if ts is None: + continue + delta = (closed_at - ts).total_seconds() + if delta < 0: + continue + if best is None or delta < best: + best = delta + return best + + +def closing_evidence(body: str, comments_: list[dict], closed_at: datetime) -> tuple[int | None, bool]: + """``(successeur, raison_d_abandon)`` depuis le body et les commentaires proches. + + Seuls les commentaires dans :data:`CLOSING_COMMENT_WINDOW_S` autour de la + fermeture comptent : un ``superseded by #N`` poste trois semaines plus tot + parlait d'autre chose. + """ + successor: int | None = None + reason = False + comment_texts: list[str] = [] + for c in comments_: + ts = _parse_ts(c.get("created_at")) + if ts is None: + continue + if abs((ts - closed_at).total_seconds()) <= CLOSING_COMMENT_WINDOW_S: + comment_texts.append(c.get("body") or "") + # Successeur : le body ET les commentaires de fermeture (un body peut porter + # « superseded by #N »). + for text in [body or ""] + comment_texts: + if successor is None: + m = _SUCCESSOR_RE.search(text) + if m: + successor = int(m.group(1)) + # Raison d'abandon : UNIQUEMENT les commentaires de fermeture. Le body est + # le pitch de la PR, jamais une raison de la fermer -- y chercher « doublon + # »/« duplicate » y trouve un descripteur de contenu, pas une decision + # (#20374 portait « duplicate » dans sa description et sortait SUPERSEDED). + for text in comment_texts: + if _ABANDON_RE.search(text): + reason = True + break + return (successor, reason) + + +def content_state(branch: str, tip: str, main_ref: str) -> tuple[bool | None, int | None]: + """``(integre, fichiers_perdus)`` pour une branche, via l'organe existant. + + Reutilise ``orphan_branch_scan.content_diff`` (identite de contenu robuste au + squash) plutot que de reimplementer la comparaison. + """ + mb = _run(["git", "merge-base", main_ref, tip]).stdout.strip() + if not mb: + return (None, None) + files = obs.content_diff(branch, tip, main_ref) + lost = [f for f in files if f["status"] in (obs.STATUS_DIFF, obs.STATUS_ABSENT)] + return (not lost, len(lost)) + + +def evaluate_pr(pr: dict, repo: str, main_ref: str, branches: dict[str, str]) -> dict: + """Applique :func:`verdict_of` a une PR reelle. Toute erreur -> ``UNKNOWN``.""" + number = pr.get("number") + branch = ((pr.get("head") or {}).get("ref")) or "" + closed_at = _parse_ts(pr.get("closed_at")) + row = { + "number": number, + "title": (pr.get("title") or "")[:120], + "branch": branch, + "base": (pr.get("base") or {}).get("ref"), + "closed_at": pr.get("closed_at"), + "url": pr.get("html_url"), + } + try: + events = timeline(repo, number) + cmts = comments(repo, number) + except RuntimeError as exc: + row.update({"verdict": UNKNOWN, "detail": f"timeline/commentaires illisibles : {exc}"}) + return row + + delta = force_push_to_close_seconds(events, closed_at) if closed_at else None + successor, abandon = closing_evidence(pr.get("body") or "", cmts, closed_at) if closed_at else (None, False) + + present = branch in branches + if present: + integrated, lost = content_state(branch, branches[branch], main_ref) + else: + integrated, lost = (None, None) + + verdict, detail = verdict_of( + integrated=integrated, + successor=successor, + abandon_reason=abandon, + force_push_to_close_s=delta, + branch_present=present, + content_lost=lost, + ) + row.update({ + "verdict": verdict, + "detail": detail, + "force_push_to_close_s": None if delta is None else round(delta, 1), + "files_lost": lost, + }) + return row + + +def scan(repo: str = REPO, window_days: float = DEFAULT_WINDOW_DAYS, now: datetime | None = None, + prs: list[dict] | None = None, branches: dict[str, str] | None = None, + main_ref: str = "origin/main") -> dict: + """Enumere, classe et agrege. ``prs``/``branches`` injectables pour les tests.""" + now = now or datetime.now(timezone.utc) + since = now - timedelta(days=window_days) + if prs is None: + prs = closed_unmerged(repo, since) + if branches is None: + branches = obs.remote_branches(repo) + rows = [evaluate_pr(pr, repo, main_ref, branches) for pr in prs] + counts = {v: 0 for v in VERDICTS} + for row in rows: + counts[row["verdict"]] = counts.get(row["verdict"], 0) + 1 + order = {v: i for i, v in enumerate(VERDICTS)} + rows.sort(key=lambda r: (order.get(r["verdict"], len(VERDICTS)), -(r["number"] or 0))) + return { + "repo": repo, + "window_days": window_days, + "since": since.isoformat(), + "counts": counts, + "rows": rows, + } + + +MARKER_START = "" +MARKER_END = "" + + +def render(result: dict) -> str: + """Corps de rapport markdown, encadre par les marqueurs d'upsert.""" + c = result["counts"] + lines = [ + MARKER_START, + f"### PRs fermees sans merge -- {result['window_days']:g} j (depuis {result['since']})", + "", + "| Verdict | Compte |", + "|---|---|", + ] + for v in VERDICTS: + lines.append(f"| `{v}` | {c.get(v, 0)} |") + for v in (ACCIDENTAL, ORPHANED): + rows = [r for r in result["rows"] if r["verdict"] == v] + if not rows: + continue + lines += ["", f"#### `{v}` ({len(rows)})", ""] + for r in rows: + lines.append( + f"- **#{r['number']}** `{r['branch']}` (base `{r['base']}`, " + f"fermee {r['closed_at']}) -- {r['detail']} -- {r['url']}" + ) + unknown = c.get(UNKNOWN, 0) + if unknown: + lines += ["", f"`{UNKNOWN}` = {unknown} : API illisible ou contenu non verifiable. " + "Ce n'est pas un vert, c'est une mesure manquante."] + lines.append(MARKER_END) + return "\n".join(lines) + + +def upsert_report(issue_number: int, result: dict, current_body: str) -> str: + """Remplace la zone marquee du body de l'issue, ou l'ajoute. + + Pure : le body courant est injecte, la fonction renvoie le nouveau body + complet. L'upsert par marqueur garantit qu'un re-balayage quotidien ne + multiplie pas les blocs. + """ + block = render(result) # porte les marqueurs aux deux bouts + if MARKER_START in current_body and MARKER_END in current_body: + start = current_body.index(MARKER_START) + end = current_body.index(MARKER_END) + len(MARKER_END) + return current_body[:start] + block + current_body[end:] + return (current_body.rstrip() + "\n\n" + block + "\n") if current_body.strip() else block + "\n" + + +def post_report(repo: str, issue_number: int, result: dict) -> None: + """POSTe/upserte le rapport sur l'issue. Toute erreur est logguee, jamais fatale.""" + payload = _gh_json(["api", f"repos/{repo}/issues/{issue_number}"]) + current = (payload or {}).get("body") or "" + new_body = upsert_report(issue_number, result, current) + _gh_json([ + "api", f"repos/{repo}/issues/{issue_number}", + "-X", "PATCH", "-f", "body=" + new_body, + ]) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--repo", default=REPO) + ap.add_argument("--window-days", type=float, default=DEFAULT_WINDOW_DAYS) + ap.add_argument("--main-ref", default="origin/main") + ap.add_argument("--post-to", type=int, default=None, + help="numero d'issue sur laquelle upserter le rapport marker-guarde") + ap.add_argument("--json", action="store_true") + args = ap.parse_args(argv) + result = scan(args.repo, args.window_days, main_ref=args.main_ref) + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + print(render(result)) + if args.post_to is not None: + try: + post_report(args.repo, args.post_to, result) + except RuntimeError as exc: + print(f"RAPPORT NON POSTE (advisory) : {exc}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/tests/test_list_closed_unmerged_prs.py b/scripts/ci/tests/test_list_closed_unmerged_prs.py new file mode 100644 index 00000000000..ba84af34b02 --- /dev/null +++ b/scripts/ci/tests/test_list_closed_unmerged_prs.py @@ -0,0 +1,285 @@ +"""Tests de `scripts/ci/list_closed_unmerged_prs.py` (#22166). + +Ce que le fichier prouve +------------------------ + 1. Les cinq verdicts sont atteignables, et l'ordre d'evaluation est celui du + contrat : un contenu sur `main` n'est jamais un accident, une fermeture + expliquee n'est jamais un orphelin, `UNKNOWN` n'est jamais un vert. + 2. Le signal d'accident est le couple **force-push + fermeture du MEME acteur + dans les 60 s** -- un force-push d'une autre lane ne suffit pas. + 3. `See #N` / `Part of #N` dans le body ne designent PAS un successeur : c'est + le piege que #20374 porte lui-meme (`Part of #20316`). + 4. Le balayage complet fonctionne sur des dicts injectes (aucun reseau). + 5. **Controle positif sur le cas reel #20374** : il doit sortir en + `ACCIDENTAL_EMPTY_CLOSE`. Le test est ignore si `gh` ou le depot ne sont + pas disponibles, mais il ne se rabat jamais sur une fixture. + 6. **Controle negatif** : une PR fermee volontairement avec une raison doit + sortir en `SUPERSEDED` (ou `INTEGRATED`). + +Run: python -m pytest scripts/ci/tests/test_list_closed_unmerged_prs.py -q +""" +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +_MODULE_PATH = Path(__file__).resolve().parents[1] / "list_closed_unmerged_prs.py" +_SPEC = importlib.util.spec_from_file_location("list_closed_unmerged_prs", _MODULE_PATH) +lcu = importlib.util.module_from_spec(_SPEC) +assert _SPEC and _SPEC.loader +_SPEC.loader.exec_module(lcu) + +REPO = "jsboige/CoursIA" +NOW = datetime(2026, 10, 23, 12, 0, tzinfo=timezone.utc) + + +# --- 1. les cinq verdicts et l'ordre d'evaluation --------------------------------- + +def test_integrated_wins_over_every_other_signal(): + v, _ = lcu.verdict_of(integrated=True, successor=1, force_push_to_close_s=5.0, + branch_present=True, content_lost=3) + assert v == lcu.INTEGRATED + + +def test_successor_wins_over_the_accident_signature(): + v, d = lcu.verdict_of(integrated=False, successor=42, force_push_to_close_s=5.0, + branch_present=True, content_lost=3) + assert v == lcu.SUPERSEDED and "#42" in d + + +def test_abandon_reason_wins_over_the_accident_signature(): + v, _ = lcu.verdict_of(integrated=False, abandon_reason=True, + force_push_to_close_s=5.0, branch_present=True, content_lost=3) + assert v == lcu.SUPERSEDED + + +def test_accidental_is_the_force_push_signature(): + v, d = lcu.verdict_of(integrated=False, force_push_to_close_s=33.0, + branch_present=True, content_lost=11) + assert v == lcu.ACCIDENTAL and "33 s" in d + + +def test_orphaned_needs_content_off_main_and_no_reason(): + v, _ = lcu.verdict_of(integrated=False, force_push_to_close_s=3600.0, + branch_present=True, content_lost=4) + assert v == lcu.ORPHANED + + +def test_unknown_is_never_green(): + assert lcu.verdict_of(api_error=True)[0] == lcu.UNKNOWN + assert lcu.verdict_of(integrated=None)[0] == lcu.UNKNOWN + # branche supprimee + contenu non verifiable + assert lcu.verdict_of(branch_present=False, content_lost=None)[0] == lcu.UNKNOWN + + +def test_branch_present_without_lost_content_is_integrated(): + assert lcu.verdict_of(integrated=False, branch_present=True, content_lost=0)[0] == lcu.INTEGRATED + + +# --- 2. le force-push doit venir du meme acteur ----------------------------------- + +def _ev(kind, ts, login="jsboige"): + return {"event": kind, "created_at": ts, "actor": {"login": login}} + + +def test_force_push_of_the_closer_counts(): + events = [_ev("head_ref_force_pushed", "2026-10-12T10:19:41Z"), + _ev("closed", "2026-10-12T10:20:14Z")] + assert lcu.force_push_to_close_seconds(events, datetime(2026, 10, 12, 10, 20, 14, tzinfo=timezone.utc)) == 33.0 + + +def test_force_push_of_another_actor_does_not_count(): + events = [_ev("head_ref_force_pushed", "2026-10-12T10:19:41Z", login="myia-po-2023"), + _ev("closed", "2026-10-12T10:20:14Z", login="jsboige")] + assert lcu.force_push_to_close_seconds(events, datetime(2026, 10, 12, 10, 20, 14, tzinfo=timezone.utc)) is None + + +def test_force_push_after_the_close_is_ignored(): + # #20374 porte un force-push a 10:20:15Z, une seconde APRES la fermeture : + # c'est le vrai commit, pas la cause. + events = [_ev("head_ref_force_pushed", "2026-10-12T10:20:15Z"), + _ev("closed", "2026-10-12T10:20:14Z")] + assert lcu.force_push_to_close_seconds(events, datetime(2026, 10, 12, 10, 20, 14, tzinfo=timezone.utc)) is None + + +def test_only_the_closest_force_push_is_reported(): + events = [_ev("head_ref_force_pushed", "2026-10-12T09:00:04Z"), + _ev("head_ref_force_pushed", "2026-10-12T10:19:41Z"), + _ev("closed", "2026-10-12T10:20:14Z")] + assert lcu.force_push_to_close_seconds(events, datetime(2026, 10, 12, 10, 20, 14, tzinfo=timezone.utc)) == 33.0 + + +# --- 3. successeur : formes explicites seulement ---------------------------------- + +CLOSED = datetime(2026, 10, 12, 10, 20, 14, tzinfo=timezone.utc) + + +def _c(body, ts="2026-10-12T10:20:00Z"): + return {"body": body, "created_at": ts} + + +@pytest.mark.parametrize("body", [ + "Superseded by #20400", + "superseded by #20400", + "Remplacee par #20400", + "closed in favor of #20400", + "Doublon de #20400", +]) +def test_explicit_successor_forms_are_recognised(body): + assert lcu.closing_evidence("", [_c(body)], CLOSED)[0] == 20400 + + +@pytest.mark.parametrize("body", [ + "Part of #20316 · See #16231 (EPIC GT-03 split)", + "See #16231", + "Part of #20316", +]) +def test_parent_references_are_not_successors(body): + """Le piege exact de #20374 : son body cite son parent, pas un successeur.""" + assert lcu.closing_evidence(body, [], CLOSED)[0] is None + + +def test_abandon_reason_is_recognised(): + assert lcu.closing_evidence("", [_c("PR obsolete, sans objet")], CLOSED)[1] is True + + +def test_a_far_away_comment_does_not_close_the_case(): + # trois semaines avant la fermeture : ce commentaire parlait d'autre chose + assert lcu.closing_evidence("", [_c("superseded by #1", "2026-09-20T10:00:00Z")], CLOSED)[0] is None + + +def test_bot_markers_are_not_abandon_reasons(): + bot = _c(" ## Base != main (advisory, #10918) ") + assert lcu.closing_evidence("", [bot], CLOSED)[1] is False + + +# --- 4. balayage sur dicts injectes ------------------------------------------------ + +def _pr(number, branch, closed_at="2026-10-20T10:00:00Z", body="", base="main"): + return {"number": number, "title": f"pr {number}", "body": body, + "closed_at": closed_at, "merged_at": None, "html_url": f"https://x/{number}", + "head": {"ref": branch}, "base": {"ref": base}} + + +def test_scan_aggregates_and_sorts_by_severity(monkeypatch): + prs = [_pr(1, "gone"), _pr(2, "kept")] + monkeypatch.setattr(lcu, "timeline", lambda repo, n: []) + + def fake_comments(repo, n): + # PR #1 : une raison d'abandon dans un COMMENTAIRE de fermeture (le body + # ne compte plus, cf fix). PR #2 : aucune raison -> orpheline. + if n == 1: + return [{"body": "PR obsolete, sans objet", "created_at": "2026-10-20T09:59:00Z"}] + return [] + + monkeypatch.setattr(lcu, "comments", fake_comments) + monkeypatch.setattr(lcu, "content_state", lambda b, t, m: (False, 5)) + res = lcu.scan(REPO, 14, now=NOW, prs=prs, branches={"gone": "aaa", "kept": "bbb"}) + assert res["counts"][lcu.ORPHANED] == 1 + assert res["counts"][lcu.SUPERSEDED] == 1 + # ORPHANED est plus severe : il passe en tete du rapport + assert [r["number"] for r in res["rows"]] == [2, 1] + + +def test_scan_reports_integrated_for_content_on_main(monkeypatch): + monkeypatch.setattr(lcu, "timeline", lambda repo, n: []) + monkeypatch.setattr(lcu, "comments", lambda repo, n: []) + monkeypatch.setattr(lcu, "content_state", lambda b, t, m: (True, 0)) + res = lcu.scan(REPO, 14, now=NOW, prs=[_pr(9, "b")], branches={"b": "ccc"}) + assert res["counts"][lcu.INTEGRATED] == 1 + + +def test_scan_marks_unknown_when_timeline_fails(monkeypatch): + def boom(repo, n): + raise RuntimeError("502") + monkeypatch.setattr(lcu, "timeline", boom) + monkeypatch.setattr(lcu, "comments", lambda repo, n: []) + res = lcu.scan(REPO, 14, now=NOW, prs=[_pr(3, "b")], branches={"b": "ddd"}) + assert res["counts"][lcu.UNKNOWN] == 1 + + +def test_render_carries_markers_and_counts(): + res = {"repo": REPO, "window_days": 14, "since": "2026-10-09T12:00:00+00:00", + "counts": {v: 0 for v in lcu.VERDICTS}, + "rows": [{"number": 20374, "branch": "feature/x", "base": "feature/y", + "closed_at": "2026-10-12T10:20:14Z", "url": "https://x/20374", + "verdict": lcu.ACCIDENTAL, "detail": "fermee 33 s apres un force-push"}]} + out = lcu.render(res) + assert lcu.MARKER_START in out and lcu.MARKER_END in out + assert "#20374" in out + + +# --- 4b. upsert marker-guarde ---------------------------------------------------- + +def _result_with(pr_number, verdict): + return {"repo": REPO, "window_days": 14, "since": "2026-10-09T12:00:00+00:00", + "counts": {v: 0 for v in lcu.VERDICTS}, + "rows": [{"number": pr_number, "branch": "b", "base": "main", + "closed_at": "2026-10-20T10:00:00Z", "url": "u", + "verdict": verdict, "detail": "d"}]} + + +def test_upsert_appends_when_no_block(): + out = lcu.upsert_report(22166, _result_with(20374, lcu.ACCIDENTAL), "Intro du body.") + assert lcu.MARKER_START in out and lcu.MARKER_END in out + assert "Intro du body." in out + assert out.count(lcu.MARKER_START) == 1 + + +def test_upsert_replaces_existing_block_without_duplicating(): + current = "Intro.\n\n" + lcu.render(_result_with(1, lcu.ORPHANED)) + "\n\nSuite." + out = lcu.upsert_report(22166, _result_with(2, lcu.ORPHANED), current) + assert out.count(lcu.MARKER_START) == 1 + assert "Intro." in out and "Suite." in out + assert "#2" in out and "#1" not in out.split(lcu.MARKER_START)[1] # l'ancien bloc est remplace + + +# --- 5. controle POSITIF sur le cas reel #20374 ------------------------------------ + +def _gh_ready() -> bool: + try: + proc = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True, + check=False, encoding="utf-8", errors="replace") + return proc.returncode == 0 + except (OSError, FileNotFoundError): + return False + + +needs_gh = pytest.mark.skipif(not _gh_ready(), reason="gh absent ou non authentifie") + + +@needs_gh +def test_real_20374_is_accidental_empty_close(): + """Controle positif exige par #22166 : le cas fondateur doit sortir ACCIDENTAL.""" + pr = json.loads(subprocess.run( + ["gh", "api", f"repos/{REPO}/pulls/20374"], + capture_output=True, text=True, check=True, + encoding="utf-8", errors="replace").stdout) + branches = lcu.obs.remote_branches(REPO) + assert pr["head"]["ref"] in branches, "la branche de #20374 doit encore exister" + row = lcu.evaluate_pr(pr, REPO, "origin/main", branches) + assert row["verdict"] == lcu.ACCIDENTAL, row + assert row["force_push_to_close_s"] is not None and row["force_push_to_close_s"] <= 60.0 + assert row["files_lost"] and row["files_lost"] > 0 + + +# --- 6. controle NEGATIF ------------------------------------------------------------ + +@needs_gh +def test_a_real_deliberate_close_is_not_orphaned(): + """Controle negatif : sur la fenetre reelle, aucune fermeture expliquee ne + doit etre classee ORPHANED ni ACCIDENTAL.""" + prs = lcu.closed_unmerged(REPO, datetime(2026, 10, 9, tzinfo=timezone.utc)) + if not prs: + pytest.skip("aucune PR fermee sans merge sur la fenetre") + branches = lcu.obs.remote_branches(REPO) + rows = [lcu.evaluate_pr(pr, REPO, "origin/main", branches) for pr in prs] + for row in rows: + if "raison d'abandon" in row.get("detail", "") or "successeur nomme" in row.get("detail", ""): + assert row["verdict"] == lcu.SUPERSEDED, row From f8a6d41e66631ff3db3b7bd95e1d448fadc67912 Mon Sep 17 00:00:00 2001 From: jsboige Date: Fri, 23 Oct 2026 13:26:08 +0200 Subject: [PATCH 2/3] Fix(ci,#22166): arret de pagination sur updated_at, pas d'acteur = pas de signature Deux grains de la revue NanoClaw (review 5627059082), reels : 1. `closed_unmerged` s'arretait des qu'un element de la page avait un `closed_at` anterieur a la fenetre. Or le tri de l'API est `updated_at` decroissant et `state=closed` inclut les PRs MERGEES (des centaines sur 14 j) : une seule PR fermee de longue date mais commentee aujourd'hui arretait le balayage en page 1, et des orphelines etaient perdues en silence -- la classe de defaut que cet organe doit attraper. L'arret se fait desormais sur l'`updated_at` du DERNIER element, qui borne toutes les pages suivantes. `fetch_page` est injectable pour tester les pages. 2. Sans evenement `closed` dans la timeline, `closer_login` etait None et n'importe quel force-push comptait comme signature d'accident. On ne sait alors pas qui a ferme : pas de signature, retour None, la PR retombe en ORPHANED (qui reste nommee). Tests : 34 (dont 2 reseau), 32 offline verts. Ajoutes : pagination sur pages injectees (page 1 vieille fermeture + maj recente -> page 2 lue), arret quand le dernier updated_at sort de la fenetre, absence d'evenement closed. Co-Authored-By: Claude Haiku 4.5 (1M context) --- scripts/ci/list_closed_unmerged_prs.py | 44 +++++++++----- .../ci/tests/test_list_closed_unmerged_prs.py | 60 +++++++++++++++++++ 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/scripts/ci/list_closed_unmerged_prs.py b/scripts/ci/list_closed_unmerged_prs.py index 8314d5931f7..5c50e42bdb2 100644 --- a/scripts/ci/list_closed_unmerged_prs.py +++ b/scripts/ci/list_closed_unmerged_prs.py @@ -185,33 +185,44 @@ def _parse_ts(value: str | None) -> datetime | None: return datetime.fromisoformat(value.replace("Z", "+00:00")) -def closed_unmerged(repo: str, since: datetime) -> list[dict]: +def closed_unmerged(repo: str, since: datetime, fetch_page=None) -> list[dict]: """PRs fermees sans merge dont ``closed_at`` tombe apres ``since``. - Pagine ``pulls?state=closed&sort=updated&direction=desc`` et s'arrete des que - la page depasse la fenetre (le tri par date de mise a jour garantit qu'aucune - PR plus recente ne suit). + Le tri de l'API est ``updated_at`` decroissant, pas ``closed_at``. Comme + ``updated_at >= closed_at`` pour toute PR, l'``updated_at`` du **dernier** + element d'une page borne toutes les pages suivantes : s'il est anterieur a + ``since``, aucune fermeture de la fenetre ne peut s'y trouver. + + S'arreter sur le ``closed_at`` d'un element quelconque serait faux : une PR + fermee il y a quarante jours mais commentee aujourd'hui figure en page 1, et + couper la dessus perdrait en silence les orphelines des pages suivantes -- + exactement la classe de defaut que cet organe doit attraper (#22166, grain 1 + de la revue NanoClaw). + + ``fetch_page`` est injectable : les tests passent des pages fixes sans reseau. """ - out: list[dict] = [] - page = 1 - while page <= 50: - data = _gh_json([ + def _page(p: int) -> list: + return _gh_json([ "api", f"repos/{repo}/pulls?state=closed&sort=updated&direction=desc" - f"&per_page=100&page={page}", + f"&per_page=100&page={p}", ]) or [] + + fetch = fetch_page or _page + out: list[dict] = [] + page = 1 + while page <= 50: + data = fetch(page) if not data: break - oldest = None for pr in data: closed = _parse_ts(pr.get("closed_at")) - if closed is not None and (oldest is None or closed < oldest): - oldest = closed if pr.get("merged_at") or closed is None: continue if closed >= since: out.append(pr) - if len(data) < 100 or (oldest is not None and oldest < since): + last_updated = _parse_ts(data[-1].get("updated_at")) + if len(data) < 100 or (last_updated is not None and last_updated < since): break page += 1 return out @@ -231,7 +242,12 @@ def force_push_to_close_seconds(events: list[dict], closed_at: datetime) -> floa L'acteur compte : un force-push d'une autre lane suivi d'une fermeture n'est pas la signature d'un ``reset --hard`` malheureux. """ - closer = next((e.get("actor") or {} for e in events if e.get("event") == "closed"), {}) + closer = next((e.get("actor") or {} for e in events if e.get("event") == "closed"), None) + if closer is None: + # Timeline sans evenement `closed` : on ne sait pas qui a ferme, donc on + # ne peut pas attribuer le force-push. Pas de signature d'accident -- la + # PR retombe en ORPHANED, qui reste nommee (#22166, grain 2). + return None closer_login = closer.get("login") best: float | None = None for ev in events: diff --git a/scripts/ci/tests/test_list_closed_unmerged_prs.py b/scripts/ci/tests/test_list_closed_unmerged_prs.py index ba84af34b02..88d063a48fb 100644 --- a/scripts/ci/tests/test_list_closed_unmerged_prs.py +++ b/scripts/ci/tests/test_list_closed_unmerged_prs.py @@ -115,6 +115,66 @@ def test_only_the_closest_force_push_is_reported(): assert lcu.force_push_to_close_seconds(events, datetime(2026, 10, 12, 10, 20, 14, tzinfo=timezone.utc)) == 33.0 +# --- 2b. pagination : arret sur updated_at, pas sur closed_at (grain 1) ----------- + +def _p(closed, updated, number=1, merged=False): + return {"number": number, "closed_at": closed, "updated_at": updated, + "merged_at": "2026-10-01T00:00:00Z" if merged else None, + "head": {"ref": f"b{number}"}, "base": {"ref": "main"}, + "title": f"pr {number}", "body": "", "html_url": "u"} + + +def test_pagination_stops_on_updated_at_not_closed_at(): + """Une PR fermee il y a 40 j mais mise a jour aujourd'hui reste en page 1 : + couper sur son closed_at perdrait les orphelines de la page 2 en silence.""" + since = datetime(2026, 10, 9, tzinfo=timezone.utc) + # page 1 : 100 elements, dont une vieille fermeture commentee aujourd'hui. + # Le DERNIER element a un updated_at recent -> la page 2 doit etre lue. + p1 = [_p("2026-08-01T00:00:00Z", "2026-10-23T10:00:00Z", number=1)] # fermee 40+ j, maj aujourd'hui + p1 += [_p("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z", number=i, merged=True) for i in range(2, 100)] + p1.append(_p("2026-10-01T00:00:00Z", "2026-10-23T09:00:00Z", number=100, merged=True)) + # page 2 : une fermeture DANS la fenetre, seule orpheline reelle + p2 = [_p("2026-10-20T10:00:00Z", "2026-10-20T10:00:00Z", number=200)] + pages = {1: p1, 2: p2} + seen = [] + + def fetch(n): + seen.append(n) + return pages.get(n, []) + + out = lcu.closed_unmerged(REPO, since, fetch_page=fetch) + assert seen == [1, 2], "la page 2 doit etre lue" + assert [pr["number"] for pr in out] == [200] + + +def test_pagination_stops_when_last_updated_is_older_than_the_window(): + since = datetime(2026, 10, 9, tzinfo=timezone.utc) + p1 = [_p("2026-09-01T00:00:00Z", "2026-09-01T00:00:00Z", number=i, merged=True) for i in range(1, 100)] + p1.append(_p("2026-09-02T00:00:00Z", "2026-09-02T00:00:00Z", number=100, merged=True)) + seen = [] + + def fetch(n): + seen.append(n) + return {1: p1}.get(n, []) + + out = lcu.closed_unmerged(REPO, since, fetch_page=fetch) + assert seen == [1] and out == [] + + +# --- 2c. pas d'evenement closed = pas de signature (grain 2) ----------------------- + +def test_no_closed_event_yields_no_accident_signature(): + events = [_ev("head_ref_force_pushed", "2026-10-12T10:19:41Z")] + assert lcu.force_push_to_close_seconds( + events, datetime(2026, 10, 12, 10, 20, 14, tzinfo=timezone.utc)) is None + + +def test_no_closed_event_falls_back_to_orphaned(): + v, _ = lcu.verdict_of(integrated=False, force_push_to_close_s=None, + branch_present=True, content_lost=3) + assert v == lcu.ORPHANED + + # --- 3. successeur : formes explicites seulement ---------------------------------- CLOSED = datetime(2026, 10, 12, 10, 20, 14, tzinfo=timezone.utc) From 312116f04d6a32c084330ee9645d719966fa9a35 Mon Sep 17 00:00:00 2001 From: jsboige Date: Fri, 23 Oct 2026 21:42:51 +0200 Subject: [PATCH 3/3] Fix(ci,#22172): le balayage closed-unmerged tourne sur ubuntu-latest (WORKFLOW_NOT_ALLOWED) `Scripts Tests (CPU)` etait rouge a f8a6d41e66 sur un seul test : `test_current_repository_self_hosted_jobs_satisfy_isolation_policy`, verdict `VIOLATION WORKFLOW_NOT_ALLOWED: closed-unmerged-pr-scan.yml:sweep`. Un job self-hosted exige une entree dans SELF_HOSTED_WORKFLOW_ALLOWLIST ; ce workflow etait le seul a en manquer. Voie retenue : `runs-on: ubuntu-latest`, pour un tir QUOTIDIEN qui ne pese rien sur le pool. L'autre voie (entree d'allowlist) touche `scripts/ci/check_self_hosted_runner_policy.py`, que la PR OUVERTE #21413 d'une autre lane modifie deja -- l'y ajouter exposerait ce merge a un conflit sans gain de capacite. Le motif est ecrit dans le workflow, avec le retour arriere (ajouter l'entree d'allowlist dans le meme commit). Co-Authored-By: Claude Haiku 4.5 (1M context) --- .github/workflows/closed-unmerged-pr-scan.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/closed-unmerged-pr-scan.yml b/.github/workflows/closed-unmerged-pr-scan.yml index 12a7d4b0d0e..a34232af890 100644 --- a/.github/workflows/closed-unmerged-pr-scan.yml +++ b/.github/workflows/closed-unmerged-pr-scan.yml @@ -39,11 +39,21 @@ concurrency: jobs: sweep: name: "Scan closed-without-merge PRs (advisory)" - # Routage #14283 tranche 4 (ai-01 2026-09-02) : balayage cron pur-Python. + # GitHub-hosted, et c'est MESURE, pas un choix par defaut : un job + # self-hosted exige une entree dans SELF_HOSTED_WORKFLOW_ALLOWLIST + # (`scripts/ci/check_self_hosted_runner_policy.py`). Sans elle, + # `Scripts Tests (CPU)` rend WORKFLOW_NOT_ALLOWED sur ce workflow (test + # test_current_repository_self_hosted_jobs_satisfy_isolation_policy, + # mesure a f8a6d41e66). L'allowlist etait l'autre voie ; le fichier est + # touche par une PR OUVERTE d'une autre lane (#21413), donc l'y ajouter + # exposerait ce merge a un conflit -- pour un tir QUOTIDIEN qui ne pese + # rien sur le pool. Repasser en self-hosted = ajouter l'entree d'allowlist + # dans le meme commit, sinon la garde rougit. + # # Declencheur `schedule` uniquement -- il ne tourne que sur la branche par # defaut, donc aucun code de fork ne peut l'atteindre et aucune garde - # same-repo n'est requise. Retour arriere = `runs-on: ubuntu-latest`. - runs-on: [self-hosted, coursia-ephemeral, coursia-linux] + # same-repo n'est requise. + runs-on: ubuntu-latest steps: - name: Checkout (full history) uses: actions/checkout@v4