From fa491979b8022f7ed9428310c1dcb18eeb8c25c5 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 9 Sep 2026 11:25:12 +0200 Subject: [PATCH 1/3] feat(ci,#15201): organe de pin des runners Linux -- compare la pin a l'exigence GitHub (advisory) Le compromis #15182 (--disableupdate) fige le runner a l'image; le rebuild etant manuel, l'ecart avec l'exigence GitHub grandit sans rien qui le voie: les slots restent online et c'est GitHub qui refuse de leur confier un job. Mesure fond (entrypoint.sh:75-85): image 2.336.0 vs exigence 2.337.0, ~1,14 Tio d'egress perdue avant le pivot. Nouveau scripts/ci/check_runner_version_pin.py (rc 0/1/2: PIN_OK / PIN_STALE|IN_PHASE_FAILURE / UNCHECKED_REQUIREMENT) + 7 tests + workflow advisory sur la jambe Linux (cron 23,53, doctrine #12817, jamais bloquant). Co-Authored-By: Claude-Code --- .../linux-runner-version-pin-advisory.yml | 64 ++++++ scripts/ci/check_runner_version_pin.py | 203 ++++++++++++++++++ .../tests/test_check_runner_version_pin.py | 134 ++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 .github/workflows/linux-runner-version-pin-advisory.yml create mode 100644 scripts/ci/check_runner_version_pin.py create mode 100644 scripts/tests/test_check_runner_version_pin.py diff --git a/.github/workflows/linux-runner-version-pin-advisory.yml b/.github/workflows/linux-runner-version-pin-advisory.yml new file mode 100644 index 0000000000..e1963d9f26 --- /dev/null +++ b/.github/workflows/linux-runner-version-pin-advisory.yml @@ -0,0 +1,64 @@ +name: Linux runner version pin advisory + +# Organe de pin de version des runners Linux self-hosted (#15201, suivi du +# compromis #15182 -- reserve n.2 de la review NanoClaw, declaree mineure +# mais qui nomme une panne reelle). +# +# Le compromis : `--disableupdate` dans l'entrypoint fige la version du +# runner a celle de l'image (le self-update du runner --ephemeral ne serait +# jamais conserve : le conteneur meurt avant, --rm efface le tarball). Le +# geste de bump est un REBUILD image (ARG RUNNER_VERSION du Dockerfile), +# documente en un seul endroit : le docblock de entrypoint.sh. Cf #15153, +# #15164. +# +# Le cout cache : entre deux rebuilds, si GitHub releve sa version requise, +# les slots restent ENREGISTRES et `online` -- et c'est GitHub qui refuse de +# leur confier un job ("required runner version"). Vu du superviseur, rien +# ne rougit : ni conteneur en erreur, ni boucle, ni sentinel. Mesure fond : +# le 2026-09-08 (pivot au rebuild), l'image epinglee 2.336.0 alors que +# GitHub exigeait 2.337.0 -- 5566 re-telechargements inutiles (~1,14 Tio, +# entrypoint.sh:75-85). Le mode de panne s'appelle "online mais non +# eligible" : un organe qui dit "online" ne mesure pas "eligible". +# +# Cet organe compare la pin (4 sites : Dockerfile ARG, Dockerfile.lean FROM, +# supervise.sh IMAGE/LEAN_IMAGE) a l'exigence courante +# (`repos/actions/runner/releases/latest`, via gh + GITHUB_TOKEN). Sorties : +# rc 0 pin a jour et en phase ; rc 1 PIN_STALE ou IN_PHASE_FAILURE ; rc 2 +# exigence illisible -- un defaut de lecture ne justifie JAMAIS un vert. +# +# Advisory PAR CONSTRUCTION (doctrine #12817) : schedule + workflow_dispatch +# uniquement, jamais pull_request/push -- un run rouge ne bloque aucune PR. +# Cron a l'offset des sweeps 13,43 et du starvation 19,49 (jamais de +# collision). runs-on sur la jambe Linux self-hebergee (meme garde anti-fork +# que le starvation) : l'image runner porte gh + python3, zéro dependance. + +on: + schedule: + - cron: '23,53 * * * *' + workflow_dispatch: + +permissions: + contents: read # checkout du script + lecture des fichiers de pin + +concurrency: + group: linux-runner-version-pin-advisory + cancel-in-progress: false + +jobs: + version-pin: + name: Linux runner version pin advisory + runs-on: [self-hosted, coursia-ephemeral, coursia-linux] + if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + # L'organe ne lit que scripts/ci (le predicat + les fichiers de pin) : + # clone partiel, pas de contenu hors portee (serie #11843). + sparse-checkout: | + scripts/ci + - name: Compare runner version pin to GitHub requirement + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/ci/check_runner_version_pin.py \ No newline at end of file diff --git a/scripts/ci/check_runner_version_pin.py b/scripts/ci/check_runner_version_pin.py new file mode 100644 index 0000000000..e4ac9e263e --- /dev/null +++ b/scripts/ci/check_runner_version_pin.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Organe de pin de version des runners Linux self-hosted (#15201). + +Le compromis accepte (#15182) porte un cout cache : `--disableupdate` fige +la version du runner a celle de l'image. Le rebuild etant manuel, l'ecart +entre la version epinglee et l'exigence GitHub courante grandit sans que +rien ne le voie — le mode de panne est SILENCIEUX du point de vue du parc : +les slots restent enregistres et `online`, et c'est GitHub qui refuse de +leur confier un job ("required runner version"). Vu du superviseur, rien ne +rougit : ni conteneur en erreur, ni boucle, ni sentinel. Un organe qui dit +"online" ne mesure pas "eligible". + +Cet organe compare la version epinglee a l'exigence GitHub courante +(`repos/actions/runner/releases/latest`) et nomme l'ecart. Il verifie aussi +que les quatre sites de la pin sont en phase (la "verification +cross-fichiers" de la review) : + +- `scripts/ci/docker/linux-runner/Dockerfile` : `ARG RUNNER_VERSION=` +- `scripts/ci/docker/linux-runner/Dockerfile.lean`: `FROM coursia-linux-runner:` +- `scripts/ci/docker/linux-runner/supervise.sh` : `COURSIA_RUNNER_IMAGE` / + `COURSIA_LEAN_RUNNER_IMAGE` +- `scripts/ci/docker/linux-runner/entrypoint.sh` : prose (le geste de bump + vit ici, en UN seul endroit) + +Sorties : + +- rc 0 : pin a jour et en phase — l'exigence courante est <= pinnee. +- rc 1 : PIN_STALE (pin < exigence GitHub courante) ou IN_PHASE_FAILURE + (les sites ne s'accordent pas). Le rapport nomme le mode de panne + "online mais non eligible". +- rc 2 : UNKNOWN — l'exigence GitHub n'a pas pu etre lue (403 / timeout / + reseau). Une lecture ratee n'est pas une absence mesuree : on ne rend + jamais "a jour" sur un defaut de lecture. + +Run (CI) : + python scripts/ci/check_runner_version_pin.py [--repo-root DIR] + [--required-version X.Y.Z] # injection test, sinon GitHub API +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Sites machine-assertables de la pin. Chaque site expose son versionneur. +PIN_SITES = { + "Dockerfile:ARG": lambda text: _first( + re.search(r"^ARG RUNNER_VERSION=([0-9]+\.[0-9]+\.[0-9]+)", text, re.M) + ), + "Dockerfile.lean:FROM": lambda text: _first( + re.search( + r"^FROM coursia-linux-runner:([0-9]+\.[0-9]+\.[0-9]+)", text, re.M + ) + ), + "supervise.sh:IMAGE": lambda text: _first( + re.search( + r"COURSIA_RUNNER_IMAGE:-coursia-linux-runner:([0-9]+\.[0-9]+\.[0-9]+)", + text, + ) + ), + "supervise.sh:LEAN_IMAGE": lambda text: _first( + re.search( + r"COURSIA_LEAN_RUNNER_IMAGE:-coursia-lean-runner:([0-9]+\.[0-9]+\.[0-9]+)", + text, + ) + ), +} + + +def _first(m: re.Match | None) -> str | None: + return m.group(1) if m else None + + +def parse_pins(repo_root: Path) -> dict[str, str | None]: + """Lit la pin de chaque site machine-assertable du runner dir. + + `--repo-root` est le point d'entree : les tests hermétiques injectent un + faux depot, le CI laisse le defaut derive de `__file__`. + """ + runner_dir = repo_root / "scripts" / "ci" / "docker" / "linux-runner" + pins: dict[str, str | None] = {} + for site, versionner in PIN_SITES.items(): + filename = site.split(":", 1)[0] + try: + pins[site] = versionner((runner_dir / filename).read_text(encoding="utf-8")) + except OSError as e: + pins[site] = f"" + return pins + + +def fetch_required_version(gh: list[str] | None = None) -> str | None: + """Exigence GitHub courante via `gh api repos/actions/runner/releases/latest`. + + Retourne None si la lecture echoue (jamais de valeur par defaut : un 403 + est une question, pas une absence mesuree, cf. #15342). + """ + cmd = gh or ["gh", "api", "repos/actions/runner/releases/latest", "--jq", ".tag_name"] + try: + r = subprocess.run( + cmd, capture_output=True, text=True, encoding="utf-8", timeout=30 + ) + except (OSError, subprocess.TimeoutExpired): + return None + if r.returncode != 0: + return None + m = re.search(r"v?([0-9]+\.[0-9]+\.[0-9]+)", r.stdout.strip()) + return m.group(1) if m else None + + +def version_key(v: str) -> tuple[int, ...]: + return tuple(int(x) for x in v.split(".")) + + +def report(pins: dict[str, str | None], required: str | None) -> dict: + """Verdict de l'organe : phase + ecart vis-a-vis de l'exigence GitHub.""" + # Une valeur non-versionnee (lecture impossible, regex sans hit) est une + # erreur de mesure, pas une pin : on ne peut pas la comparer. + bad = [ + s + for s, v in pins.items() + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", v or "") + ] + if bad: + return { + "guard_pass": False, + "status": "IN_PHASE_FAILURE", + "detail": "site(s) de pin illisible ou non versionne : " + + ", ".join(bad), + "pins": pins, + "required_version": required, + "exit_code": 1, + } + values = list(pins.values()) + if len(set(values)) > 1: + return { + "guard_pass": False, + "status": "IN_PHASE_FAILURE", + "detail": "les sites de pin ne s'accordent pas", + "pins": pins, + "required_version": required, + "exit_code": 1, + } + pinned = values[0] + if required is None: + return { + # Rien a comparer : ni pass ni fail -- un defaut de lecture ne + # justifie pas un vert (exit 2 le fait rougir en CI, advisory). + "guard_pass": None, + "status": "UNCHECKED_REQUIREMENT", + "detail": "exigence GitHub illisible (403/timeout) — aucun verdict", + "pins": pins, + "pinned_version": pinned, + "required_version": None, + "exit_code": 2, + } + stale = version_key(pinned) < version_key(required) + return { + "guard_pass": not stale, + "status": "PIN_STALE" if stale else "PIN_OK", + "detail": ( + "online mais non eligible : la pin " + f"{pinned} est derriere l'exigence GitHub {required} — bump par " + "rebuild (ARG RUNNER_VERSION du Dockerfile), jamais a chaud" + if stale + else f"pin {pinned} >= exigence {required}" + ), + "pins": pins, + "pinned_version": pinned, + "required_version": required, + "exit_code": 1 if stale else 0, + } + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--repo-root", type=Path, default=REPO_ROOT) + ap.add_argument("--required-version", default=None, + help="exigence GitHub (injection test; defaut: API live)") + args = ap.parse_args(argv) + + pins = parse_pins(args.repo_root) + required = args.required_version or fetch_required_version() + verdict = report(pins, required) + + print(json.dumps(verdict, indent=2, ensure_ascii=False)) + if verdict["exit_code"] == 1: + print(f"[RUNNER-VERSION-PIN] {verdict['status']} : {verdict['detail']}") + elif verdict["exit_code"] == 2: + print("[RUNNER-VERSION-PIN] UNKNOWN : exigence GitHub illisible — " + "enqueter avant de conclure quoi que ce soit.") + else: + print(f"[RUNNER-VERSION-PIN] {verdict['status']} : {verdict['detail']}") + return verdict["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/scripts/tests/test_check_runner_version_pin.py b/scripts/tests/test_check_runner_version_pin.py new file mode 100644 index 0000000000..9dda8f71e6 --- /dev/null +++ b/scripts/tests/test_check_runner_version_pin.py @@ -0,0 +1,134 @@ +"""Non-regression #15201: l'organe de pin des runners Linux. + +Le compromis #15182 (`--disableupdate`) fige la version du runner a celle +de l'image ; le rebuild etant manuel, l'ecart avec l'exigence GitHub courante +grandit SANS RIEN QUI LE VOIE : les slots restent `online`, c'est GitHub qui +refuse de leur confier un job. L'organe `check_runner_version_pin.py` doit : +- comparer la pin (4 sites) a l'exigence GitHub courante et rouge quand + l'ecart est bloquant (mode de panne nomme : "online mais non eligible") ; +- verifier que les sites de la pin sont en phase (verification + cross-fichiers) ; +- ne JAMAIS rendre "a jour" sur un defaut de lecture de l'exigence (rc 2). + +Levolet 1 est hermétique (faux depot injecte via --repo-root et +--required-version, API fakee par injection de commande). Le volet 2 est le +controle positif du repo reel : les 4 sites du depôt doivent s'accorder. + +Run: + python -m pytest scripts/tests/test_check_runner_version_pin.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +SCRIPT = ( + Path(__file__).resolve().parents[2] + / "scripts" + / "ci" + / "check_runner_version_pin.py" +) +sys.path.insert(0, str(SCRIPT.parent)) + +import check_runner_version_pin as organ # noqa: E402 + + +def _fake_repo(tmp_path: Path, versions: dict[str, str] | None = None) -> Path: + """Faux depot : le runner dir avec les 4 fichiers machine-assertables.""" + runner_dir = tmp_path / "scripts" / "ci" / "docker" / "linux-runner" + runner_dir.mkdir(parents=True) + v = versions or {} + (runner_dir / "Dockerfile").write_text( + "FROM ubuntu:24.04\n" + f"ARG RUNNER_VERSION={v.get('Dockerfile', '2.337.0')}\n", + encoding="utf-8", + ) + (runner_dir / "Dockerfile.lean").write_text( + f"FROM coursia-linux-runner:{v.get('Dockerfile.lean', '2.337.0')}\n", + encoding="utf-8", + ) + (runner_dir / "supervise.sh").write_text( + 'IMAGE="${COURSIA_RUNNER_IMAGE:-coursia-linux-runner:' + f"{v.get('supervise.sh:IMAGE', '2.337.0')}}}\"\n" + 'LEAN_IMAGE="${COURSIA_LEAN_RUNNER_IMAGE:-coursia-lean-runner:' + f"{v.get('supervise.sh:LEAN_IMAGE', '2.337.0')}}}\"\n", + encoding="utf-8", + ) + return tmp_path + + +# --- volet 1 : verdicts, depot fake ------------------------------------------ + +def test_pin_a_jour_et_en_phase_rend_rc0(tmp_path: Path): + repo = _fake_repo(tmp_path) + pins = organ.parse_pins(repo) + verdict = organ.report(pins, required="2.337.0") + assert verdict["status"] == "PIN_OK" + assert verdict["exit_code"] == 0 + assert verdict["pinned_version"] == "2.337.0" + + +def test_exigence_superieure_rouge_pin_stale(tmp_path: Path): + """GitHub exige 2.338.0, la pin est a 2.337.0 : le slot est en ligne mais + ne recevra plus de job -- le rapport doit nommer le mode de panne.""" + repo = _fake_repo(tmp_path) + verdict = organ.report(organ.parse_pins(repo), required="2.338.0") + assert verdict["status"] == "PIN_STALE" + assert verdict["exit_code"] == 1 + assert "online mais non eligible" in verdict["detail"] + assert "2.338.0" in verdict["detail"] + + +def test_sites_desaccordes_rend_in_phase_failure(tmp_path: Path): + """Un seul site en retard (supervise.sh IMAGE 2.336.0) : les autres sites + ne peuvent pas etre la verite -- l'image rebuild est une seule.""" + repo = _fake_repo(tmp_path, {"supervise.sh:IMAGE": "2.336.0"}) + verdict = organ.report(organ.parse_pins(repo), required="2.337.0") + assert verdict["status"] == "IN_PHASE_FAILURE" + assert verdict["exit_code"] == 1 + + +def test_site_manquant_est_une_erreur_de_mesure_pas_une_pin(tmp_path: Path): + """Dockerfile.lean absent : IN_PHASE_FAILURE qui nomme le site -- jamais + un vert sur une pin incompletement lue.""" + repo = _fake_repo(tmp_path) + (repo / "scripts" / "ci" / "docker" / "linux-runner" / "Dockerfile.lean").unlink() + verdict = organ.report(organ.parse_pins(repo), required="2.337.0") + assert verdict["status"] == "IN_PHASE_FAILURE" + assert "Dockerfile.lean" in verdict["detail"] + assert verdict["exit_code"] == 1 + + +def test_exigence_illisible_rend_rc2_pas_un_vert(tmp_path: Path): + """API injoignable (None) : aucun verdict possible -- rc 2, le rapport le + dit explicitement au lieu de pretender que la pin est a jour.""" + repo = _fake_repo(tmp_path) + verdict = organ.report(organ.parse_pins(repo), required=None) + assert verdict["status"] == "UNCHECKED_REQUIREMENT" + assert verdict["exit_code"] == 2 + assert verdict["guard_pass"] is None + + +def test_fetch_required_version_parse_et_echoue(tmp_path: Path): + """Injection de commande : `gh api` attendu rend v2.337.0 ; une commande + qui echoue (403/timeout) rend None, jamais une valeur par defaut.""" + ok = organ.fetch_required_version( + gh=[sys.executable, "-c", 'print("v2.337.0")'] + ) + assert ok == "2.337.0" + fail = organ.fetch_required_version( + gh=[sys.executable, "-c", "import sys; sys.exit(1)"] + ) + assert fail is None + + +# --- volet 2 : controle positif du depot reel --------------------------------- + +def test_repo_reel_sites_en_phase(): + """Les 4 sites du depôt s'accordent (le controle positif de l'organe + dans sa forme la plus simple) -- si ce test rouge, l'image n'est PAS une + seule version et les slots portent des binaires differents.""" + pins = organ.parse_pins(organ.REPO_ROOT) + assert len(set(pins.values())) == 1, f"sites desaccordes : {pins}" \ No newline at end of file From bf07cb461371e03a8707d486bea88e3dc3ac3784 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 9 Sep 2026 15:18:08 +0200 Subject: [PATCH 2/3] fix(ci,#15201): allowlist linux-runner-version-pin-advisory in isolation policy The new self-hosted workflow was rejected by check_self_hosted_runner_policy (WORKFLOW_NOT_ALLOWED) -- the single red of Scripts Tests (CPU) on #15366. Entry documented per allowlist tranche conventions: schedule+workflow_dispatch only (advisory by construction, #12817), static runs-on labels, job-level same-repo guard, run GITHUB_TOKEN read-only (contents: read, releases/latest). Rollback = revert of this PR. Co-Authored-By: Claude-Code --- scripts/ci/check_self_hosted_runner_policy.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/ci/check_self_hosted_runner_policy.py b/scripts/ci/check_self_hosted_runner_policy.py index ab7d4f3954..7a0595c802 100644 --- a/scripts/ci/check_self_hosted_runner_policy.py +++ b/scripts/ci/check_self_hosted_runner_policy.py @@ -208,6 +208,16 @@ "fabricated-output-gate.yml", "fast-lane-shadow.yml", "lane-claim-guard.yml", + # linux-runner-version-pin-advisory.yml (#15201, owner + # myia-po-2023:CoursIA) : organe cron schedule+workflow_dispatch + # UNIQUEMENT (advisory par construction, doctrine #12817), runs-on + # statique [self-hosted, coursia-ephemeral, coursia-linux], garde + # same-repo au niveau job malgre l'absence de trigger pull_request. + # Pur-Python stdlib + gh binaire de l'image ; le GITHUB_TOKEN du run + # ne sert qu'a LIRE releases/latest (permissions: contents: read). + # Cron 23,53 -- offset des sweeps 13,43 et du starvation 19,49. + # Rollback = revert de la PR (l'entree disparait de l'allowlist). + "linux-runner-version-pin-advisory.yml", "linux-runner-starvation-advisory.yml", "markdown-rendering-guard.yml", "markdown-table-guard.yml", From 494ec92abe3a6b8c3a0e41d9266b96a3f8aa7e98 Mon Sep 17 00:00:00 2001 From: jsboige Date: Fri, 11 Sep 2026 12:17:16 +0200 Subject: [PATCH 3/3] fix(ci,#15201): nommer le mesure dans l'organe de pin des runners (review #15366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La review de #15366 a releve que `fetch_required_version()` lit `repos/actions/runner/releases/latest` et le traite comme « l'exigence GitHub courante » : la derniere release publiee n'est pas la version minimale exigee, et l'organe affirmait donc un fait qu'il ne mesure pas — exactement le travers qu'il denonce. Remede (a) de la review : nommer le mesure. - `report()` : le detail de PIN_STALE dit desormais « pin en retard sur la derniere release publiee (X < Y) — l'ineligibilite qui peut en decouler n'est PAS mesuree ici (GitHub n'expose pas l'exigence minimale) ». Le PIN_OK dit « >= derniere release publiee ». La consequence supposee ("online mais non eligible") n'est plus affirmee ; le mode de panne reste decrit comme scenario dans la prose historique (mesure 2026-09-08). - `fetch_required_version` -> `fetch_latest_release` : le nom dit ce qui est lu ; docstring explicite le statut de PROXY. - Docstrings (module, `report`) et en-tete du workflow advisory alignes sur le meme fait mesure. - Nit de la review : `args.required_version or fetch_required_version()` faisait retomber un `--required-version ""` sur l'appel API live. Desormais refus explicite (argparse rc 2), sans appel reseau. - Tests : assertion de l'ancien libelle remplacee par les nouvelles (fait mesure present, "non eligible" absent), test du fetch renomme, nouveau test de refus de la chaine vide (aucun appel API). scripts/tests/test_check_runner_version_pin.py : 8 passed. scripts/tests/test_check_self_hosted_runner_policy.py + test_check_runner_starvation.py : 82 passed. Co-Authored-By: Claude Sonnet 5 --- .../linux-runner-version-pin-advisory.yml | 11 ++- scripts/ci/check_runner_version_pin.py | 84 ++++++++++++------- .../tests/test_check_runner_version_pin.py | 46 ++++++---- 3 files changed, 94 insertions(+), 47 deletions(-) diff --git a/.github/workflows/linux-runner-version-pin-advisory.yml b/.github/workflows/linux-runner-version-pin-advisory.yml index e1963d9f26..38f911e3c8 100644 --- a/.github/workflows/linux-runner-version-pin-advisory.yml +++ b/.github/workflows/linux-runner-version-pin-advisory.yml @@ -21,10 +21,13 @@ name: Linux runner version pin advisory # eligible" : un organe qui dit "online" ne mesure pas "eligible". # # Cet organe compare la pin (4 sites : Dockerfile ARG, Dockerfile.lean FROM, -# supervise.sh IMAGE/LEAN_IMAGE) a l'exigence courante -# (`repos/actions/runner/releases/latest`, via gh + GITHUB_TOKEN). Sorties : -# rc 0 pin a jour et en phase ; rc 1 PIN_STALE ou IN_PHASE_FAILURE ; rc 2 -# exigence illisible -- un defaut de lecture ne justifie JAMAIS un vert. +# supervise.sh IMAGE/LEAN_IMAGE) a la derniere release publiee +# (`repos/actions/runner/releases/latest`, via gh + GITHUB_TOKEN) -- un PROXY +# de l'exigence : la version minimale exigee n'est pas exposee par GitHub, et +# l'ineligibilite qui pourrait en decouler n'est pas mesuree ici. Le rapport +# nomme donc ce qu'il mesure (review #15366). Sorties : rc 0 pin a jour et en +# phase ; rc 1 PIN_STALE ou IN_PHASE_FAILURE ; rc 2 release illisible -- un +# defaut de lecture ne justifie JAMAIS un vert. # # Advisory PAR CONSTRUCTION (doctrine #12817) : schedule + workflow_dispatch # uniquement, jamais pull_request/push -- un run rouge ne bloque aucune PR. diff --git a/scripts/ci/check_runner_version_pin.py b/scripts/ci/check_runner_version_pin.py index e4ac9e263e..f9e6ca70c9 100644 --- a/scripts/ci/check_runner_version_pin.py +++ b/scripts/ci/check_runner_version_pin.py @@ -3,17 +3,20 @@ Le compromis accepte (#15182) porte un cout cache : `--disableupdate` fige la version du runner a celle de l'image. Le rebuild etant manuel, l'ecart -entre la version epinglee et l'exigence GitHub courante grandit sans que -rien ne le voie — le mode de panne est SILENCIEUX du point de vue du parc : -les slots restent enregistres et `online`, et c'est GitHub qui refuse de -leur confier un job ("required runner version"). Vu du superviseur, rien ne -rougit : ni conteneur en erreur, ni boucle, ni sentinel. Un organe qui dit -"online" ne mesure pas "eligible". - -Cet organe compare la version epinglee a l'exigence GitHub courante -(`repos/actions/runner/releases/latest`) et nomme l'ecart. Il verifie aussi -que les quatre sites de la pin sont en phase (la "verification -cross-fichiers" de la review) : +entre la version epinglee et la derniere release publiee du runner grandit +sans que rien ne le voie — le mode de panne est SILENCIEUX du point de vue +du parc : les slots restent enregistres et `online`, et c'est GitHub qui +refuse de leur confier un job ("required runner version"). Vu du +superviseur, rien ne rougit : ni conteneur en erreur, ni boucle, ni +sentinel. Un organe qui dit "online" ne mesure pas "eligible". + +Cet organe compare la version epinglee a la derniere release publiee du +runner (`repos/actions/runner/releases/latest`) et nomme l'ecart. Ce n'est +qu'un PROXY de l'exigence GitHub : la release publiee n'est pas la version +minimale exigee (GitHub ne l'expose pas), et l'ineligibilite qui pourrait +en decouler n'est pas mesuree ici. L'organe nomme donc ce qu'il mesure, et +rien de plus (review #15366). Il verifie aussi que les quatre sites de la +pin sont en phase (la "verification cross-fichiers" de la review) : - `scripts/ci/docker/linux-runner/Dockerfile` : `ARG RUNNER_VERSION=` - `scripts/ci/docker/linux-runner/Dockerfile.lean`: `FROM coursia-linux-runner:` @@ -24,17 +27,19 @@ Sorties : -- rc 0 : pin a jour et en phase — l'exigence courante est <= pinnee. -- rc 1 : PIN_STALE (pin < exigence GitHub courante) ou IN_PHASE_FAILURE - (les sites ne s'accordent pas). Le rapport nomme le mode de panne - "online mais non eligible". -- rc 2 : UNKNOWN — l'exigence GitHub n'a pas pu etre lue (403 / timeout / +- rc 0 : pin a jour et en phase — la derniere release publiee est <= pinnee. +- rc 1 : PIN_STALE (pin < derniere release publiee) ou IN_PHASE_FAILURE + (les sites ne s'accordent pas). Le rapport nomme le fait MESURE — la pin + est derriere la derniere release publiee, proxy de l'exigence — et non la + consequence supposee ("online mais non eligible", non mesuree ici). +- rc 2 : UNKNOWN — la derniere release n'a pas pu etre lue (403 / timeout / reseau). Une lecture ratee n'est pas une absence mesuree : on ne rend jamais "a jour" sur un defaut de lecture. Run (CI) : python scripts/ci/check_runner_version_pin.py [--repo-root DIR] - [--required-version X.Y.Z] # injection test, sinon GitHub API + [--required-version X.Y.Z] # injection test ; sinon la + # derniere release publiee (proxy) """ from __future__ import annotations @@ -94,8 +99,13 @@ def parse_pins(repo_root: Path) -> dict[str, str | None]: return pins -def fetch_required_version(gh: list[str] | None = None) -> str | None: - """Exigence GitHub courante via `gh api repos/actions/runner/releases/latest`. +def fetch_latest_release(gh: list[str] | None = None) -> str | None: + """Derniere release publiee du runner via `gh api .../releases/latest`. + + C'est un PROXY de l'exigence GitHub, pas l'exigence elle-meme : cette + derniere n'est exposee par aucune API stable, et l'ineligibilite qu'elle + entraine n'est pas mesurable ici. Le nom de la fonction dit ce qu'elle + lit (review #15366). Retourne None si la lecture echoue (jamais de valeur par defaut : un 403 est une question, pas une absence mesuree, cf. #15342). @@ -118,7 +128,8 @@ def version_key(v: str) -> tuple[int, ...]: def report(pins: dict[str, str | None], required: str | None) -> dict: - """Verdict de l'organe : phase + ecart vis-a-vis de l'exigence GitHub.""" + """Verdict de l'organe : phase + ecart vis-a-vis de la version de + reference (derniere release publiee en live, ou version injectee).""" # Une valeur non-versionnee (lecture impossible, regex sans hit) est une # erreur de mesure, pas une pin : on ne peut pas la comparer. bad = [ @@ -153,7 +164,8 @@ def report(pins: dict[str, str | None], required: str | None) -> dict: # justifie pas un vert (exit 2 le fait rougir en CI, advisory). "guard_pass": None, "status": "UNCHECKED_REQUIREMENT", - "detail": "exigence GitHub illisible (403/timeout) — aucun verdict", + "detail": "derniere release du runner illisible (403/timeout) — " + "aucun verdict", "pins": pins, "pinned_version": pinned, "required_version": None, @@ -164,11 +176,12 @@ def report(pins: dict[str, str | None], required: str | None) -> dict: "guard_pass": not stale, "status": "PIN_STALE" if stale else "PIN_OK", "detail": ( - "online mais non eligible : la pin " - f"{pinned} est derriere l'exigence GitHub {required} — bump par " - "rebuild (ARG RUNNER_VERSION du Dockerfile), jamais a chaud" + f"pin en retard sur la derniere release publiee du runner " + f"({pinned} < {required}) — l'ineligibilite qui peut en decouler " + "n'est PAS mesuree ici (GitHub n'expose pas l'exigence minimale) ; " + "bump par rebuild (ARG RUNNER_VERSION du Dockerfile), jamais a chaud" if stale - else f"pin {pinned} >= exigence {required}" + else f"pin {pinned} >= derniere release publiee {required}" ), "pins": pins, "pinned_version": pinned, @@ -181,19 +194,32 @@ def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--repo-root", type=Path, default=REPO_ROOT) ap.add_argument("--required-version", default=None, - help="exigence GitHub (injection test; defaut: API live)") + help="version a comparer (injection test ; defaut : la " + "derniere release publiee, proxy de l'exigence)") args = ap.parse_args(argv) + # Un `--required-version ""` n'est pas « aucune exigence » : le `or` + # d'origine le faisait retomber silencieusement sur l'appel API live + # (releve en review #15366). On le refuse explicitement. + if args.required_version is not None and not args.required_version.strip(): + ap.error("--required-version attend une version X.Y.Z " + "(chaine vide refusee ; omettre le flag pour lire la " + "derniere release publiee)") + pins = parse_pins(args.repo_root) - required = args.required_version or fetch_required_version() + required = ( + args.required_version + if args.required_version is not None + else fetch_latest_release() + ) verdict = report(pins, required) print(json.dumps(verdict, indent=2, ensure_ascii=False)) if verdict["exit_code"] == 1: print(f"[RUNNER-VERSION-PIN] {verdict['status']} : {verdict['detail']}") elif verdict["exit_code"] == 2: - print("[RUNNER-VERSION-PIN] UNKNOWN : exigence GitHub illisible — " - "enqueter avant de conclure quoi que ce soit.") + print("[RUNNER-VERSION-PIN] UNKNOWN : derniere release du runner " + "illisible — enqueter avant de conclure quoi que ce soit.") else: print(f"[RUNNER-VERSION-PIN] {verdict['status']} : {verdict['detail']}") return verdict["exit_code"] diff --git a/scripts/tests/test_check_runner_version_pin.py b/scripts/tests/test_check_runner_version_pin.py index 9dda8f71e6..97c4b48fb8 100644 --- a/scripts/tests/test_check_runner_version_pin.py +++ b/scripts/tests/test_check_runner_version_pin.py @@ -1,14 +1,19 @@ """Non-regression #15201: l'organe de pin des runners Linux. Le compromis #15182 (`--disableupdate`) fige la version du runner a celle -de l'image ; le rebuild etant manuel, l'ecart avec l'exigence GitHub courante -grandit SANS RIEN QUI LE VOIE : les slots restent `online`, c'est GitHub qui -refuse de leur confier un job. L'organe `check_runner_version_pin.py` doit : -- comparer la pin (4 sites) a l'exigence GitHub courante et rouge quand - l'ecart est bloquant (mode de panne nomme : "online mais non eligible") ; +de l'image ; le rebuild etant manuel, l'ecart avec la derniere release +publiee du runner grandit SANS RIEN QUI LE VOIE : les slots restent +`online`, c'est GitHub qui refuse de leur confier un job. L'organe +`check_runner_version_pin.py` doit : +- comparer la pin (4 sites) a la derniere release publiee (proxy de + l'exigence, qui n'est pas exposee) et rouge quand l'ecart est bloquant — + en nommant le fait MESURE, pas la consequence supposee + ("online mais non eligible" n'est pas mesure ici, cf. review #15366) ; - verifier que les sites de la pin sont en phase (verification cross-fichiers) ; -- ne JAMAIS rendre "a jour" sur un defaut de lecture de l'exigence (rc 2). +- ne JAMAIS rendre "a jour" sur un defaut de lecture de la release (rc 2) ; +- refuser un `--required-version ""` au lieu de retomber silencieusement + sur l'appel API live. Levolet 1 est hermétique (faux depot injecte via --repo-root et --required-version, API fakee par injection de commande). Le volet 2 est le @@ -70,15 +75,19 @@ def test_pin_a_jour_et_en_phase_rend_rc0(tmp_path: Path): assert verdict["pinned_version"] == "2.337.0" -def test_exigence_superieure_rouge_pin_stale(tmp_path: Path): - """GitHub exige 2.338.0, la pin est a 2.337.0 : le slot est en ligne mais - ne recevra plus de job -- le rapport doit nommer le mode de panne.""" +def test_release_superieure_rouge_pin_stale(tmp_path: Path): + """La reference est 2.338.0, la pin est a 2.337.0 : le rapport rouge doit + nommer le fait MESURE (pin derriere la derniere release), sans affirmer + l'ineligibilite, qui n'est pas mesuree (review #15366).""" repo = _fake_repo(tmp_path) verdict = organ.report(organ.parse_pins(repo), required="2.338.0") assert verdict["status"] == "PIN_STALE" assert verdict["exit_code"] == 1 - assert "online mais non eligible" in verdict["detail"] assert "2.338.0" in verdict["detail"] + assert "derniere release publiee" in verdict["detail"] + assert "mesuree" in verdict["detail"] + # La consequence NON mesuree ne doit plus etre affirmee nulle part. + assert "non eligible" not in verdict["detail"] def test_sites_desaccordes_rend_in_phase_failure(tmp_path: Path): @@ -101,7 +110,7 @@ def test_site_manquant_est_une_erreur_de_mesure_pas_une_pin(tmp_path: Path): assert verdict["exit_code"] == 1 -def test_exigence_illisible_rend_rc2_pas_un_vert(tmp_path: Path): +def test_release_illisible_rend_rc2_pas_un_vert(tmp_path: Path): """API injoignable (None) : aucun verdict possible -- rc 2, le rapport le dit explicitement au lieu de pretender que la pin est a jour.""" repo = _fake_repo(tmp_path) @@ -111,19 +120,28 @@ def test_exigence_illisible_rend_rc2_pas_un_vert(tmp_path: Path): assert verdict["guard_pass"] is None -def test_fetch_required_version_parse_et_echoue(tmp_path: Path): +def test_fetch_latest_release_parse_et_echoue(tmp_path: Path): """Injection de commande : `gh api` attendu rend v2.337.0 ; une commande qui echoue (403/timeout) rend None, jamais une valeur par defaut.""" - ok = organ.fetch_required_version( + ok = organ.fetch_latest_release( gh=[sys.executable, "-c", 'print("v2.337.0")'] ) assert ok == "2.337.0" - fail = organ.fetch_required_version( + fail = organ.fetch_latest_release( gh=[sys.executable, "-c", "import sys; sys.exit(1)"] ) assert fail is None +def test_required_version_vide_refuse_sans_appel_api(tmp_path: Path): + """`--required-version ""` n'est pas « aucune exigence » : refus explicite + (argparse rc 2), et surtout AUCUN appel API live declenche en douce.""" + repo = _fake_repo(tmp_path) + with pytest.raises(SystemExit) as exc: + organ.main(["--repo-root", str(repo), "--required-version", ""]) + assert exc.value.code == 2 + + # --- volet 2 : controle positif du depot reel --------------------------------- def test_repo_reel_sites_en_phase():