diff --git a/.github/workflows/pr-gate-rerun.yml b/.github/workflows/pr-gate-rerun.yml index 3203ccd197..e74e4d64c9 100644 --- a/.github/workflows/pr-gate-rerun.yml +++ b/.github/workflows/pr-gate-rerun.yml @@ -77,17 +77,30 @@ jobs: resolve: name: Resolve the re-aggregation route (manual) # Q35 etape 2 (#17397) : agregateur pur -- resolve/re-run du gate, aucun - # secret, docker, GPU ni toolchain locale. Re-agreger decide si une PR est - # mergeable : tant que ce travail tourne sur le parc local, il se degrade - # exactement quand la file qu'il doit debloquer grossit (saturation du - # 2026-09-22). La garde same-repo ci-dessous est conservee telle quelle -- - # seul le runner change, aucune semantic de declenchement. + # secret, docker, GPU ni toolchain locale (python stdlib seul, #17680). + # Re-agreger decide si une PR est mergeable : tant que ce travail tourne + # sur le parc local, il se degrade exactement quand la file qu'il doit + # debloquer grossit (saturation du 2026-09-22). La garde same-repo + # ci-dessous est conservee telle quelle -- seul le runner change, aucune + # semantic de declenchement. runs-on: ubuntu-latest if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository outputs: action: ${{ steps.resolve.outputs.action }} run_id: ${{ steps.resolve.outputs.run_id }} steps: + # #17680 : la route vit dans scripts/ci/pr_gate_route.py (stdlib pur) -- + # testable en unitaire, horloge incluse. Le bash inline ne pouvait pas + # distinguer une tentative queued MORTE d'une saine : statut identique, + # decision identique, impasse complete (cf #17099, 19h30 queued). + - uses: actions/checkout@v4 + with: + sparse-checkout: | + scripts/ci/pr_gate_route.py + sparse-checkout-cone-mode: false + - uses: actions/setup-python@v5 + with: + python-version: '3.11' - name: Resolve route id: resolve env: @@ -103,55 +116,14 @@ jobs: echo "action=skip" >> "$GITHUB_OUTPUT" exit 0 fi - # Find the original pr-gate.yml run for THIS head SHA. event=pull_request - # only (pr-gate.yml also accepts workflow_dispatch, whose payload's - # --sha target is not the PR head). Most recently CREATED, per #11519: - # a rerun replays the frozen payload of its original event, so an older - # run for the same SHA is fine (same SHA, fresh API reads) but a run - # for an older SHA would re-aggregate a stale head. - RUN_JSON=$(gh api "repos/${REPO}/actions/runs?head_sha=${HEAD_SHA}&event=pull_request&per_page=100" \ - --jq '[.workflow_runs[] | select(.name == "PR gate")] | sort_by(.created_at) | last') - if [ -z "$RUN_JSON" ] || [ "$RUN_JSON" = "null" ]; then - # #16624: the OLD message here claimed "the gate will run on its - # own" -- FALSE for the retarget population. A PR retargeted onto - # main (edited, not in the pre-#16624 default types) never fired - # the gate and NOTHING will fire it: no push, no synchronize, and - # a close/reopen performed by a bot token triggers no run - # (GITHUB_TOKEN anti-recursion). The two honest paths are below. - EXISTING=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs?per_page=100" \ - --jq '[.check_runs[] | select(.name == "PR gate")] | length' 2>/dev/null || echo 1) - if [ "$EXISTING" != "0" ]; then - echo "[pr-gate] a 'PR gate' check-run already exists on ${HEAD_SHA} though no pull_request run does -- refusing to POST beside it (#11519 twin), skip" - echo "action=skip" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "[pr-gate] no pull_request PR gate run and no 'PR gate' check-run for ${HEAD_SHA} -- gate ABSENT (#16624): aggregating the head and POSTing the verdict" - echo "action=aggregate_absent" >> "$GITHUB_OUTPUT" - exit 0 - fi - RUN_ID=$(echo "$RUN_JSON" | jq -r '.id') - STATUS=$(echo "$RUN_JSON" | jq -r '.status') - CONCLUSION=$(echo "$RUN_JSON" | jq -r '.conclusion') - echo "[pr-gate] target run ${RUN_ID}: status=${STATUS} conclusion=${CONCLUSION}" - echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" - # Already running/queued: it will aggregate the current check state - # by itself -- rerunning now is impossible (API rejects it) and - # useless. This is also the loop bound for near-simultaneous guard - # completions (#11519: "borner la boucle"). - if [ "$STATUS" != "completed" ]; then - echo "[pr-gate] run in flight (${STATUS}) -- it will see the fresh guard verdict, skip" - echo "action=skip" >> "$GITHUB_OUTPUT" - exit 0 - fi - # Green already: this leg is not what blocks the PR. No rerun. - if [ "$CONCLUSION" = "success" ]; then - echo "[pr-gate] run already green -- nothing to rescue, skip" - echo "action=skip" >> "$GITHUB_OUTPUT" - exit 0 - fi - # Full rerun (NOT --failed): the gate is a single job, and a full - # rerun replays the aggregation end-to-end against live check state. - echo "action=rerun" >> "$GITHUB_OUTPUT" + # Route decision, cancel-and-wait for dead queued attempts included: + # no run found -> twin guard / gate-absent (#11519/#16624); queued + # beyond --stale-hours -> cancel + wait completed + rerun (#17680); + # fresh in-flight -> skip; completed -> rerun unless green. + python scripts/ci/pr_gate_route.py \ + --repo "$REPO" \ + --sha "$HEAD_SHA" \ + --pr-number "$PR_NUMBER" rerun: name: Re-run the original PR gate (manual) diff --git a/.github/workflows/pr-gate-stale-sweep.yml b/.github/workflows/pr-gate-stale-sweep.yml index 2b70b63750..f1aa01afdc 100644 --- a/.github/workflows/pr-gate-stale-sweep.yml +++ b/.github/workflows/pr-gate-stale-sweep.yml @@ -976,10 +976,23 @@ jobs: echo "[stale-sweep] (dry-run) would re-run #$NUM ($SHA) via run $RUN_ID" else echo "[stale-sweep] re-running gate for #$NUM ($SHA) -- run $RUN_ID" - # `|| echo`: one PR whose re-run is refused must not abort the loop - # nor redden a sweep whose other re-runs landed. - gh run rerun "$RUN_ID" --repo "$REPO" \ - || echo "[stale-sweep] re-run refused for #$NUM (non-fatal)" + # A refused re-run must not abort the loop nor redden a sweep + # whose other re-runs landed. #17680: the refusal is the + # dead-queued signature (the API rejects re-running a + # non-completed run) -- the run sits `queued` with an empty job + # list forever (#17099: 19h30). Dispatch the manual harness: + # its route (scripts/ci/pr_gate_route.py) cancels a stale + # queued attempt, waits for `completed`, then re-runs. Harmless + # on the other refusal cause (flipped to in_progress between + # read and rerun): the route sees a fresh in-flight run and + # skips. The harness is concurrency-grouped per PR, so repeated + # refusals cannot stampede. + if ! gh run rerun "$RUN_ID" --repo "$REPO"; then + echo "[stale-sweep] re-run refused for #$NUM (run $RUN_ID not completed) -- dispatching PR gate (re-aggregate) to revive it (#17680)" + gh workflow run pr-gate-rerun.yml --repo "$REPO" \ + -f pr_number="$NUM" -f head_sha="$SHA" \ + || echo "[stale-sweep] revive dispatch refused for #$NUM (non-fatal)" + fi fi if [ "${RANK:-1}" = "0" ]; then m=$((m + 1)); else i=$((i + 1)); fi done < /tmp/candidates.txt diff --git a/scripts/ci/pr_gate_route.py b/scripts/ci/pr_gate_route.py new file mode 100644 index 0000000000..a0c49a72e0 --- /dev/null +++ b/scripts/ci/pr_gate_route.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Route decision for pr-gate-rerun.yml -- revive dead `queued` attempts (#17680). + +Measured defect (2026-09-23/24): three rerun attempts stayed `queued` with an +empty job list for 19h30 (PR #17099 among them), while the queue served same-day +attempts in minutes. The resolve step read ANY `status != completed` as +"in flight -- it will see the fresh guard verdict, skip", so a queued attempt +GitHub has lost is indistinguishable from a healthy one by status alone. The PR +stays BLOCKED with zero red, and the stale-sweep's own `gh run rerun` is refused +by the API on a non-completed run -- a complete impasse. + +The whole route is extracted here so it is unit-testable (the workflow step is +a thin call): + + no run found -> a "PR gate" check-run already exists on the SHA ? + yes -> skip (never POST beside it, #11519 twin) + no -> aggregate_absent (#16624) + status != completed -> `queued` for longer than --stale-hours ? + yes -> cancel, wait for `completed` (bounded), + then action=rerun + no -> skip (genuinely in flight) + completed + success -> skip + completed + other -> rerun + +The stale threshold must sit above the measured queue latency +(scripts/ci/gh_queue_health.py); the default of 2 h follows the issue's +proposal (dead attempts measured at 19h30, healthy ones served in minutes). + +Bias: an unreadable API on the twin-guard probe counts as "a check-run +exists" (skip) -- same fail-closed choice the inline bash made (`|| echo 1`). +A revive that cannot reach `completed` within --wait-max-sec downgrades to +skip with a loud reason (the attempt was NOT cancelled into a rerunnable +state; re-running would be refused anyway) -- the next sweep re-dispatches. +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Callable + +DEFAULT_STALE_HOURS = 2.0 +DEFAULT_WAIT_MAX_SEC = 300 +DEFAULT_POLL_SEC = 10 +SELF_NAME = "PR gate" + + +class RouteError(RuntimeError): + """Fatal route failure (unreadable API on the primary lookups).""" + + +def _run_gh(args: list[str]) -> subprocess.CompletedProcess: + return subprocess.run( + args, capture_output=True, text=True, + encoding="utf-8", errors="replace", + ) + + +def gh_api(path: str) -> Any: + """GET `gh api ` and parse JSON. Any failure is fatal.""" + completed = _run_gh(["gh", "api", path]) + if completed.returncode != 0: + raise RouteError( + f"gh api {path} failed (exit {completed.returncode}): " + f"{completed.stderr.strip()[:400]}" + ) + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise RouteError(f"gh api {path} returned non-JSON: {exc}") from exc + + +def gh_api_lenient(path: str) -> Any: + """GET that returns None on any failure (probes whose failure means + "act as if the guard tripped", fail-closed).""" + try: + return gh_api(path) + except RouteError: + return None + + +def find_latest_pr_gate_run(repo: str, head_sha: str) -> dict | None: + """Most recently CREATED `PR gate` run with event=pull_request for the SHA. + + Per #11519: a rerun replays the frozen payload of its original event, so an + older run for the same SHA is fine but a run for an older SHA would + re-aggregate a stale head. + """ + payload = gh_api( + f"repos/{repo}/actions/runs?head_sha={head_sha}" + "&event=pull_request&per_page=100" + ) + runs = [ + run + for run in payload.get("workflow_runs", []) + if run.get("name") == SELF_NAME + ] + if not runs: + return None + return max(runs, key=lambda run: run.get("created_at") or "") + + +def existing_self_check_runs(repo: str, head_sha: str) -> int: + payload = gh_api_lenient( + f"repos/{repo}/commits/{head_sha}/check-runs?per_page=100" + ) + if payload is None: + # Fail-closed: unreadable == "a check-run exists" -> skip. + return 1 + return sum( + 1 for cr in payload.get("check_runs", []) if cr.get("name") == SELF_NAME + ) + + +def _parse_ts(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def attempt_started_at(run: dict) -> datetime | None: + """When the current attempt last moved. + + A queued attempt GitHub has lost never started: `run_started_at` is null, + so fall back to `updated_at` (bumped on the transition into queued), then + `created_at`. This is the honest "how long has it sat like this" clock. + """ + for key in ("run_started_at", "updated_at", "created_at"): + stamp = _parse_ts(run.get(key)) + if stamp is not None: + return stamp + return None + + +def _now() -> datetime: + """Injectable clock (unit tests pin it; #17680 verdicts are time-based).""" + return datetime.now(timezone.utc) + + +def classify(run: dict, now: datetime, stale_after: timedelta) -> tuple[str, str]: + """Pure decision for a FOUND run -> (action, reason). + + Actions: "rerun" (full re-run of the original), "cancel_rerun" (the + attempt is dead -- cancel it, wait, then re-run), "skip". + """ + status = run.get("status") + conclusion = run.get("conclusion") + if status != "completed": + started = attempt_started_at(run) + if ( + status == "queued" + and started is not None + and now - started > stale_after + ): + return ( + "cancel_rerun", + f"queued since {started.isoformat()} (> {stale_after}) -- " + "dead attempt, cancel then re-run (#17680)", + ) + return "skip", f"run in flight ({status}) -- it will see the fresh guard verdict" + if conclusion == "success": + return "skip", "run already green -- nothing to rescue" + return "rerun", f"completed with conclusion {conclusion}" + + +def cancel_run(repo: str, run_id: int) -> bool: + completed = _run_gh( + ["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/cancel"] + ) + return completed.returncode == 0 + + +def wait_completed( + run_id: int, + get_status: Callable[[int], str | None], + max_sec: int, + poll_sec: int, +) -> bool: + """Poll until the run reports `completed`. False on timeout.""" + deadline = datetime.now(timezone.utc) + timedelta(seconds=max_sec) + while datetime.now(timezone.utc) < deadline: + status = get_status(run_id) + if status == "completed": + return True + time.sleep(poll_sec) + return False + + +def _status_of(repo: str, run_id: int) -> str | None: + try: + return gh_api(f"repos/{repo}/actions/runs/{run_id}").get("status") + except RouteError: + return None + + +def _emit(action: str, run_id: int | None = None) -> None: + out = os.environ.get("GITHUB_OUTPUT") + lines = [f"action={action}"] + if run_id is not None: + lines.append(f"run_id={run_id}") + body = "\n".join(lines) + "\n" + if out: + with open(out, "a", encoding="utf-8") as handle: + handle.write(body) + else: + sys.stdout.write(body) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", required=True) + parser.add_argument("--sha", required=True, help="PR head SHA to re-aggregate") + parser.add_argument("--pr-number", default="", help="PR number (logs only)") + parser.add_argument( + "--stale-hours", + type=float, + default=DEFAULT_STALE_HOURS, + help="A queued attempt older than this is dead (default: %(default)s)", + ) + parser.add_argument( + "--wait-max-sec", + type=int, + default=DEFAULT_WAIT_MAX_SEC, + help="Bound on the cancel->completed wait (default: %(default)s)", + ) + parser.add_argument("--poll-sec", type=int, default=DEFAULT_POLL_SEC) + args = parser.parse_args(argv) + + tag = f"[pr-gate] #{args.pr_number} " if args.pr_number else "[pr-gate] " + run = find_latest_pr_gate_run(args.repo, args.sha) + if run is None: + if existing_self_check_runs(args.repo, args.sha) != 0: + print( + f"{tag}a 'PR gate' check-run already exists on {args.sha} though no pull_request run does -- refusing to POST beside it (#11519 twin), skip" + ) + _emit("skip") + return 0 + print( + f"{tag}no pull_request PR gate run and no 'PR gate' check-run for " + f"{args.sha} -- gate ABSENT (#16624): aggregating the head and " + "POSTing the verdict" + ) + _emit("aggregate_absent") + return 0 + + run_id = int(run["id"]) + print(f"{tag}target run {run_id}: status={run.get('status')} " + f"conclusion={run.get('conclusion')}") + action, reason = classify(run, _now(), timedelta(hours=args.stale_hours)) + + if action == "cancel_rerun": + print(f"{tag}{reason}") + if not cancel_run(args.repo, run_id): + print(f"{tag}cancel refused for run {run_id} -- skip (next sweep retries)") + _emit("skip", run_id) + return 0 + if not wait_completed( + run_id, + lambda rid: _status_of(args.repo, rid), + args.wait_max_sec, + args.poll_sec, + ): + print( + f"{tag}run {run_id} did not reach completed within " + f"{args.wait_max_sec}s after cancel -- skip (next sweep retries)" + ) + _emit("skip", run_id) + return 0 + print(f"{tag}dead attempt cancelled and completed -- proceeding to re-run") + action = "rerun" + + print(f"{tag}{reason} -> action={action}") + _emit(action, run_id) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except RouteError as exc: + print(f"[pr-gate] FATAL: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/tests/test_pr_gate_rerun_noop_guard.py b/scripts/tests/test_pr_gate_rerun_noop_guard.py index 75335db2c5..207a81d895 100644 --- a/scripts/tests/test_pr_gate_rerun_noop_guard.py +++ b/scripts/tests/test_pr_gate_rerun_noop_guard.py @@ -109,11 +109,22 @@ def test_absent_leg_never_posts_beside_an_existing_gate_check_run(): """Garde structurale anti-jumeau (#11519) : la route aggregate_absent n'est prise QUE si aucun check-run « PR gate » n'existe deja sur la tete. Sans elle, un POST pourrait se poser a cote d'un verdict existant et - l'AND de GitHub garderait la PR bloquee.""" - text = WORKFLOW.read_text(encoding="utf-8") - assert "refusing to POST beside it (#11519 twin)" in text, ( - "la garde anti-jumeau du resolve a disparu : la jambe absent pourrait " - "creer le doublon que #11519 documente" + l'AND de GitHub garderait la PR bloquee. #17680 : la route vit desormais + dans scripts/ci/pr_gate_route.py -- la garde a demenage avec elle.""" + route_script = ( + Path(__file__).resolve().parents[2] / "scripts" / "ci" / "pr_gate_route.py" + ) + assert "refusing to POST beside it (#11519 twin)" in route_script.read_text( + encoding="utf-8" + ), ( + "la garde anti-jumeau du resolve a disparu du script de route : la " + "jambe absent pourrait creer le doublon que #11519 documente" + ) + # Le workflow doit bien appeler CE script (sinon la garde testee n'est + # pas celle qui s'execute). + assert "scripts/ci/pr_gate_route.py" in str(_load().get("jobs", {})), ( + "le resolve n'appelle plus pr_gate_route.py : la garde anti-jumeau " + "testee dans le script n'est pas celle qui tourne" ) diff --git a/scripts/tests/test_pr_gate_route.py b/scripts/tests/test_pr_gate_route.py new file mode 100644 index 0000000000..8921fa2e4c --- /dev/null +++ b/scripts/tests/test_pr_gate_route.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Tests for scripts/ci/pr_gate_route.py -- dead `queued` attempts (#17680). + +Fondateur (mesure 2026-09-23/24, ai-01) : trois tentatives de rerun du workflow +`PR gate` restees `queued` (liste de jobs vide) pendant 19h30, dont celle de +#17099 -- PR READY, gate rc=0, mais `mergeStateStatus: BLOCKED` sans aucun +rouge. La route inline traitait TOUT `status != completed` comme « en vol », +et le sweep voyait son `gh run rerun` refuse par l'API sur un run non termine : +impasse complete. La route est extraite dans le script pour etre testable -- +ces tests sont le critere d'acceptation de l'issue. + +Run: + python -m pytest scripts/tests/test_pr_gate_route.py +""" +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "scripts" / "ci")) + +import pr_gate_route as route # noqa: E402 + +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr-gate-rerun.yml" +SWEEP = REPO_ROOT / ".github" / "workflows" / "pr-gate-stale-sweep.yml" + +NOW = datetime(2026, 9, 24, 14, 0, 0, tzinfo=timezone.utc) + + +def run_payload( + status="queued", + conclusion=None, + run_started_at=None, + updated_at=None, + created_at="2026-09-23T18:52:00Z", + rid=35895035044, +): + return { + "id": rid, + "name": "PR gate", + "status": status, + "conclusion": conclusion, + "run_started_at": run_started_at, + "updated_at": updated_at, + "created_at": created_at, + } + + +# --- decision pure (classify) ------------------------------------------------ + + +def test_classify_queued_beyond_threshold_is_dead(): + """Une tentative queued de plus de N heures n'est plus « en vol » -- c'est + le coeur de #17680 : le statut seul ne distingue pas une tentative morte + d'une saine, l'horloge si.""" + run = run_payload( + run_started_at=None, + updated_at="2026-09-23T18:52:00Z", # ~19h avant NOW + ) + action, reason = route.classify(run, NOW, timedelta(hours=2)) + assert action == "cancel_rerun" + assert "#17680" in reason + + +def test_classify_recent_queued_is_in_flight(): + run = run_payload(run_started_at="2026-09-24T13:55:00Z") # 5 min avant NOW + action, _ = route.classify(run, NOW, timedelta(hours=2)) + assert action == "skip" + + +def test_classify_in_progress_is_in_flight_even_after_hours(): + """Seule la file d'attente peut mourir de faim silencieusement ; un run + in_progress a un runner et rapportera.""" + run = run_payload(status="in_progress", run_started_at="2026-09-20T00:00:00Z") + action, _ = route.classify(run, NOW, timedelta(hours=2)) + assert action == "skip" + + +def test_classify_completed_failure_reruns_and_success_skips(): + assert route.classify(run_payload(status="completed", conclusion="failure"), NOW, timedelta(hours=2))[0] == "rerun" + assert route.classify(run_payload(status="completed", conclusion="success"), NOW, timedelta(hours=2))[0] == "skip" + + +def test_classify_boundary_exactly_at_threshold_stays_in_flight(): + """Exactement N heures = pas encore morte (comparaison stricte) : biaiser + vers « en vol » quand l'horloge est a la limite.""" + run = run_payload(run_started_at=(NOW - timedelta(hours=2)).isoformat()) + action, _ = route.classify(run, NOW, timedelta(hours=2)) + assert action == "skip" + + +def test_attempt_started_at_falls_back_when_never_started(): + """Une tentative queued jamais demarree n'a pas de run_started_at : l'horloge + tombe sur updated_at (transition vers queued), puis created_at.""" + assert route.attempt_started_at( + run_payload(run_started_at=None, updated_at="2026-09-24T10:00:00Z") + ) == datetime(2026, 9, 24, 10, 0, tzinfo=timezone.utc) + assert route.attempt_started_at( + run_payload(run_started_at=None, updated_at=None, created_at="2026-09-24T09:00:00Z") + ) == datetime(2026, 9, 24, 9, 0, tzinfo=timezone.utc) + + +# --- route end-to-end (main, gh fake) ---------------------------------------- + + +class FakeGh: + """Dispatch gh_api/_run_gh par path ; enregistre les cancel.""" + + def __init__(self, run=None, check_runs=(), statuses=()): + self.run = run + self.check_runs = list(check_runs) + self.statuses = list(statuses) # consommes par les sondes post-cancel + self.cancels = [] + self.lookups = [] + + def api(self, path): + self.lookups.append(path) + if "actions/runs?" in path: + return {"workflow_runs": [self.run] if self.run else []} + if "check-runs" in path: + return {"check_runs": [{"name": name} for name in self.check_runs]} + if "/actions/runs/" in path: + if self.statuses: + return {"status": self.statuses.pop(0)} + return {"status": "completed"} + raise AssertionError(f"chemin inattendu: {path}") + + def run_gh(self, args): + if args[-1].endswith("/cancel"): + self.cancels.append(args[-1]) + import types + + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + raise AssertionError(f"appel _run_gh inattendu: {args}") + + +def _drive(monkeypatch, tmp_path, fake, sha="abc123", extra=None): + out = tmp_path / "gh_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(out)) + monkeypatch.setattr(route, "gh_api", fake.api) + monkeypatch.setattr(route, "_run_gh", fake.run_gh) + monkeypatch.setattr(route, "_now", lambda: NOW) + monkeypatch.setattr(route, "_status_of", lambda repo, rid: fake.api(f"repos/x/actions/runs/{rid}").get("status")) + argv = ["--repo", "jsboige/CoursIA", "--sha", sha, "--pr-number", "17680", + "--wait-max-sec", "5", "--poll-sec", "0"] + if extra: + argv += extra + rc = route.main(argv) + action = "" + run_id = "" + for line in out.read_text(encoding="utf-8").splitlines(): + if line.startswith("action="): + action = line.split("=", 1)[1] + if line.startswith("run_id="): + run_id = line.split("=", 1)[1] + return rc, action, run_id + + +def test_stale_queued_attempt_is_cancelled_then_rerun(monkeypatch, tmp_path): + """Critere d'acceptation 1 de #17680 : une tentative queued vieille de plus + de N heures -> action=rerun APRES cancel (le run doit etre completed pour + que l'API accepte le rerun).""" + fake = FakeGh( + run=run_payload(run_started_at=None, updated_at="2026-09-23T18:52:00Z"), + statuses=["queued", "cancelling", "completed"], + ) + rc, action, run_id = _drive(monkeypatch, tmp_path, fake) + assert rc == 0 + assert action == "rerun" + assert run_id == "35895035044" + assert fake.cancels == ["repos/jsboige/CoursIA/actions/runs/35895035044/cancel"] + + +def test_recent_queued_attempt_skips_without_cancel(monkeypatch, tmp_path): + """Critere d'acceptation 2 : tentative queued recente -> action=skip, et + AUCUN cancel emis.""" + fake = FakeGh(run=run_payload(run_started_at=(NOW - timedelta(minutes=5)).isoformat())) + _rc, action, _rid = _drive(monkeypatch, tmp_path, fake) + assert action == "skip" + assert fake.cancels == [] + + +def test_revive_timeout_downgrades_to_skip(monkeypatch, tmp_path): + """Le cancel ne converge pas vers completed dans la borne : skip honnete + (le rerun serait refuse de toute facon), le prochain sweep re-dispatch.""" + statuses = ["cancelling"] * 50 + fake = FakeGh( + run=run_payload(run_started_at=None, updated_at="2026-09-23T18:52:00Z"), + statuses=statuses, + ) + _rc, action, _rid = _drive(monkeypatch, tmp_path, fake, extra=["--wait-max-sec", "0"]) + assert action == "skip" + assert fake.cancels # le cancel a bien ete tente + + +def test_completed_failure_routes_rerun_no_cancel(monkeypatch, tmp_path): + fake = FakeGh(run=run_payload(status="completed", conclusion="failure")) + _rc, action, _rid = _drive(monkeypatch, tmp_path, fake) + assert action == "rerun" + assert fake.cancels == [] + + +def test_no_run_with_existing_check_run_refuses_twin(monkeypatch, tmp_path): + """Garde anti-jumeau #11519 preserve par l'extraction : un check-run + « PR gate » deja present sur la tete sans run pull_request -> skip.""" + fake = FakeGh(run=None, check_runs=["PR gate"]) + _rc, action, _rid = _drive(monkeypatch, tmp_path, fake) + assert action == "skip" + + +def test_no_run_without_check_run_aggregates_absent(monkeypatch, tmp_path): + fake = FakeGh(run=None, check_runs=[]) + _rc, action, _rid = _drive(monkeypatch, tmp_path, fake) + assert action == "aggregate_absent" + + +# --- wiring ------------------------------------------------------------------ + + +def _load(path): + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def test_rerun_workflow_resolves_via_the_script(): + """La route extraite doit etre celle que le workflow appelle -- un retour + au bash inline re-ouvrirait l'angle mort non testable (#17680).""" + data = _load(WORKFLOW) + resolve = data["jobs"]["resolve"] + text = str(resolve) + assert "scripts/ci/pr_gate_route.py" in text, ( + "le resolve n'appelle plus pr_gate_route.py : la decision de route " + "doit vivre dans le script teste, pas en bash inline" + ) + assert "actions: write" in str(data.get("permissions", {})) or \ + data.get("permissions", {}).get("actions") == "write" + + +def test_twin_guard_string_lives_in_the_route_script(): + """La garde anti-jumeau a demenage avec la route : le message pinné doit + exister dans le script (le wiring noop-guard pointe ici aussi).""" + text = (REPO_ROOT / "scripts" / "ci" / "pr_gate_route.py").read_text(encoding="utf-8") + assert "refusing to POST beside it (#11519 twin)" in text + + +def test_sweep_refusal_falls_back_to_dispatching_the_harness(): + """Le sweep ne peut pas annuler lui-meme (pas de checkout python dans sa + boucle) : un rerun refuse doit dispatcher le harnais, dont la route sait + revivre une tentative morte -- sinon l'impasse decrite dans #17680 + persiste cote organes autonomes.""" + text = SWEEP.read_text(encoding="utf-8") + assert "gh workflow run pr-gate-rerun.yml" in text, ( + "le fallback #17680 a disparu du sweep : un rerun refuse (tentative " + "queued morte) laisse la PR bloquee sans aucun organe pour la revivre" + ) + assert "-f pr_number=" in text and "-f head_sha=" in text + + +def test_false_skip_message_stays_gone_everywhere(): + """Le message mensonger pre-#16624 ne doit revenir ni dans le workflow ni + dans le script extrait.""" + for path in (WORKFLOW, REPO_ROOT / "scripts" / "ci" / "pr_gate_route.py"): + assert "the gate will run on its own" not in path.read_text(encoding="utf-8")