Préparation des runners GitHub Actions auto-hébergés
Cette page décrit les mesures et les garde-fous du chantier #12704. État au 2026-09-01 : activation partielle sur po-2024, volet Linux conteneurisé en ligne (2 slots Docker, preuve d’identité RUNNER_OS = Linux rendue — cf section Runner Linux conteneurisé ; runner fast-guards live, tool-cache seedé, ré-enregistrement sans UAC opérationnel), les autres profils du registre restant en préparation. Le dépôt jsboige/CoursIA est public : une PR de fork peut contenir du code non fiable. Toute exécution auto-hébergée reste réservée aux branches du dépôt lui-même, avec une garde YAML explicite en plus des réglages GitHub.
Mesurer avant de dimensionner
Le collecteur scripts/ci/measure_runner_demand.py mesure une cohorte de runs créés dans une fenêtre UTC :
python scripts/ci/measure_runner_demand.py `
--repo jsboige/CoursIA `
--since 2026-08-24T09:00:00Z `
--until 2026-08-24T10:00:00Z `
--output C:/chemin/runner-demand.jsonLa sortie contient le snapshot minimal et son analyse. Elle se rejoue sans accès réseau :
python scripts/ci/measure_runner_demand.py `
--input C:/chemin/runner-demand.json `
--output C:/chemin/runner-demand-replay.jsonLes snapshots live datés sont des preuves de session : ils restent hors du dépôt et leurs chiffres, fenêtre et dénominateurs sont cités dans la PR ou sur le dashboard. Le dépôt conserve l’instrument et les définitions durables, pas un rapport d’état.
Définitions
Pour chaque job disposant de timestamps cohérents :
- attente =
started_at - created_at; - travail runner =
completed_at - started_at; - minutes-runner par heure murale = somme du travail des jobs / durée de la fenêtre ;
- équivalents runners moyens = somme du travail / durée de la fenêtre, les deux exprimées en minutes.
L’analyse publie aussi les distributions d’attente et de travail (p50, p90, max) dans by_label et by_runner. Un job qui porte plusieurs labels contribue une fois à chacun d’eux ; les groupes sont triés lexicalement pour rendre les replays déterministes. Les identités absentes restent visibles sous <unlabelled> et <unassigned> au lieu d’être supprimées. Chaque groupe expose jobs, timed_jobs, incomplete_or_untimed_jobs, timestamp_skew_jobs et timing_coverage : un percentile sans job temporisable vaut null, jamais zéro.
run_started_at n’est pas utilisé pour l’attente : l’API peut le rendre égal au created_at du run alors que ses jobs attendent encore. Un job annulé après avoir démarré a consommé un runner et compte dans le travail. Un job skipped ou encore en file ne devient jamais une durée zéro : il apparaît dans incomplete_or_untimed_jobs et réduit timing_coverage. GitHub peut aussi inverser des timestamps adjacents à cause de leur précision : ces jobs sont exclus du calcul et comptés dans timestamp_skew_jobs, sans transformer l’anomalie en durée négative ou nulle.
La provenance est classée en trois catégories :
same_repo:head_repository.full_nameégale le dépôt mesuré ;fork: le nom diffère ;unknown: provenance absente.
Un résultat avec unknown > 0 ne prouve pas « 100 % same-repo ».
Exhaustivité et zéros
L’API Actions plafonne certaines recherches filtrées à 1 000 runs. L’instrument bissecte automatiquement la fenêtre temporelle dès que total_count >= 1000, déduplique les runs aux frontières, puis pagine tous les jobs de chaque run. Il refuse la mesure (exit 2) si une sous-fenêtre d’une seconde reste plafonnée ou si une page disparaît avant le dénominateur annoncé. Un job dont les timestamps donnent une durée négative est exclu des distributions et compté dans timestamp_skew_jobs.
Une fenêtre réellement vide est valide et imprime explicitement runs: 0, jobs: 0 et timing_coverage: null. Elle est donc distincte d’un instrument cassé. Ne jamais citer un zéro sans son dénominateur et son code retour.
Lecture de la baseline
Avant toute bascule, relever au minimum :
- la fenêtre UTC et sa durée ;
- le nombre de runs, de jobs et de jobs temporisés ;
- la couverture temporelle ;
- les minutes-runner/heure ;
- le détail par workflow et par conclusion ;
- les p50/p90/max d’attente et de travail par label et par runner, avec leurs dénominateurs ;
- les comptes
same_repo,forketunknown; - les rafales
runs_created_per_minute.
Le détail par workflow sépare la capacité réellement consommée de l’auto-contention. En particulier, le PR gate peut occuper un runner pendant qu’il sonde des checks eux-mêmes en file : dimensionner sur la demande brute financerait ce temps d’attente au lieu de le corriger. Les distributions par label et runner localisent une saturation observée ; elles ne révèlent pas à elles seules combien de runners partagent un hôte physique, son plafond de concurrence, ni la politique de capacité à retenir. Ces décisions exigent une mesure de topologie distincte.
Co-résidence : hôte, concurrence, slots (#15574)
Les deux nombres que le paragraphe précédent laisse ouverts sont mesurables, mais côté API, pas côté machine : actions/runners rend les slots enregistrés, et les horodatages des jobs rendent le recouvrement. scripts/ci/measure_runner_demand.py les produit en deux blocs, à lire ensemble — le premier dit ce qui s’est passé, le second la capacité qui l’a produit.
| Bloc | Question | Source | Nature |
|---|---|---|---|
runners_inventory |
Combien de runners partagent un hôte ? | actions/runners (--runners) |
STATIQUE |
co_residence |
Combien de jobs un hôte a-t-il portés simultanément, et à quelle durée ? | horodatages des jobs | DYNAMIQUE |
L’hôte est présumé du nom du runner. Le pool nomme ses slots <hôte>-<n> (myia-po-2024-linux-docker-1/-2), donc le regroupement par préfixe reconstitue l’hôte. Un nom sans suffixe numérique n’est pas attribué : l’inventer fabriquerait un hôte d’un seul slot, c’est-à-dire exactement le chiffre qu’on cherche à mesurer. Les jobs et runners non attribuables sont comptés séparément (jobs_unplaced, unplaced_runners).
Deux statistiques de concurrence par job, parce qu’elles répondent à deux questions distinctes :
- le pic — combien de jobs l’hôte a portés simultanément pendant ce job ; c’est lui qui dit le plafond atteint ;
- la moyenne — quelle part de la durée s’est faite en compagnie.
Le pic est calculé sur les bornes des intervalles : entre deux bornes le compte ne peut pas changer, et un échantillon unique au point médian rate un job qui chevauche un autre sur la moitié de sa durée (constaté en écrivant la suite de tests).
Bornes et honnêteté de la mesure. La concurrence observée est une borne inférieure : seuls les jobs de la fenêtre collectée sont connus, un job hors fenêtre qui tournait en parallèle est invisible. Elle peut donc montrer qu’un hôte est sur-souscrit, jamais prouver qu’il ne l’est pas. Les caveats sont émis dans la sortie JSON, pas seulement dans ce document, et une corrélation durée↔︎concurrence n’y est pas présentée comme une cause : une durée plus longue en concurrence peut venir du job lui-même. L’inventaire distingue trois états — measured, unavailable (droit de lecture manquant, raison incluse) et not_collected — dont aucun ne rend un parc vide.
Pourquoi un runner de workflow et pas une commande locale. La collecte coûte environ un appel API par run (la lecture des jobs), plus la pagination. Un poste de travail épuise son quota REST avant de couvrir une fenêtre utile — mesuré le 2026-09-21 : quota épuisé avant la fin d’une fenêtre d’une heure, l’instrument rendant alors BROKEN INSTRUMENT (exit 2) plutôt qu’un zéro propre, ce qui est le comportement voulu. runner-coresidence-advisory.yml porte donc la mesure sur ubuntu-latest (l’observateur ne consomme pas ce qu’il observe), en advisory — schedule + workflow_dispatch seulement, jamais pull_request, donc un run rouge ne peut pas bloquer une PR.
# après merge (workflow_dispatch exige le fichier sur la branche par défaut)
# hours se calcule sur le rythme MESURE du dépôt (voir ci-dessous), pas sur le
# défaut 6 : au rythme du 2026-09-23 (~780 runs/h), 1 h est le maximum qui
# tient sous le plafond de 1 000 requêtes/h du jeton de workflow.
gh workflow run runner-coresidence-advisory.yml -f hours=1 -f runner_inventory=true
# ou en local, quand le quota REST est disponible
python scripts/ci/measure_runner_demand.py --repo jsboige/CoursIA \
--since <ISO8601Z> --until <ISO8601Z> --runners --output coresidence.jsonÉtat de la mesure — premiers chiffres (2026-09-23). Les deux nombres sont écrits ci-dessous, datés, avec leur véhicule et leurs limites.
Inventaire statique — actions/runners, mesuré le 2026-09-23T14:44Z :
| Hôte | slots | occupés à l’instant | labels de pool |
|---|---|---|---|
myia-po-2024-linux-docker |
7 | 7 | coursia-ephemeral, coursia-linux |
myia-ai-01-wsl |
3 | 3 | coursia-ephemeral, coursia-linux |
myia-po-2024-lean-docker |
2 | 0 | coursia-ephemeral, coursia-lean |
myia-po-2024-linux-waiter |
12 | 0 | coursia-waiter |
myia-ai-01-linux-waiter |
16 | 0 | coursia-waiter |
- Le pool
coursia-ephemeralcompte 12 slots, dont 9 (75 %) sur le seul hôte physiquemyia-po-2024(7linux-docker+ 2lean-docker) ; les 3 autres sont surmyia-ai-01-wsl. Au moment de la mesure, les 7 slotslinux-dockerde po-2024 étaient occupés simultanément : le plafond de concurrence observé sur cet hôte est donc d’au moins 7. - Les slots
po-2024-linux-docker-9et-10, cités par le diagnostic fondateur du 2026-09-11 (les deux runners tués à 71 % des tests), sont absents de tous les inventaires relevés (14:44Z à la rédaction ; contrôle du 2026-09-23 18:31Z). Le reste de la composition est daté et volatile :-7absent de l’inventaire de 14:44Z est relevé en ligne à 18:31Z — une absence n’est pas un retrait, aucune trace de désenregistrement n’est citée ; le point durable est la paire {-9, -10}. - Les profils
coursia-fast-guardsdeself_hosted_runner_profiles.jsonne portent aucun runner enregistré à cet instant — c’est leur état nominal : le cycle est éphéméral (un slot s’enregistre à la demande pour un job, puis se désenregistre ; cfwindows-self-hosted-tests.yml).
Co-résidence dynamique — premier run de l’organe runner-coresidence-advisory.yml, fenêtre 1 h (2026-09-23T14:08Z → 15:08Z, run 35877355222) :
| Hôte | identités de slots vues sur la fenêtre | jobs portés | pic de concurrence | concurrence moyenne |
|---|---|---|---|---|
myia-ai-01-wsl |
10 | 180 | 9 | 5.30 |
myia-po-2024-linux-docker |
8 | 99 | 7 | 4.09 |
- 279 jobs placés sur les deux hôtes, et 274 des 279 (98 %) ont tourné en concurrence avec au moins un autre job du même hôte — la co-résidence est le régime nominal du parc, pas un cas limite. Les 439 autres jobs de la fenêtre restent sans attribution à un hôte self-hosted reconnu par l’instrument.
- Les deux hôtes culminent quasiment à leur capacité observée : ai-01-wsl à 9 jobs simultanés (10 identités de slots vues), po-2024-linux-docker à 7 (8 identités). Les identités vues sur la fenêtre excèdent l’inventaire statique de 14:44Z (10 contre 3 sur ai-01-wsl, 8 contre 7 sur po-2024 ; 41 runners contre 40 à l’instant du run) : des slots éphémères se sont enregistrés pendant l’heure chargée — le parc auto-scale, la photo statique n’en montre qu’une tranche.
- Aucune dégradation médiane détectée : p50 seul 0.367 min, p50 partagé 0.267 min (ratio 0.727). À ne pas sur-lire : la population « seule » ne compte que 5 jobs — c’est l’absence de signal au médian, pas l’absence de coût de co-résidence sur les jobs longs. Le rapport partagé/seul porte désormais p50, p90 et max (
shared_over_solo_runtime_ratio), parce que c’est la queue qui fait mourir un job au timeout : un rapport médian proche de 1 peut coexister avec une queue qui double. Les effectifs (solo_jobs,shared_jobs) restent publiés à côté du rapport — un quantile de queue sur une population mince n’est pas une mesure. Le relevé du 2026-09-23 est antérieur à ce champ : sa queue n’est pas recalculable (la mesure JSON du run n’est pas conservée), elle viendra du prochain relevé.
Le sizing nominal de l’organe est périmé d’un ordre de grandeur. La fenêtre par défaut de 6 h était calibrée sur « ~60-80 runs/heure » (≈500 appels, plafond 1 000/h du jeton de workflow). Mesuré : 1 433 runs sur 6 h le 2026-09-11 (≈240/h) et 781 runs sur la dernière heure le 2026-09-23 (≈780/h) — le rythme a plus que triplé entre les deux. À ce rythme, une fenêtre de 6 h exige ~5 800 appels, hors de portée du jeton de workflow comme d’un poste de travail ; le premier dispatch (fenêtre 6 h) a été annulé au profit d’une fenêtre de 1 h (≈780 appels, sous le plafond). Toute fenêtre future se calcule sur le rythme mesuré du dépôt au moment du run, pas sur la constante de conception.
Vérifiabilité des deux blocs. La lecture actions/runners a été prise sous le compte myia-ai-01, détenteur de la permission fine-grained runners sur ce dépôt : un reviewer qui ne l’a pas obtient un 403 (constaté depuis clusterManager-Myia le 2026-09-23T20:28Z), donc ce contrôle précis se demande à ce compte, ou se rejoue via l’organe lui-même (runner_inventory=true) sur un inventaire frais — il ne se lit pas de l’extérieur du dépôt. Les chiffres de co-résidence se rejouent par un dispatch de runner-coresidence-advisory.yml (commande ci-dessus, hours=1) : c’est une photo datée d’une heure, pas un relevé continu, et le run qui l’a produite (35877355222) est cité avec sa fenêtre.
Les deux nombres étant versés et datés ci-dessus, la caractérisation est complète ; reste ouverte la décision de capacité (plafond de concurrence par hôte, retrait ou renfort documenté des slots), qui relève du coordinateur et n’est pas prise dans ce document.
Topologie retenue
jsboige/CoursIA appartient à un compte GitHub personnel. Les groupes de runners personnalisés sont réservés aux organisations et ne constituent donc pas une barrière disponible ici. La frontière activable repose sur deux contrôles complémentaires :
- une allowlist statique de workflows ;
- les labels exacts
self-hosted,coursia-ephemeral,coursia-fast-guards.
La capacité est distribuée sur les machines des workers myia-po-2023 à myia-po-2026, pas centralisée sur ai-01. Il n’existe pas d’affinité entre auteur du push et machine d’exécution : GitHub choisit un runner disponible portant les labels. Chaque machine utilise le même compte Windows local dédié et un profil distinct. ai-01 reste hors du pool initial afin de préserver ses charges de coordination, vLLM et entraînement.
Un runner ne doit jamais utiliser un label GitHub-hosted (ubuntu-latest, windows-latest, etc.) : cela contournerait la classification statique. Il ne doit pas non plus hériter du compte interactif du worker.
Gestionnaire Windows, à blanc par défaut
Le registre scripts/ci/self_hosted_runner_profiles.json épingle pour chaque worker : dépôt, identité locale, chemins possédés, labels, version, URL officielle et SHA-256 du runner. Il ne contient aucun secret.
python scripts/ci/manage_self_hosted_runner.py install `
--profile myia-po-2025-fast-guards
python scripts/ci/manage_self_hosted_runner.py register `
--profile myia-po-2025-fast-guards
python scripts/ci/manage_self_hosted_runner.py verify `
--profile myia-po-2025-fast-guards
python scripts/ci/manage_self_hosted_runner.py teardown `
--profile myia-po-2025-fast-guardsSans --apply, ces commandes observent l’état local et impriment un plan JSON déterministe. Elles ne téléchargent rien, ne créent aucun compte, n’écrivent aucun fichier, ne contactent pas GitHub et ne modifient aucun service. Codes retour : 0 plan/état valide, 1 précondition de sécurité refusée, 2 profil ou état illisible.
Installation ultérieure
install --apply est réservé à une session d’activation autorisée et élevée. Il exige COURSIA_RUNNER_ACCOUNT_PASSWORD dans l’environnement, puis :
- télécharge uniquement l’archive Windows x64 officielle épinglée ;
- compare son SHA-256 au pin committé avant extraction ;
- refuse les chemins absolus, traversals, symlinks et flux alternatifs NTFS dans le ZIP ;
- extrait dans un staging puis renomme atomiquement ;
- crée un compte local standard dédié et refuse tout compte préexistant/non possédé ;
- retire l’héritage ACL du répertoire runner ;
- ajoute des refus de lecture explicites sur tout le répertoire
.secrets/, SSH etGitHub CLI/hosts.yml; - écrit un manifeste local qui borne les ressources que le teardown peut retirer.
Un hash faux, un chemin sensible absent, un compte administrateur ou un état partiel fait échouer l’installation. Aucun fallback vers LocalSystem, NetworkService ou le compte interactif n’est admis.
Bouton d’enregistrement — ne pas presser pendant la préparation
register --apply est le geste d’activation. Il exige :
- une installation conforme ;
GITHUB_RUNNER_REGISTRATION_TOKEN;COURSIA_RUNNER_ACCOUNT_PASSWORD.
Le gestionnaire transmet les secrets via les entrées upstream ACTIONS_RUNNER_INPUT_* du runner, et jamais via --token ou --windowslogonpassword dans la ligne de commande. L’environnement enfant est construit depuis une allowlist et n’hérite ni de GH_TOKEN, ni de GITHUB_TOKEN, ni du profil interactif. L’invocation fixe --unattended --ephemeral --replace --runasservice et les trois labels exacts.
Le token d’enregistrement ne transite jamais par un commit, une PR, un commentaire GitHub ou un dashboard. Le canal éventuel est un DM RooSync privé avec autodestruction adaptée. La commande register --apply ne doit être exécutée qu’après un geste explicite du user ou du coordinateur.
Vérification de l’isolation
Le mode à blanc vérifie manifeste, version, labels et état. verify --apply, lors de l’activation contrôlée, exécute sous le compte runner un probe réel qui exige quatre résultats :
- lecture d’un fichier de contrôle placé dans
.secrets/refusée ; - lecture SSH refusée ;
- lecture de la configuration/keyring
ghinteractive refusée ; - écriture puis suppression dans le workdir réussie.
whoami ne suffit pas. Un fichier absent ou une erreur ambiguë n’est jamais assimilé à un refus d’accès réussi. Le probe et son résultat temporaire sont supprimés dans tous les cas.
Teardown symétrique
teardown --apply n’agit que si le manifeste prouve la propriété du chemin et du compte. Pour un runner enregistré, il exige GITHUB_RUNNER_REMOVAL_TOKEN, transmis lui aussi par l’environnement upstream. Il arrête et désinstalle le service, désenregistre le runner, copie _diag hors workdir, refuse de continuer si un secret fourni apparaît dans les logs, retire les ressources possédées, les ACE et le compte dédié. Un second passage sur un état absent est un succès explicite sans action.
Les logs conservés restent locaux et hors du dépôt. Le contrôle distant « zéro runner enregistré » et la preuve d’un job réel appartiennent à la tranche d’activation, car ils nécessitent l’API GitHub.
Limite des runners éphémères — et le contrôleur de ré-enregistrement
Un runner --ephemeral traite au plus un job puis doit être ré-enregistré : chaque job consomme l’inscription. Le gestionnaire prépare une invocation unique ; il ne crée ni boucle permanente, ni broker de tokens. La décision est prise (ai-01, DM 2026-08-28T14:33Z) : un contrôleur de ré-enregistrement supervise l’invocation unique — pas de JIT (il exigerait le broker de tokens que cette page refuse), pas de one-shot (ce n’est pas de la capacité). L’éphémère est préservé : chaque job garde une inscription fraîche, donc un token négocié à chaud à chaque cycle. Un runner persistant n’est pas un raccourci acceptable.
Le contrôleur est scripts/ci/runner_controller.py :
| Commande | Effet |
|---|---|
status |
État distant + plan, aucun effet de bord. |
ensure --apply |
Idempotent : runner online → no-op ; absent → token frais via gh + register + verify du gestionnaire. Sans --apply, imprime le plan JSON. |
deregister --apply |
Arrêt propre : config.cmd remove avec token de retrait. L’installation demeure ; l’état redevient « préparé, pas activé ». |
task-install --apply |
Enregistre la tâche planifiée CoursIA-Runner-Controller (tick 60 s, limite d’exécution 10 min, IgnoreNew) qui déclenche ensure --apply. C’est le bouton — exige une session élevée. |
task-remove --apply |
Retire la tâche (retour arrière du bouton). Second passage sur une tâche absente = succès explicite. |
Le tick ne fait quasiment rien quand le runner est online (une lecture d’API) : autant de passages que de minutes, un seul état stable. L’action de la tâche lit le mot de passe du compte dédié dans le fichier machine local conventionnel (<racine-parent>\secrets\runner_pwd.txt, jamais dans le dépôt), négocie tout le reste à chaud et journalise dans <racine-parent>\logs\controller.log. Le token d’enregistrement ne transite jamais par argv, commit, PR, commentaire ou dashboard, et est retiré de l’environnement après chaque cycle.
Geste d’activation (ai-01 ou user, session élevée) : python scripts/ci/runner_controller.py task-install --profile <profil> --apply, puis observer logs\controller.log et gh api repos/jsboige/CoursIA/actions/runners.
Geste de retour arrière : task-remove --apply (la machine cesse de ré-enregistrer), puis laisser le job courant consommer l’inscription — ou deregister --apply pour l’arrêt immédiat. Le teardown complet du gestionnaire (compte, ACL, arborescence) reste disponible en dernier recours.
Nom de route (2026-08-28, mesuré firsthand sous myia-ai-01) : la route de retrait est POST /actions/runners/remove-token, pas removal-token. Les trois mesures sous une même identité non-admin lèvent l’ambiguïté : registration-token → 403, remove-token → 403 (la route existe, seul le droit manque), removal-token → 404 (la route n’existe pas). Il n’y a donc aucune asymétrie d’API entre l’enregistrement et le retrait — le 404 initialement observé venait du nom de route erroné, corrigé ici. L’élévation reste requise pour l’appel réel (201 sous identité admin, cf. registration-token mesuré par po-2024) : deregister --apply échoue fail-closed sous identité non-admin, ce qui est le comportement voulu.
Provisionnement Python du tool-cache (option a2)
windows-self-hosted-tests.yml conserve actions/setup-python. Les deux alternatives ont été écartées sur preuve (arbitrage #13217, 2026-08-27) :
- Python machine sans
setup-python(PR #13233, fermée) : le compte dédié.\coursia-runnern’a aucunpythondans son PATH — les installations per-user deC:\Users\<worker>\AppData\Local\Programs\Pythonsont hors PATH système et hors ACL du compte service. Mesuré au run 33087876304 : « The term ‘python’ is not recognized » (Install test dependencies, 2 s). - Install all-users + PATH (option a1) : fonctionnelle mais exige une passe UAC ; non retenue tant que l’alternative sans UAC existe.
Sur un runner éphémère sans provisionnement, le premier setup-python télécharge l’interpréteur et son setup.ps1 est bloqué par l’ExecutionPolicy du compte service (défaut fondateur de #13217). La voie retenue, déployée et mesurée sur po-2024 : seeder le tool-cache du runner avec un Python épinglé et son stamp de complétude, pour que setup-python trouve l’interpréteur en cache local et n’exécute jamais setup.ps1 — zéro téléchargement, zéro script d’installation.
Le stamp est la pièce critique
Un seed sans stamp échoue en détruisant le seed. Sans fichier x64.complete, tc.find() répond « was not found in the local cache » ; setup-python télécharge, et son setup.ps1 trouve le dossier seedé, le supprime, puis copie uniquement l’installeur — l’archive actions/python-versions embarque un exécutable, pas un arbre — et échoue en l’exécutant sous le compte service (0x80070005, reproduit hors job). Avec le stamp : cache hit immédiat.
Procédure (mesurée sur po-2024)
- Installer Python 3.11.9 per-user (python.org, hors UAC) sur le compte interactif du worker.
- Peupler le tool-cache par
robocopyvers<work>\_tool\Python\3.11.9\x64\sous le compte.\coursia-runner(2820 fichiers) — si le compte interactif peut écrire sous_work\_tool(ACL par profil), le robocopy direct suffit ; sinon l’exécuter ascoursia-runner. - Écrire le stamp vide
<work>\_tool\Python\3.11.9\x64.complete— frère du répertoirex64\, jamais dedans.@actions/tool-cachecompose<cache>/<tool>/<version>/<arch>puis teste ce même chemin suffixé de.complete:find()évaluefs.existsSync(cachePath) && fs.existsSync(cachePath + '.complete'), et_completeToolPath()écritmarkerPath = folderPath + '.complete'. Un marqueur placé dansx64\n’est donc jamais lu —tc.find()répond « not found », et le seed est détruit au premier job par leio.rmRF(folderPath)de_createToolPath(). - Vérifier sous le compte service :
python --version→3.11.9etpip --version→ 24.0 (le témoinTOOL_PYOK 3.11.9des runs de preuve).
Le tool-cache et le stamp persistent sous _work entre jobs éphémères : les ré-enregistrements suivants gardent le cache hit.
Preuves mesurées (po-2024, 2026-08-27)
- run 33092567324 (main, 16:19Z) :
setup-pythonsuccess (cache hit), pip success, pytest 39 passed / 1 failed — l’échec résiduel était le bug d’invariant #13238, sans rapport avec le provisionnement ; - run 33093119578 (branche, 16:25Z) : 42 passed, conclusion success ;
- run de contrôle sans stamp :
0x80070005, le dossier seedé détruit parsetup.ps1(mécanisme ci-dessus).
Ré-enregistrement sans UAC (chaîne po-2024)
Chaque workflow_dispatch consomme l’enregistrement éphémère (un job, un runner) : la chaîne de ré-enregistrement doit donc tourner sans intervention élevée. Mise au point sur po-2024 (2026-08-27) :
- tâche planifiée
CoursIA-Runner-Activate: exécute périodiquement leregister --applydu gestionnaire (les secrets d’enregistrement restent transmis par l’environnement upstream, jamais en ligne de commande) ; - listener interactif lancé sous le compte dédié : le service runner démarre dans la session du compte
.\coursia-runner, sans élévation ; - résultat mesuré : dispatch 16:00Z pris par un runner ré-enregistré automatiquement — « la machinery vit » sans passe UAC par cycle.
Le stamp _work\_tool (section précédente) persiste à travers ces cycles : le cache hit survit aux ré-enregistrements.
Diagnostic complet et arbitrage : #13217. Chantier runners : #12704.
Runner Linux conteneurisé (po-2024, mission #13378)
Le volet Linux du chantier passe par Docker plutôt que WSL nu (décision user relayée par ai-01, dispatch 2026-08-31) : isolation (conteneur jetable vs compte Windows sous ACL), éphémérité native (cycle de vie porté par docker run --rm, pas de ré-enregistrement à orchestrer côté service), plafonnement ressources par le daemon.
Contexte : scripts/ci/docker/linux-runner/ (Dockerfile — runner 2.336.0 linux-x64 épinglé par SHA-256 officiel, utilisateur non-root, aucun montage hôte ; entrypoint — enregistrement via ACTIONS_RUNNER_INPUT_* uniquement, jamais --token en argv).
Lancement cappé (contraintes user : la machine sert aussi de workstation GPU + interactive — l’hôte prime sur la CI) :
TOKEN=$(gh api repos/jsboige/CoursIA/actions/runners/registration-token --jq .token)
docker run --rm -d --name coursia-linux-runner \
--cpus=3 --memory=4g --pids-limit=384 \
--security-opt=no-new-privileges \
-e ACTIONS_RUNNER_INPUT_TOKEN="$TOKEN" \
-e ACTIONS_RUNNER_INPUT_URL=https://github.com/jsboige/CoursIA \
-e ACTIONS_RUNNER_INPUT_NAME=myia-po-2024-linux-docker \
-e ACTIONS_RUNNER_INPUT_LABELS=self-hosted,coursia-ephemeral,coursia-linux \
coursia-linux-runner:2.336.0Pas de --gpus (aucun passthrough GPU, par design). Le runner --ephemeral traite au plus un job puis se désenregistre et le conteneur --rm disparaît : un dispatch = un job = un conteneur.
Ce cycle mono-job est le facteur limitant nommé par le census #13378 — pas la compatibilité des workflows. Un docker run isolé plafonne donc la concurrence à 1, ce qui ne débraye rien : c’est le superviseur ci-dessous qui lève ce plafond.
Superviseur — le plafond de 1 job se lève par N boucles
scripts/ci/docker/linux-runner/supervise.sh (livré 2026-09-01, finalisation du volet laissé en conception). Un slot = une boucle while qui relance un conteneur dès que le précédent meurt ; N slots = N jobs concurrents. C’est toute la différence entre le conteneur et le service Windows : côté Windows, chaque ré-enregistrement est une tâche planifiée à orchestrer ; ici c’est un docker run de plus, gratuit et parallélisable.
scripts/ci/docker/linux-runner/supervise.sh pin # epingle le contexte hors arbre (#16134)
docker build -t coursia-linux-runner:2.337.0 "$COURSIA_RUNNER_PINNED_CTX"
scripts/ci/docker/linux-runner/supervise.sh start 2 # 2 slots concurrents
scripts/ci/docker/linux-runner/supervise.sh status
scripts/ci/docker/linux-runner/supervise.sh stop # gracieux : les jobs en cours finissentLe build se fait depuis l’épingle (pin, défaut $STATE_DIR/image-context), pas depuis le checkout : le garde de fraîcheur #14801 compare l’image à cette copie épinglée, et un checkout de branche ou une édition non commitée n’invalident plus le parc (#16134 — l’incident du 2026-09-14 : 1 h 15 de flotte morte parce que le garde comparait l’arbre vivant). Après un merge qui touche entrypoint.sh ou work_cache_health.sh : pin (qui publie le diff d’empreintes), rebuild, restart.
Trois points de conception qui ne sont pas négociables :
- La boucle vit sur l’hôte, jamais dans l’image. Le
registration tokenvaut 1 h et est jetable : il en faut un neuf à chaque démarrage de conteneur. Le fetch exigeghauthentifié avec droit admin sur le dépôt — ces credentials ne descendent jamais dans le conteneur, qui ne reçoit que le token par-e, comme l’exige déjàentrypoint.sh. - L’arrêt est gracieux par défaut (sentinel
~/.coursia-runner/stop) : plus aucun conteneur n’est lancé, mais le job en cours va à son terme. Tuer un job en vol produirait un rouge qui ne veut rien dire. - N appartient à po-2024, pas à ai-01. Le défaut est 2, volontairement bas. La clause de souveraineté ci-dessous prime sur toute décision d’élargissement.
Labels dédiés : le jeu {self-hosted, coursia-ephemeral, coursia-linux} (second jeu admis par check_self_hosted_runner_policy.py) route uniquement vers le conteneur — un dispatch Windows ne peut jamais y atterrir, un job Linux ne peut jamais atterrir sur un runner Windows. Mélanger les deux jeux reste une violation (RUNNER_LABELS).
État déployé le 2026-09-01 (po-2024, inventaire GitHub firsthand ~20:27Z) : 2 slots myia-po-2024-linux-docker-1/-2 [online], labels {self-hosted, coursia-ephemeral, coursia-linux}, image 2.336.0, lancés par supervise.sh start 2. Preuve d’identité rendue : run 33554804211 — Runner name: 'myia-po-2024-linux-docker-1', runner.os=Linux, job 1 m 51 s, conclusion success. Empreinte au repos mesurée sur les deux conteneurs : ~40,5 MiB / 4 GiB chacun, CPU ~0 %, PIDS 14-18. Empreinte sous charge (deux jobs organiques concurrents, ~21:55Z) : CPU 113 % et 161 % (cap 300 %), MEM 187 MiB et 498 MiB (cap 4 GiB), PIDS 52 et 50 (cap 384) — la jambe tient N=2 concurrent sans approcher les caps. La boucle de supervision est prouvée : après la mort éphémère du conteneur post-job, ré-enregistrement automatique observé et runners repassés [online].
La leçon qui a fondé cette preuve reste écrite noir sur blanc : cette page a déjà décrit une conception comme un état déployé — l’inventaire du matin même (2026-09-01) montrait un registre à un seul runner Windows, label coursia-linux orphelin nulle part, image jamais construite. Tant qu’aucun job n’a rendu la preuve d’identité (RUNNER_OS = Linux dans les logs), aucun vert de cette chaîne ne prouve quoi que ce soit — leçon po-2024 du run 33178577527, où l’échec s’était produit pour la mauvaise raison (ACE manquante) et aurait pu passer pour un succès de routage.
L’image expose python nu (python-is-python3) pour les workflows stdlib-only (le check-navlinks du job 100021313259 avait échoué exit 127 "python: not found" avant cela), et les slots montent le volume coursia-runner-toolcache sur /opt/hostedtoolcache (RUNNER_TOOL_CACHE) pour que les actions setup-* ne re-téléchargent pas leurs outils à chaque conteneur éphémère. Après toute modification du Dockerfile : rebuild (même tag), puis roulement des slots — docker kill des conteneurs vérifiés busy=false (la boucle relance sur la nouvelle image ; les slots occupés se soignent seuls au tour suivant). Le check busy=false échoue en silence par deux chemins mesurés : jq est absent de l’hôte Ubuntu (il vit dans l’image), et gh api ne supporte pas --arg. Forme canonique — côté Windows (gh embarque jq), nom littéral interpolé, fail-closed :
busy=$(gh api repos/jsboige/CoursIA/actions/runners --jq ".runners[] | select(.name==\"$name\") | .busy")
rc=$?; [ $rc -eq 0 ] && [ -n "$busy" ] || { echo "check FAILED rc=$rc busy='$busy'"; exit 1; }Re-vérifier par slot juste avant chaque kill : un job peut être pris entre l’inventaire et le geste. Un busy vide capturé sans contrôle de rc n’est pas une vérification — l’égalité != "true" passe et le kill part non vérifié.
Cache de dépôt persistant (#14285, 2026-09-02) : chaque slot monte en plus un volume dédié coursia-runner-work-<slot> sur /home/runner/_work. Sans lui, --rm détruisait le clone avec le conteneur et actions/checkout re-clonait le dépôt entier à chaque job — mesure #14285 : checkout 80-148 s (contre 40-51 s sur ubuntu-latest), ~97 % du temps du job, pour un pack de 3,54 GiB. Avec le volume, checkout trouve un clone existant et fait un git fetch incrémental ; son clean par défaut nettoie l’arbre entre jobs. Un volume par slot (jamais partagé : deux jobs concurrents se battraient sur le même .git), ~4 GiB par slot sur le disque hôte. Contrôle d’acceptance : le premier job après création du volume paie encore le clone complet (attendu) ; si le second job paie le même prix, le volume n’est pas pris en compte et le correctif est inerte. Garde liée : la persistance de _work n’est sûre que tant qu’aucun code de fork n’atteint ces runners (~95 forks étudiants) — si la garde fork saute, ce volume devient un vecteur inter-jobs et la persistance doit être retirée avant d’ouvrir un trigger pull_request.
Pool d’attente PR-gate — waiters [N] (#13363, 2026-09-02) : le PR gate agrège jusqu’à 35 min en polling, occupant un slot d’exécution pendant que les jobs réels attendent derrière. supervise.sh waiters [N] (défaut 24) lance N slots sur le label dédié {self-hosted, coursia-waiter} — jamais coursia-linux, aucun job d’exécution ne doit leur atterrir (aucun workflow ne demande ce label aujourd’hui ; la bascule du gate dessus est l’item B, ai-01). Caps volontairement légers (1 cpu / 1g / 128 pids), pas de volume toolcache/_work : un slot d’attente ne coûte rien, il sur-provisionne le gate par design. L’organe d’extinction #13378 compte par label coursia-linux : les waiters lui restent invisibles (les offline de la famille n’y figurent pas). Registre : ~24 runners de plus (le plafond de runners par dépôt GitHub peut refuser l’enregistrement au-delà de la limite du plan — mesurer le compte réel à l’acceptance post-déploy). Roll : stop → waiters 24 → ai-01 bascule le gate.
Routage (décision coordinateur) — tranche 1 portée par #14148, MERGED le 2026-09-02 (merge 9bac9cd5d) : 11 workflows passent désormais sur les labels coursia-linux, sous la règle « allowlist du checker check_self_hosted_runner_policy.py + garde universelle de fork/payload + timeout + permissions: read ». Le reste des 93 jobs ubuntu-latest du census #13378 demeure sur GitHub-hosted tant que l’empreinte n’a pas été mesurée sur des jobs réels — l’élargissement (tranche 2, N slots) reste décision coordinateur après 24 h de vert sur la tranche 1.
Geste d’urgence (écrit le 2026-09-02, po-2024, AVANT d’en avoir besoin). Si les 11 workflows routés restent queued indéfiniment (superviseur mort, plus aucun runner
coursia-linux[online], lePR gateagrégeur ne complète jamais — en particulier sisupervise.shtournait dans une session et que la session a expiré, cf. §persistance), le repli d’urgence ramène les 11 workflows sur GitHub-hosted en un geste :git revert 9bac9cd5d # revert du merge de #14148 : les 11 workflows repartent sur ubuntu-latestN’est PAS un traitement du fond : il republie la tranche 1, il ne répare pas la persistance. C’est une sortie de crise, à faire quand le service CI est coupé et que le fix de persistance n’est pas encore en place — après quoi la tranche 1 sera re-routée à nouveau une fois la persistance validée. Le revert est préférable à un
workflow_dispatchmanuel : il restaure l’état antérieur connu, et il est lui-même revertable une fois le superviseur relancé.
Le gestionnaire ne bloque pas. manage_self_hosted_runner.py et self_hosted_runner_profiles.json sont Windows-only par validation (« must pin an official Windows x64 archive ») : ils ne peuvent pas porter un profil Linux aujourd’hui. Ce n’est pas un blocage — supervise.sh fonctionne sans eux, sans aucune PR préalable. Étendre le gestionnaire aux profils Linux est une PR de suivi, jamais la condition du débrayage.
Si l’empreinte mesurée pendant un job gêne la workstation (training GPU, sessions interactives), on réduit les caps ou on arrête, et on le signale à ai-01.
Persistance du POOL D’ATTENTE — coursia-waiters.service, déployée sur ai-01 (#14612, #14846)
La section ci-dessous décrit la persistance de la jambe d’exécution (start N, label coursia-linux). La jambe d’attente (waiters N, label coursia-waiter) n’en avait aucune, sur aucune machine : ni unité installée, ni même copie de référence dans persist/. Elle était lancée à la main dans une session et mourait avec elle — la panne que la section suivante existe pour supprimer, restée entière sur l’autre jambe.
Ce que ça a coûté, mesuré le 2026-09-07. Les 12 waiters d’ai-01 étaient morts depuis 15:40Z la veille (dernier Listening for Jobs, zéro conteneur sur l’hôte, 12 inscriptions offline fantômes côté GitHub). po-2024 était donc fournisseur unique du label. Quand ses conteneurs se sont mis à être fauchés à ~10 min, le PR gate — check requis — a échoué 6 fois sur 6 entre 00:06Z et 00:40Z : gel de tous les merges de la flotte. C’est très exactement le point unique de défaillance que #14612 nomme, et la deuxième occurrence du gel décrit par #14846.
L’unité. coursia-waiters.service est la jumelle de coursia-runner.service (mêmes Wants=docker.service, Restart=always, Nice=10), avec trois écarts délibérés :
| Jambe d’exécution | Jambe d’attente | |
|---|---|---|
| Commande | supervise.sh start N |
supervise.sh waiters N |
| Label servi | coursia-linux |
coursia-waiter |
STATE_DIR |
/var/lib/coursia-runner |
/var/lib/coursia-waiters |
TimeoutStopSec |
900 s (un lake build en vol) |
120 s (un slot d’attente ne porte pas de build long) |
Le STATE_DIR dédié n’est pas cosmétique — c’est la seule subtilité du montage. supervise.sh pose sa sentinelle d’arrêt gracieux en "$STATE_DIR/stop", et cmd_waiters refuse de démarrer tant qu’elle est présente (sentinel STOP pose -- arreter d'abord). Partager le state dir aurait donc fait qu’un systemctl stop coursia-runner empêche le pool d’attente de redémarrer : deux jambes conçues comme indépendantes, couplées en silence par un fichier, et un couplage qui ne se serait manifesté qu’au pire moment — pendant un arrêt de maintenance de l’autre jambe.
Acceptance — elle se mesure au registre, jamais au service. systemctl is-active rend active dès que la boucle tourne, y compris si aucun conteneur ne s’enregistre (token illisible, mauvais DOCKER_HOST, plafond de runners du plan atteint). Le seul contrôle qui répond est le compte de runners en ligne :
GH_TOKEN=$(gh auth token --user jsboige --hostname github.com) gh api "repos/jsboige/CoursIA/actions/runners?per_page=100" --jq '[.runners[] | select(.name|startswith("myia-ai-01-linux-waiter"))]
| "online=\([.[]|select(.status=="online")]|length)"'Mesure au déploiement (2026-09-07T00:39Z) : online=12, 12 conteneurs Up sur l’hôte, et deux slots busy dans la minute suivante.
Déploiement sur une autre machine : copier les deux fichiers de persist/ vers /usr/local/bin/ et /etc/systemd/system/, ajuster COURSIA_RUNNER_WAITER_NAME_PREFIX (défaut myia-ai-01-linux-waiter) et le N de l’ExecStart, puis systemctl enable --now. Ne pas relancer supervise.sh waiters à la main en session : c’est précisément ce que cette unité remplace.
Persistance du superviseur — déployée sur po-2024 (systemd dans Ubuntu + holder WSL)
supervise.sh vit dans une session : si elle meurt, les slots en ligne consomment leur inscription au prochain job et rien ne les relance. Le déploiement durable est posé et mesuré sur po-2024 (2026-09-02, mandat user « Il faut du persistant !!! ») : les slots ont quitté Docker Desktop pour la distro Ubuntu sous systemd, et le réveil de la distro est tenu par un processus holder Windows. Copies de référence committées dans scripts/ci/docker/linux-runner/persist/.
Architecture (3 étages) :
- Étage Linux (systemd) — la distro Ubuntu tourne avec systemd en PID 1.
docker-ce(pas Docker Desktop) est épinglé sur/var/run/docker-ce.sockvia un drop-indocker.service.d/coursia-socket.conf. L’unité systèmecoursia-runner.service(Requires=docker.service,Restart=always,TimeoutStopSec=900) exécute le wrapper/usr/local/bin/coursia-runner-start.sh start 4: il relit le token admin GitHub à chaque invocation depuismaster.envcôté Windows (/mnt/c/...viased+tr -d '\r'— un CRLF tuerait la valeur ; le token ne vit jamais dans la distro ni dans un argv), exporteDOCKER_HOST/GH_TOKEN/COURSIA_RUNNER_STATE_DIR=/var/lib/coursia-runner, puisexec supervise.sh start N.ExecStoppasse par l’arrêt gracieux (sentinel) : un job en vol va à son terme. - Étage pont (tâche planifiée) —
CoursIA-LinuxRunners(InteractiveToken,LeastPrivilege, logon) exécutelaunch-runner.sh: il ne fait rien lui-même, il invoque le holder et rend son rc. - Étage holder (la pièce non négociable) —
hold-runner.ps1spawn un processuswsl.exedétaché qui exécutesystemctl start coursia-runner.service && exec sleep infinity. Tant que ce processus vit, une session client WSL existe et la distro ne peut pas être reapée.
Pourquoi le holder est obligatoire — mesure décisive du 2026-09-02 : un appel wsl.exe one-shot ne suffit jamais. Séquence mesurée : 4 slots [online] → sortie du dernier client wsl → moins de 3 minutes plus tard, distro morte (wsl -l --running : Ubuntu absente ; GitHub : online: 0) — avec systemd en PID 1, le service actif et les conteneurs lancés. WSL reape la distro au départ du dernier client, quel que soit son état interne. Ce constat unifie tous les échecs de réveil one-shot observés : le pont logon qui « rend rc=0 » sans jamais remonter les slots, et la tâche S4U d’un autre hôte « rc=0 sans rien démarrer » — le rc=0 dit que la demande a été acceptée, pas que la distro a survécu au client. wsl -l --running (listage pur, qui ne réveille pas les distros) est le seul instrument de liveness qui ne confonde pas la mesure.
Mesures d’appoint :
- Guerre de flap name-replace : enregistrer un runner sous un nom existant remplace l’entrée (« Successfully replaced the runner »). Deux superviseurs sur les mêmes noms → boucle auto-entretenue
Error: Conflict / Retrying until reconnected(cadence ~2 min < TTL session ~3 min : ça ne converge jamais). Correctif mesuré : arrêt complet ~5 min (purger TOUTES les sessions), puis start unique → 4/4 online en 20 s. Corollaire : la bascule Docker Desktop → Ubuntu remplace les entrées, elle ne les duplique pas. - S4U exige l’élévation :
Register-ScheduledTask -LogonType S4Uest refusé sans admin (« Accès refusé », mêmeRunLevel Limited). La tâche bootCoursIA-LinuxRunners-Boot(S4U +AtStartup, script prêt) attend un clic UAC du user — le pont logon couvre le cas nominal en attendant. Limite connue du pontInteractiveToken: le holder meurt à la fermeture de session ; la tâche S4U boot la relance avant le logon.
Recette de réplication (un autre worker) : installer docker-ce dans la distro + drop-in socket → poser le wrapper et l’unité (persist/coursia-runner-start.sh, persist/coursia-runner.service, en adaptant NAME_PREFIX et N) → systemctl enable --now coursia-runner → créer la tâche logon qui appelle persist/launch-runner.sh (elle appelle le holder local) → valider par le protocole de mesure ci-dessus (tuer la distro, déclencher la tâche, attendre 3+ min sans aucun appel wsl, puis lister). L’installation d’un mécanisme permanent d’enregistrement reste un geste explicite (coordinateur ou user), jamais silencieux.
Cache d’archives d’actions — le rouge « Set up job » n’était pas un défaut du dépôt (#14853)
Un job qui meurt sur Set up job avant le moindre checkout porte un rouge requis que rien dans le dépôt n’explique — et qu’aucune PR ne peut réparer. La cause est en amont du dépôt : codeload sert les archives d’actions à un débit qui oscille (mesure #14853 du 2026-09-06 : 1 936 à 39 530 o/s). Sous ~16 Ko/s, l’archive de actions/setup-python (1 569 541 octets, mesurés) demande plus de 100 s, le runner abandonne ses trois tentatives, et le job échoue sur Set up job.
Ces archives sont par construction identiques à chaque run. Le runner sait les lire depuis un répertoire de cache : l’image les embarque donc une fois, au build, au lieu de les re-télécharger à chaque job.
La convention de nommage se lit dans le source du runner, elle ne se devine pas — src/Runner.Worker/ActionManager.cs, PrepareRepositoryAsync, vérifié au pin v2.337.0 (celui du RUNNER_VERSION) et sur main :
$ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE/<owner>_<repo>/<sha_resolu>.tar.gz
Deux pièges font échouer un cache écrit « au feeling » :
- le
/deowner/repodevient_, et le nom s’arrête au dépôt :github/codeql-action/init@v4et.../analyze@v4sont deux entréesuses:mais une seule archive, sousgithub_codeql-action/; - la clé est le SHA résolu, jamais le tag :
actions/setup-python@v5ne se cache pas sousv5.tar.gz.
scripts/ci/docker/linux-runner/seed_action_cache.py résout donc tag → SHA au build, avec le même mécanisme que le runner (git ls-remote, deref ^{} des tags annotés), pour que la clé écrite au build soit exactement celle que le runner cherchera au run. Il échoue si une seule action manque : un cache partiel serait un fix qui a l’air fait et ne l’est pas (--allow-partial existe pour l’assumer explicitement).
Preuve mesurée (build local du 2026-09-13, docker run --rm --entrypoint sh sur l’image construite) : 11 dépôts, 13 actions, toutes en runner:runner, et notamment
/opt/actions-cache/actions_setup-python/a26af69be951a213d495a4c3e4e4022e16d87065.tar.gz 1 569 541 o
— soit le SHA même présent dans le log d’échec de #14853, à la taille qu’il y mesurait : la clé écrite au build est bien celle que le runner recompute au run. L’image lean (Dockerfile.lean, FROM coursia-linux-runner) hérite du répertoire et des deux ENV par héritage de couche — aucun doublon.
A4 de #14853 nomme une variable qui n’existe pas. ACTIONS_RUNNER_HTTP_TIMEOUT n’est déclarée nulle part (Constants.cs du runner au pin v2.337.0 ne porte que ACTION_ARCHIVE_CACHE et SYMLINK_CACHED_ACTIONS). Le levier réel est GITHUB_ACTIONS_RUNNER_HTTP_TIMEOUT (Runner.Sdk/Util/VssUtil.cs, deux sites), exprimé en secondes, clampé à [100, 1200], valeur par défaut 100 — c’est cette valeur-là qu’il faut battre. Posé à 420 s, il sert de filet pour les actions hors cache : 1,5 Mo passe encore à ~3,7 Ko/s.
Le garde — scripts/tests/test_action_cache_seed_guard.py — rejoue la mesure sur .github/workflows/*.yml et compare l’ensemble obtenu à la liste ACTIONS du script. Sans lui, le trou est silencieux : un workflow ajoute uses: actions/foo@v1, personne ne touche la liste, et ce job continue de télécharger à la volée. Le scanner se valide par ses faux négatifs autant que par ses hits — deux pièges sont épinglés par un test chacun : la négation du mot (# uses: actions/checkout@v4 commenté n’est pas une action) et les deux formes YAML d’une étape (uses: aligné sous un name: — 224 occurrences dans le corpus — et la forme en ligne de liste - uses: — 133 occurrences) : un motif qui n’en couvre qu’une laisse toute action écrite dans l’autre hors du cache sans que rien ne rougisse.
Ce que cette tranche ne fait pas : ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS (forme « dossier déployé » du même cache) n’est pas activée — elle exige d’extraire chaque archive selon une disposition stricte, et le runner retombe silencieusement sur le téléchargement en cas d’écart. C’est un second levier, pas A1 ; l’archive est la forme que A1 demande.
Résiduel honnête : A2/A3 (contrôles positif et négatif sur un log de job réel) ne sont pas satisfaits par cette tranche. Ils exigent une image reconstruite et redéployée, puis un job réel : la preuve qu’on peut apporter sans déploiement s’arrête au contenu de l’image, et c’est ce qui est mesuré ci-dessus.
A2/A3 — les contrôles positif et négatif, mesurés sur jobs réels (#16654)
Le résiduel ci-dessus est refermé le 2026-10-03 : image reconstruite (docker build du linux-runner, seed à 11 archives, setup-python à a26af69be951a… — le SHA même de la preuve A1) et deux jobs réels exécutés dessus (run 37093241932, jobs 111117851523/111117939237, tous deux succès), depuis un slot éphémère à label unique seed-a23 enregistré par ACTIONS_RUNNER_INPUT_* puis désenregistré nativement après un job — aucun job de la flotte (pools coursia-*) ne peut atterrir sur ce label, et la flotte de production n’a pas été touchée : le contrôle démontre le comportement de l’image reconstruite, pas un redéploiement des slots po-2024.
Où la preuve se lit — pas dans le libellé du journal de job : « Download action repository » y figure même sur hit (c’est le nom d’étape, pas un événement réseau). La preuve vit dans le journal de diagnostic du runner (/opt/runner/_diag/Worker_*.log, niveau INFO) et dans l’état du répertoire de cache mesuré après le job (docker cp du conteneur mort, comparé à la baseline du build) :
| contrôle | diagnostic du runner (INFO, horodaté) | /opt/actions-cache après le job |
|---|---|---|
A2 — hit : actions/setup-python@<SHA seedé> |
Found action archive '/opt/actions-cache/actions_setup-python/a26af69….tar.gz' in cache directory ; zéro requête codeload, zéro « Save archive » |
byte-identique à la baseline — aucune écriture |
A3 — miss : actions/setup-java@v4 (absent du seed) |
cache consulté, non trouvé → Save archive 'https://codeload.github.com/actions/setup-java/tar.gz/cf277c60…' → Request URL: … Http Status: OK (~1 s) |
inchangé |
Le « cache enrichi » attendu par #16654 est réfuté par la mesure. Sur miss, l’archive téléchargée atterrit dans _work/_actions/_temp_<guid>/, jamais dans le répertoire de cache : ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE est en lecture seule au runtime — seule l’image (le seed au build) y écrit. Une action absente du seed se télécharge donc à chaque job, sans exception ni apprentissage. C’est le renfort de fait du garde zéro-trou (test_action_cache_seed_guard.py) : puisque rien ne peut enrichir le cache au run, seul le build ferme la porte.
Le workflow de contrôle vit sur une branche jetable (ci/16654-seed-a23-control, supprimée après capture) : le garde seed interdit par design la présence de setup-java sur main, ce qui est exactement le contrôle négatif voulu. Pour rejouer : reconstruire l’image, pousser une branche portant deux jobs runs-on: [self-hosted, <label unique>] (action seedée épinglée au SHA du build / action absente du seed), enregistrer un conteneur éphémère avec ce label, puis lire Worker_*.log et comparer le cache avant/après.
Mode persistent — le conteneur qui retire le maillon superviseur (#14329)
Le superviseur hôte n’existe que parce que les runners sont --ephemeral : un runner éphémère traite au plus un job puis se désenregistre, donc un processus hôte doit reminter un token et relancer un conteneur à chaque job — et ce processus hôte est exactement le maillon qui meurt au logoff (mesure du reboot 2026-09-02 : flotte retombée à 1 runner, tous les PR gate gelés en STARVED). L’idée user : « un conteneur en autorestart qui fait le polling tout seul ».
Le design livré dans l’image (RUNNER_MODE=persistent, --ephemeral reste le défaut — rétro-compatible) :
- Volume de config par slot monté sur
/opt/runner: il porte le layout complet du runner (.runner+.credentials+ binaires). L’image extrait le tarball vers/opt/runner-dist(source vierge) et copie vers/opt/runner; si le volume est créé vide, l’entrypoint restaure les binaires depuisrunner-distau boot — monter un volume sur/opt/runnerne masque donc jamais les binaires. - Enregistrement une seule fois :
token/url/name/labelsne sont exigés que si.runnerest absent (premier boot). Aux restarts suivants, l’entrypoint détecte.runner, journalise « reprise sans ré-enregistrement » et lancerun.shdirectement — aucune variable requise, le token (valable 1 h) ne sert plus jamais. - Pas de teardown : le
trap 'config.sh remove'du mode éphémère est absent — désenregistrer au EXIT tuerait la propriété même du mode.--replacereste posé à l’enregistrement : un slot recréé remplace son entrée offline.
Lancement type (slot N) :
docker run -d --restart unless-stopped \
-v coursia-runner-cfg-N:/opt/runner \
-v coursia-work-N:/home/runner/_work \
-e RUNNER_MODE=persistent \
-e ACTIONS_RUNNER_INPUT_TOKEN=... -e ACTIONS_RUNNER_INPUT_URL=https://github.com/jsboige/CoursIA \
-e ACTIONS_RUNNER_INPUT_NAME=myia-po-2024-linux-docker-N \
-e ACTIONS_RUNNER_INPUT_LABELS=self-hosted,coursia-linux \
coursia-linux-runner
Docker relance le conteneur à chaque démarrage du daemon (donc de la distro) ; le volume _work de #14288 se combine avec celui de config, il ne s’y substitue pas.
Ce que la non-éphéméralité coûte — écrit, pas supposé :
- État persistant entre jobs. C’est précisément ce que
--ephemeralachetait (#13378) : workspace, tool-cache et processus résiduels survivent d’un job au suivant. Le troc est accepté parce que la contrainte « le code des forks étudiants ne touche jamais le self-hosted » tient par ailleurs : aucunpull_request_targetauto-hébergé, garde universelle fork/payload (github.event.pull_request.head.repo.full_name == github.repository, tranche 4), et « Require approval for all outside collaborators » côté dépôt. Retirer la persistance avant tout triggerpull_requestreste la règle. - Sémantique des hooks entrypoint. En éphémère, les blocs de désarmement (sparse-checkout résiduel, refs dangling) et
work_cache_healths’exécutent à chaque job (conteneur--rmpar job). En persistent, ils s’exécutent au boot du conteneur seulement : le runner enchaîne les jobs sans relancer l’entrypoint. Le nettoyage inter-jobs repose alors sur lecleanpar défaut du checkout ; le hook de boot reste en première ligne à chaque restart Docker. Ce n’est pas un renforcement : c’est une couverture moins fréquente, assumée. - Un slot offline consomme son inscription. Un runner non-éphémère arrêté reste enregistré « idle/offline » sur GitHub jusqu’à son retour — contrairement à l’éphémère qui se désenregistre seul. Sans incident tant que le conteneur revient ; c’est le compteur GitHub à lire après un long arrêt.
Validation au déploiement (po-2024 uniquement, jamais ai-01) : la preuve du mode est un systemctl restart docker qui voit les slots revenir sans intervention — geste destructif réservé à la machine qui ne porte ni vLLM, ni Qdrant, ni ComfyUI. La mesure avant/après du temps de checkout se prend au même moment (volumes _work + config combinés).
Tranches suivantes, activation partielle
La préparation complète reste découpée :
- Mesure — instrument de cette page.
- Isolation statique — scanner fail-closed, allowlist et labels dépôt.
- Cycle de vie local — gestionnaire, profils, probes et teardown décrits ci-dessus.
- Commutation — un seul point de bascule et garde universelle de fork/payload sur chaque workflow routé :
github.event.pull_request.head.repo.full_name == github.repository; aucunpull_request_targetauto-hébergé. C’est la forme appliquée par #14148 aux 11 workflows de la tranche 1. - Preuve contrôlée — autorisation explicite, une exécution légère réussie, contrôle négatif fork/payload (livré : garde #13387, simulation run 33185586681), puis teardown et preuve que l’état initial est restauré.
- Capacité — le contrôleur ci-dessus ; son test de bout en bout (tâche posée → tick → job consommé → ré-enregistrement observé → tâche retirée) exige une session élevée : c’est la checklist de la session d’activation, le bouton appartient au coordinateur ou au user.
État au 2026-08-28 : les tranches 1-3 sont livrées ; la tranche 4 est active sur po-2024 (jobs réels consommés par le pool coursia-fast-guards, ex. runs 33092567324 et 33093119578) ; la preuve contrôlée complète (5) et l’extension du pool aux autres machines restent à faire. Chaque extension machine exige le provisionnement Python de la section dédiée avant le premier job.
| Profil du registre | État — inventaire GitHub firsthand du 2026-09-01 |
|---|---|
myia-po-2023-fast-guards |
en préparation (aucun runner enregistré) |
myia-po-2024-fast-guards |
actif — seul runner du dépôt ; Windows ; tool-cache seedé (a2), ré-enregistrement sans UAC, jobs réels consommés |
myia-po-2025-fast-guards |
en préparation (aucun runner enregistré) |
myia-po-2026-fast-guards |
en préparation (aucun runner enregistré ; profil vérifié dans le registre) |
coursia-linux (conteneur) |
en ligne + persistant — 4 slots conteneurisés sur po-2024 (myia-po-2024-linux-docker-1..4) sous systemd dans Ubuntu (holder WSL, cf section Persistance), preuve d’identité run 33554804211 (RUNNER_OS = Linux) |
La colonne est datée d’une mesure, pas d’une intention : le tableau précédent portait « actif » et « en préparation » sans dire ce qui avait été compté, ce qui a laissé lire une conception comme un déploiement.
Le réglage GitHub « Require approval for all outside collaborators » complète la garde YAML ; il ne la remplace jamais (non exposé par l’API /actions/permissions — capture à faire côté admin, UI Settings → Actions). L’activation finale reste un geste explicite du user ou du coordinateur, après validation des tranches précédentes.