diff --git a/.github/actions/heal-worktree/action.yml b/.github/actions/heal-worktree/action.yml index e64e7bab4e..27004d1d47 100644 --- a/.github/actions/heal-worktree/action.yml +++ b/.github/actions/heal-worktree/action.yml @@ -13,8 +13,12 @@ description: > Le corps du bloc vit dans `heal_worktree.sh`, extrait des workflows sans reecriture (`19b2163f6cc3`) : ce qui change est l'endroit ou il vit, pas ce - qu'il fait. Le controle positif local est - `scripts/tests/test_heal_worktree_action.py`. + qu'il fait. Les copies inline que portent encore les workflows sont + regenerees depuis ce script par + `scripts/ci/sync_heal_worktree_copies.py` (#23512) : modifier le script + source puis lancer le generateur met les copies a jour -- une edition, une + commande, au lieu d'une edition synchrone par workflow. Le controle positif + local est `scripts/tests/test_heal_worktree_action.py`. runs: using: composite diff --git a/docs/reference/scripts-reference.md b/docs/reference/scripts-reference.md index 30c9ccfbba..cde2f92596 100644 --- a/docs/reference/scripts-reference.md +++ b/docs/reference/scripts-reference.md @@ -211,6 +211,7 @@ Pipeline d'audit qualité et de **matrice de coût** (EPIC #8056) + audit séman | `scripts/ci/measure_runner_demand.py` | **Baseline exhaustive de demande GitHub Actions** (#12704) : collecte une fenêtre UTC avec bisection anti-cap 1 000 + pagination de tous les jobs, mesure attente (`started_at-created_at`), travail runner (`completed_at-started_at`), provenance same-repo/fork/unknown et dénominateurs ; replay offline par `--input`. **Co-résidence** (#15574) : bloc `co_residence` (hôte présumé du préfixe du `runner_name`, pic et moyenne de concurrence par job) et bloc `runners_inventory` (`--runners`, slots enregistrés par hôte, trois états `measured`/`unavailable`/`not_collected`, jamais un parc vide). Exit `0` mesure valide / `2` instrument ou snapshot incomplet. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) · organe : `.github/workflows/runner-coresidence-advisory.yml` | | `scripts/ci/manage_self_hosted_runner.py` + `self_hosted_runner_profiles.json` | **Cycle de vie Windows des runners éphémères isolés** (#12704) : profils distribués po-2023..po-2026 avec archive/SHA-256 épinglés ; commandes `install`, `register`, `verify`, `teardown` en dry-run par défaut, mutations uniquement avec `--apply`; compte local dédié, ACL négatives `.secrets`/SSH/gh, tokens via `ACTIONS_RUNNER_INPUT_*`, extraction anti-Zip-Slip/ADS et teardown borné par manifeste. `register --apply` est le bouton d’activation séparé, jamais lancé pendant la préparation. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) | | `scripts/ci/pool_review_verdicts.py` | **Vue de triage du pool : verdict lu dans les corps, pas `reviewDecision`** (#16926) : sous token COMMENT-only (#15511) le champ `reviewDecision` vaut `null` à perpétitude sur ~82 % du pool y compris les PRs portant un `VERDICT: LGTM` argumenté — trier dessus fabrique le faux compte « sans review ». L'organe balaye le pool en GraphQL paginé borné (page 50 + retry 5xx unique : les fenêtres `reviews`+`comments` font 504/502 au-delà, mesures 2026-09-20), lit le préfixe `VERDICT:` en début de ligne sur les **deux surfaces** (reviews[] + commentaires de persona, marqueur importé du canon `check_unaddressed_nits`), latest-wins, et distingue `SANS-REVIEW` (aucune voix) de `VOIX-SANS-VERDICT` et des états réels CHANGES_REQUESTED/APPROVED qui gouvernent. `--gradient` rend la répartition par quartile d'âge (l'enseignement #16926 : les PRs vieillissent parce qu'elles portent une réserve vivante). Advisory : exit 0, ou 2 si le balayage est illisible. Tests : `scripts/tests/test_pool_review_verdicts.py` | +| `scripts/ci/sync_heal_worktree_copies.py` | **Generateur des copies inline du soin d'arbre chaud** (#23512) : regenere depuis la source unique `.github/actions/heal-worktree/heal_worktree.sh` les blocs `Restore the warm work tree before the sparse checkout` que portent 26 workflows du pool persistant (28 occurrences) ; `--check` n'ecrit rien et sort en `1` sur une desync (mode CI). Corriger le soin demande donc une edition et une commande, non 26 editions synchrones. L'action composite existe mais l'appel `uses:` est impossible a cet endroit (l'etape court AVANT `actions/checkout`) et l'appel distant force le telechargement de l'archive codeload du depot entier -- mesure et arbitrage dans #23574. Tests : `scripts/tests/test_heal_worktree_action.py` | | `scripts/check_testpaths_coverage.py` | **Dérive des suites pytest, dans les deux sens** (#22735) : le sens historique confronte les `testpaths` de `pytest.ini` aux cibles pytest réellement invoquées par les workflows (exclusions par marqueur `# CI-EXCLUDED:`, cibles déclarées dans `WORKFLOW_COVERAGE` dont la présence littérale dans le run est vérifiée — un chemin cité en commentaire ne couvre rien) ; le **second sens**, ajouté ici, confronte l'**arbre** à la couverture — une suite `scripts/**/tests/` présente sur le disque mais exécutée par aucun workflow est un rouge, et une suite déclarée câblée mais disparue de l'arbre aussi. C'est ce second sens qui rend visible une suite absente des deux listes (des suites vertes en local, jamais rejouées par personne). Exit `0` OK / `1` dérive. Garde CI : `.github/workflows/testpaths-coverage-guard.yml`. Tests : `scripts/tests/test_check_testpaths_coverage.py` | | `scripts/mcp-maintenance/` | Maintenance MCP (config, docs, scripts) — cf `README_MCP_MAINTENANCE.md` | | `scripts/validation/dispatch.py` + `matrix.yml` | Matrice de validation / dispatch | diff --git a/scripts/ci/sync_heal_worktree_copies.py b/scripts/ci/sync_heal_worktree_copies.py new file mode 100644 index 0000000000..c591731388 --- /dev/null +++ b/scripts/ci/sync_heal_worktree_copies.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Regenere les copies inline du soin d'arbre chaud (#23512). + +Le soin d'arbre chaud (`Restore the warm work tree before the sparse checkout`) +est porte par 26 workflows du pool persistant, sous la forme d'un bloc shell +inline. La source unique est `.github/actions/heal-worktree/heal_worktree.sh` +(le corps sous son marqueur) ; les copies inline en sont byte-identiques, ce +que verifie `scripts/tests/test_heal_worktree_action.py`. + +Ce que la duplication rendait couteux : corriger le bloc demandait 26 editions +synchrones a la main. Ce script retire la main de l'edition : on modifie le +script source, on lance le generateur, et les 26 copies sont regenerees. + +Il ne decide rien de la forme de l'appel. L'action composite existe, mais +l'appel `uses: jsboige/CoursIA/.github/actions/heal-worktree@main` force +GitHub a telecharger l'archive codeload du depot ENTIER a l'etape « Set up +job » (~3,5 Go, mesure dans #23574) et l'action locale `uses: ./...` est +impossible a cet endroit (l'etape court AVANT `actions/checkout`, donc +`.github/actions/` n'est pas dans l'espace de travail). Les copies inline +restent donc en place ; c'est ce generateur qui porte l'objectif de source +unique. + +Usage : + + python scripts/ci/sync_heal_worktree_copies.py # regenere + python scripts/ci/sync_heal_worktree_copies.py --check # exit 1 si desync + +Le mode `--check` est le mode CI : il n'ecrit rien et sort en erreur si une +copie a derive du script source. +""" + +from __future__ import annotations + +import argparse +import io +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / ".github" / "actions" / "heal-worktree" / "heal_worktree.sh" +WORKFLOWS = ROOT / ".github" / "workflows" + +STEP_NAME = "Restore the warm work tree before the sparse checkout" +MARKER = "# --- corps extrait : byte-identique au bloc des workflows (#23287) ---" +INDENT = " " * 10 + +STEP_RE = re.compile( + r"^ - name: " + re.escape(STEP_NAME) + r"\n run: \|\n", re.MULTILINE +) + + +class Desync(ValueError): + """Un workflow porte une occurrence du bloc que le generateur ne sait pas regeneree.""" + + +def script_body() -> list[str]: + """Corps du script source : les lignes apres le marqueur.""" + text = SCRIPT.read_text(encoding="utf-8") + if MARKER not in text: + raise Desync("le marqueur de corps a disparu du script source : %s" % SCRIPT) + return text.split(MARKER + "\n", 1)[1].splitlines() + + +def _blocks(text: str) -> list[tuple[int, int, list[str]]]: + """Occurrences du bloc dans un workflow. + + Rend pour chacune le couple (debut, fin) du corps dans le texte et les + lignes du corps, dedentees de l'indentation du workflow. + """ + found = [] + for match in STEP_RE.finditer(text): + tail = text[match.end():] + stop = tail.find("\n - ") + end = match.end() + (stop if stop >= 0 else len(tail)) + raw = text[match.end():end].splitlines() + body = [] + for line in raw: + if not line: + body.append("") + elif line.startswith(INDENT): + body.append(line[len(INDENT):]) + else: + raise Desync( + "ligne du bloc sans indentation attendue (%r) : %r" % (INDENT, line[:40]) + ) + found.append((match.end(), end, body)) + return found + + +def sync_text(text: str, body: list[str]) -> tuple[str, int]: + """Rend le texte regenere et le nombre d'occurrences reecrites.""" + rendered = "\n".join((INDENT + line) if line else "" for line in body) + out = [] + cursor = 0 + count = 0 + for start, end, current in _blocks(text): + if current == body: + continue + out.append(text[cursor:start]) + out.append(rendered) + cursor = end + count += 1 + out.append(text[cursor:]) + return "".join(out), count + + +def sync_file(path: Path, body: list[str]) -> int: + """Regenere un workflow. Rend le nombre d'occurrences reecrites. + + Le texte est **normalise avant** la synchronisation. Sans cela, le + generateur est un no-op silencieux sur un checkout CRLF -- c'est-a-dire + sur tous les postes Windows de la flotte (`core.autocrlf=true`) : le + fichier porte `run: |\r\n`, que `STEP_RE` (qui exige `\n`) ne + reconnait pas, donc `_blocks()` rend 0 occurrence, rien n'est ecrit, et + `main()` annonce pourtant « regenere ». + + Les fins de ligne du fichier sont rendues a l'ecriture : le generateur + ne touche qu'au corps du bloc. + """ + raw = path.read_bytes() + newline = "\r\n" if b"\r\n" in raw else "\n" + text = raw.decode("utf-8").replace("\r\n", "\n") + new_text, count = sync_text(text, body) + if count and new_text != text: + if newline == "\r\n": + new_text = new_text.replace("\r\n", "\n").replace("\n", "\r\n") + path.write_bytes(new_text.encode("utf-8")) + return count + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Regenere les copies inline du soin d'arbre chaud depuis sa source unique." + ) + parser.add_argument( + "--check", + action="store_true", + help="ne rien ecrire ; exit 1 si une copie a derive du script source", + ) + args = parser.parse_args(argv) + + body = script_body() + changed = [] + carriers = 0 + for path in sorted(WORKFLOWS.glob("*.yml")): + text = path.read_text(encoding="utf-8") + occurrences = _blocks(text) + if occurrences: + carriers += 1 + new_text, count = sync_text(text, body) + if count and new_text != text: + changed.append((path.name, count)) + + if not changed: + print("[heal-worktree] %d workflow(s) portent le bloc -- toutes les copies sont a jour" + % carriers) + return 0 + + for name, count in changed: + print("[heal-worktree] %s : %d bloc(s) a regenerer" % (name, count)) + if args.check: + print("[heal-worktree] DESYNC : %d workflow(s) a regenerer (mode --check : rien n'a ete ecrit)" + % len(changed)) + return 1 + for name, count in changed: + path = WORKFLOWS / name + written = sync_file(path, body) + if written != count: + raise Desync( + "%s : %d bloc(s) detecte(s) mais %d ecrit(s) -- le generateur a " + "echoue en silence, rien n'a ete regenere" % (name, count, written) + ) + print("[heal-worktree] %s : regenere (%d bloc(s) ecrit(s))" % (name, written)) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Desync as exc: + print("[heal-worktree] ERREUR : %s" % exc, file=sys.stderr) + sys.exit(2) diff --git a/scripts/tests/test_heal_worktree_action.py b/scripts/tests/test_heal_worktree_action.py index 0a03d56816..cf0493b8ee 100644 --- a/scripts/tests/test_heal_worktree_action.py +++ b/scripts/tests/test_heal_worktree_action.py @@ -21,6 +21,14 @@ job » -- de quoi faire tomber la totalite des workflows qui l'utilisent. Les copies inline restent donc en place, et c'est ce test qui porte l'objectif de source unique (#23287) : chaque copie doit etre byte-identique au script. + +Depuis #23512, l'identite n'est plus seulement **verifiee**, elle est +**maintenue** : `scripts/ci/sync_heal_worktree_copies.py` regenere les copies +depuis le script source, donc corriger le soin demande une edition et une +commande, non vingt-six editions synchrones. Ce test couvre le generateur +(mode `--check` sans desync, controle positif de reparation, idempotence) et +la fermeture de la couverture : un workflow qui porterait le bloc sans etre +inscrit dans `EXPECTED_WORKFLOWS` est signale. """ import io @@ -45,17 +53,36 @@ # une garde qui compte les occurrences passe aussi quand une seule en porte # une -- c'etait le trou du controle d'identite, qui verifiait la copie sans # verifier la couverture. +# +# La liste a ete completee le 2026-11-04 (#23512) : elle portait 12 noms +# alors que 26 workflows du pool portaient deja le bloc (28 occurrences). La +# couverture ne verifiait que les 12 premiers -- un workflow qui perdait son +# soin passait inapercu. EXPECTED_WORKFLOWS = ( "adjacency-stale-sweep.yml", "candidate-delivered-advisory.yml", "concurrency-conj-guard.yml", + "degenerate-figure-gate.yml", + "epic-charter-advisory.yml", + "epic-neglect-sweep.yml", + "grain-orphans-sweep.yml", "harness-coauthor-guard.yml", "hooks-parity.yml", "lane-claim-guard.yml", + "linux-runner-version-pin-advisory.yml", "markdown-table-guard.yml", + "orphaned-delivery-scan.yml", "pr-gate-rerun.yml", "pr-gate-sweep-health-advisory.yml", + "regression-guard.yml", + "slides-composition-pr-relay.yml", + "stale-base-warning.yml", "stale-guard-red-sweep.yml", + "svg-broken-geometry-gate.yml", + "svg-decimal-comma-gate.yml", + "svg-empty-display-gate.yml", + "svg-offscreen-flat-gate.yml", + "translation-guard.yml", "unique-check-run-names-guard.yml", "variation-light-genre.yml", ) @@ -189,7 +216,9 @@ def _workflow_bodies(): end = i + 1 + (m.start() if m else len(rest) - 1) lines = text[start:end].splitlines() assert lines[1].strip() == "run: |", path - bodies.append((path.name, [ln[10:] for ln in lines[2:]])) + # une ligne vide du script reste vide dans le workflow : la + # dedentition ne doit pas presumer d'une longueur minimale + bodies.append((path.name, [ln[10:] if len(ln) >= 10 else "" for ln in lines[2:]])) idx = i + len(STEP_NAME) return bodies @@ -247,3 +276,137 @@ def test_action_reference_le_script(): assert "using: composite" in text assert "${{ github.action_path }}/heal_worktree.sh" in text assert "reset --hard" not in text, "le corps doit vivre dans le script, pas dans l'action" + + +# --- generateur des copies (#23512) ----------------------------------------- +# +# Le bloc est porte par 26 workflows ; sans generateur, le corriger demandait +# 26 editions synchrones a la main. Le generateur retire la main de l'edition : +# on modifie le script source, on le regenere, les copies suivent. + +GENERATOR = ROOT / "scripts" / "ci" / "sync_heal_worktree_copies.py" + + +def _load_generator(): + """Charge le generateur comme module (chemin relatif au depot).""" + import importlib.util + + spec = importlib.util.spec_from_file_location("sync_heal_worktree_copies", GENERATOR) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_la_couverture_est_complete_et_fermee(): + """Aucun workflow en dehors de la liste ne porte le bloc (croiture). + + La liste est la declaration de qui porte le soin. Un workflow qui + copierait le bloc sans y etre inscrit passerait la garde d'identite + (qui ne verifie que l'egalite) sans jamais etre signale : la duplication + pourrait croitre en silence. La direction fermee rattrape ce cas. + """ + bodies = _workflow_bodies() + portraits = {name for name, _ in bodies} + hors_liste = sorted(portraits - set(EXPECTED_WORKFLOWS)) + assert not hors_liste, ( + "workflow(s) portant le soin d'arbre chaud sans etre inscrits dans " + "EXPECTED_WORKFLOWS : %s -- inscrire le nom, sinon la duplication " + "croit sans controle" % ", ".join(hors_liste) + ) + + +def test_le_generateur_ne_reecrit_rien_en_mode_check(): + """Mode `--check` : le depot est deja synchronise, donc exit 0.""" + module = _load_generator() + assert module.main(["--check"]) == 0, "les copies inline ont derive du script source" + + +def test_le_generateur_repare_une_copie_divergente(): + """Controle positif : une copie alteree est regeneree a l'identique. + + Sans ce controle, un generateur qui ne regenere rien passerait tous les + tests : il suffit de le rendre idempotent pour qu'il paraisse correct. + """ + module = _load_generator() + body = module.script_body() + source = (WORKFLOWS / "adjacency-stale-sweep.yml").read_text(encoding="utf-8") + + # on alterre le bloc : une ligne du corps est remplacee + altere = source.replace("set -u\n", "set -u\n", 1) + altere = altere.replace("reset --hard --quiet", "reset --hard --quiet ; exit 0", 1) + assert altere != source, "la fixture n'a pas reellement ete alteree" + + regenere, count = module.sync_text(altere, body) + assert count == 1, "le generateur n'a pas vu la derive" + assert regenere == source, "la copie regeneree n'est pas revenue a l'identique" + + # et le generateur est idempotent : une seconde passe ne change rien + encore, count2 = module.sync_text(regenere, body) + assert count2 == 0 + assert encore == regenere + + +def test_le_generateur_repare_une_copie_crlf(tmp_path): + """Un checkout CRLF n'est plus un no-op silencieux du generateur. + + Controle POSITIF : le chemin d'ecriture (`sync_file`) repare une copie + CRLF alteree, et les fins de ligne CRLF sont preservees a l'ecriture. + + Controle NEGATIF : ce test est rouge sur une tete ou `sync_file` ne + normalise pas le texte avant la synchronisation (mesure sur ai-01 : + `STEP_RE` exige `run: |\n`, le fichier CRLF porte `run: |\r\n`, donc + `_blocks()` rend 0 et rien n'est ecrit). C'est donc la preuve que le + chemin d'ecriture -- et non seulement `sync_text` -- est exerce. + """ + module = _load_generator() + body = module.script_body() + source = (WORKFLOWS / "adjacency-stale-sweep.yml").read_text(encoding="utf-8") + + altere = source.replace("reset --hard --quiet", "reset --hard --quiet ; exit 0", 1) + assert altere != source, "la fixture n'a pas reellement ete alteree" + + cible = tmp_path / "copie-crlf.yml" + cible.write_bytes(altere.replace("\n", "\r\n").encode("utf-8")) + + count = module.sync_file(cible, body) + + assert count == 1, ( + "le generateur n'a pas vu la derive d'une copie CRLF : `sync_file` " + "normalise-t-il le texte avant la synchronisation ?" + ) + brut = cible.read_bytes() + assert b"\r\n" in brut, "les fins de ligne CRLF n'ont pas ete preservees" + assert b"\n" not in brut.replace(b"\r\n", b""), ( + "une fin de ligne LF s'est glisee dans un fichier CRLF" + ) + # le bloc est revenu a l'identique du script source, une fois les fins de + # ligne normalisees + relu = brut.decode("utf-8").replace("\r\n", "\n") + assert relu == source, "la copie CRLF regeneree n'est pas revenue a l'identique" + + +def test_main_signale_un_ecart_entre_le_compte_detecte_et_le_compte_ecrit( + tmp_path, monkeypatch +): + """`main()` ne peut plus annoncer « regenere » quand rien n'a ete ecrit. + + Le desync silencieux (compte detecte != compte ecrit) est une erreur, pas + une reussite : la sortie doit etre non nulle, pour que la commande ne + passe jamais pour une regeneration reussie. + """ + module = _load_generator() + body = module.script_body() + source = (WORKFLOWS / "adjacency-stale-sweep.yml").read_text(encoding="utf-8") + + altere = source.replace("reset --hard --quiet", "reset --hard --quiet ; exit 0", 1) + assert altere != source, "la fixture n'a pas reellement ete alteree" + + pool = tmp_path / "workflows" + pool.mkdir() + (pool / "copie.yml").write_text(altere, encoding="utf-8", newline="\n") + monkeypatch.setattr(module, "WORKFLOWS", pool) + # on simule un generateur qui n'ecrit rien : l'ecart doit etre signale + monkeypatch.setattr(module, "sync_file", lambda path, body: 0) + + with pytest.raises(module.Desync): + module.main([])