Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .github/workflows/runner-coresidence-advisory.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: Runner co-residence advisory

# Organe de MESURE de la co-residence du pool self-hosted (#15574).
#
# POURQUOI CE WORKFLOW EXISTE
# ---------------------------
# Un test CPU dont le temps varie d'un facteur 3-4 selon le runner qui le
# prend, a code constant, ne mesure plus le code (#15574). Les distributions
# par label et par runner localisent une saturation ; elles ne revelent pas
# « combien de runners partagent un hote physique, ni son plafond de
# concurrence » (docs/ci/self-hosted-runners.md). Ce sont les deux nombres qui
# manquent pour trancher une sur-souscription, et ils sont mesurables --
# `scripts/ci/measure_runner_demand.py` les rend par deux blocs :
#
# - `co_residence` (DYNAMIQUE) : par job, le pic et la moyenne du nombre de
# jobs qui tournaient sur le meme hote, croises avec sa duree.
# - `runners_inventory` (STATIQUE) : les slots enregistres par hote.
#
# POURQUOI UN RUNNER DEDIE ET PAS UNE COMMANDE LOCALE
# ---------------------------------------------------
# La collecte coute ~1 appel API par run (les jobs) plus la pagination. Un
# poste de travail epuise son quota REST bien avant de couvrir une fenetre
# utile -- mesure du 2026-09-21 : quota epuise avant la fin d'une fenetre d'une
# heure, l'instrument rendant alors `BROKEN INSTRUMENT` (exit 2) plutot qu'un
# zero propre. Le jeton du workflow a son propre quota, et l'organe devient
# reproductible par quiconque, sans dependre du poste d'un agent.
#
# Advisory PAR CONSTRUCTION : schedule + workflow_dispatch seulement, jamais
# pull_request ni push -- un run rouge ne peut donc jamais bloquer une PR, et
# le nom porte « advisory ». Le job tourne sur `ubuntu-latest` (jamais
# self-hosted : l'observateur ne doit pas consommer ce qu'il observe).
#
# `--runners` exige `administration: read`. Sans le secret RUNNERS_READ_PAT,
# l'inventaire sort `unavailable` AVEC sa raison -- jamais un parc vide, qui
# serait indiscernable d'un refus de lecture (meme discipline que
# check_runner_starvation.py, #13378).
#
# FENETRE : 6 h par defaut. Le jeton de workflow plafonne a 1000 requetes par
# heure et par depot ; le depot tire ~60-80 runs par heure, soit ~500 appels
# pour 6 h. Une fenetre plus large doit etre demandee explicitement, en
# sachant qu'elle peut rendre `BROKEN INSTRUMENT`.

on:
# Mardi 04:20 UTC : apres le slow-lane (02:30) et hors du pic de push
# europeen. Minute off-:00 et hors des offsets de sweep (13, 43).
schedule:
- cron: '20 4 * * 2'
workflow_dispatch:
inputs:
hours:
description: "Taille de la fenetre mesuree, en heures"
required: false
default: "6"
runner_inventory:
description: "Collecter aussi l'inventaire des runners (exige le PAT)"
required: false
type: boolean
default: true

permissions:
contents: read
actions: read

concurrency:
group: runner-coresidence-advisory
cancel-in-progress: false

jobs:
coresidence:
name: Runner co-residence measurement
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Measure co-residence
id: measure
env:
# Le PAT read-only (Administration:read) quand il existe ; sinon le
# jeton du workflow, qui fera rendre `unavailable` a l'inventaire au
# lieu de le fabriquer.
GH_TOKEN: ${{ secrets.RUNNERS_READ_PAT || github.token }}
HOURS: ${{ inputs.hours || '6' }}
WANT_INVENTORY: ${{ inputs.runner_inventory || 'true' }}
run: |
set -uo pipefail
python - > /tmp/window.env <<'PY'
import datetime as d
import os
now = d.datetime.now(d.timezone.utc)
hours = int(os.environ.get("HOURS", "6"))
print("SINCE=" + (now - d.timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%SZ"))
print("UNTIL=" + now.strftime("%Y-%m-%dT%H:%M:%SZ"))
PY
. /tmp/window.env
echo "fenetre : ${SINCE} -> ${UNTIL}"
ARGS=(--repo "$GITHUB_REPOSITORY" --since "$SINCE" --until "$UNTIL" --output /tmp/coresidence.json)
if [ "${WANT_INVENTORY}" = "true" ]; then
ARGS+=(--runners)
fi
set +e
python scripts/ci/measure_runner_demand.py "${ARGS[@]}"
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
set -e

- name: Publish verdict
if: always()
env:
EXIT_CODE: "${{ steps.measure.outputs.exit_code }}"
run: |
if [ ! -s /tmp/coresidence.json ]; then
echo "::warning title=Runner co-residence (instrument casse)::La mesure n'a pas abouti (exit=${EXIT_CODE}). C'est un verdict sur l'instrument ou le quota, jamais un parc sans concurrence -- ne pas lire l'absence de sortie comme un zero."
exit 0
fi
python - <<'PY'
import json, pathlib
raw = json.loads(pathlib.Path("/tmp/coresidence.json").read_text(encoding="utf-8"))
a = raw.get("analysis", {})
summary = a.get("co_residence", {}).get("summary", {})
inv = a.get("runners_inventory", {})
window = a.get("window", {})
lines = [
f"fenetre : {window.get('since')} -> {window.get('until')} ({window.get('hours')} h)",
f"jobs places : {summary.get('jobs_placed')} | non attribues : {summary.get('jobs_unplaced')}",
f"jobs seuls : {summary.get('solo_jobs')} | en concurrence : {summary.get('shared_jobs')}",
f"p50 seul : {summary.get('solo_runtime_minutes', {}).get('p50')} min"
f" | p50 partage : {summary.get('shared_runtime_minutes', {}).get('p50')} min"
f" | ratio : {summary.get('shared_over_solo_p50_ratio')}",
f"hotes observes : {summary.get('hosts')}",
f"inventaire runners : {inv.get('availability')}"
+ (f" ({inv.get('total_runners')} runners)" if inv.get("availability") == "measured" else f" -- {inv.get('detail')}"),
]
for host in a.get("co_residence", {}).get("hosts", [])[:12]:
lines.append(
f" {host['host']}: {host['slots_observed']} slot(s), {host['jobs']} jobs,"
f" pic {host['max_concurrency_observed']}, moyenne {host['mean_concurrency_overall']}"
)
body = "\n".join(lines)
print(body)
print("::notice title=Runner co-residence (%s -> %s)::%s" % (
window.get("since"), window.get("until"), body.replace("\n", " | ")))
PY
32 changes: 32 additions & 0 deletions docs/ci/self-hosted-runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,38 @@ Avant toute bascule, relever au minimum :

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.

```bash
# après merge (workflow_dispatch exige le fichier sur la branche par défaut)
gh workflow run runner-coresidence-advisory.yml -f hours=6 -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.** L'instrument et son runner sont livrés ; **les chiffres ne le sont pas encore**. Ils seront publiés par le premier run de l'organe (cron du mardi 04:20 UTC, ou dispatch immédiat après merge) et versés ici datés, avec la décision de capacité qu'ils fondent. Tant qu'ils ne sont pas là, #15574 reste ouvert : un instrument n'est pas une caractérisation.

## 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 :
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/scripts-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ Pipeline d'audit qualité et de **matrice de coût** (EPIC #8056) + audit séman
| `scripts/check_adjoint_prevalidation.py` | **Gate d'entrée en review ai-01** (#16442) : exige un dossier `[ADJOINT PREFLIGHT]` complet de la lane adjointe, lié au SHA exact et à une empreinte des surfaces body/comments/reviews/threads/checks. Toute absence, mutation observable des surfaces actuelles ou valeur non canonique échoue fermé ; un événement GitHub ensuite supprimé/reverté n'est pas reconstructible par cet organe stateless. `--template` génère le bloc complet (empreinte incluse) et `--fingerprint` expose l'empreinte seule. Exit `0` READY / `1` BLOCKED / `2` UNKNOWN. READY autorise la lecture finale du coordinateur, jamais le merge |
| `scripts/check_pr_perimeter.py` | **Source de vérité périmètre pour reviews** (#11268) : énumère les fichiers effectifs d'une PR (`gh pr view --json files`), nomme tout `.github/workflows/**` dans une section dédiée, détecte les **mouvements de baseline/seuil** du diff avec leur sens (`sorry-baseline` 16→14 = TIGHTEN ; un desserrement sans `--baseline-justified` => CHANGES_REQUESTED), et confronte l'assertion de périmètre du reviewer (`--assert "..."`) à la liste réelle — la review #11227 (« 2 fichiers twins uniquement » sur 3 fichiers dont un workflow) ne peut plus être produite à l'insu. `--scan-thread` scanne le body PR + les reviews top-level et confronte chaque assertion trouvée à la liste effective ; câblé par `.github/workflows/perimeter-review-guard.yml` (déclenché sur `pull_request` + `pull_request_review`) — une fausse assertion devient un check rouge bloquant. Exit `0` OK / `1` FAIL (écart assertion, workflow non nommé, desserrement nu) / `2` erreur gh. À exécuter AVANT toute assertion de périmètre dans une review |
| `scripts/coordination/debt_ledger.py` | **Reducteur du ledger de dette d'issue partage** (`issue-debt`) : schema versionne, validation stricte des observations (UTC explicite, provenance obligatoire, cles inconnues refusees), fusion par champ « plus recente observation compatible gagne » avec provenance et historique, pliage idempotent du snapshot precedent (archive-aware). Le **transport partage n'est pas un fichier** : observations = messages append-only du dashboard dedie `CoursIA-issue-debt-ledger`, snapshot ecrit par ai-01 seul en section `status` — jamais d'ecriture sous `$ROOSYNC_SHARED_PATH` (Drive : pas de verrou, `assert_local_output` refuse, sans override). CLI `init` (dry-run par defaut) / `append` (imprime l'appel MCP, n'ecrit rien) / `reduce` (artefacts locaux atomiques sous verrou). L'adaptateur de l'export producteur, le contrat de couverture `window` et le ledger `pr-actions` arrivent avec le transport. Schema et metriques : [scripts/coordination/README.md](../../scripts/coordination/README.md) ; lectures : `scripts/tests/test_debt_ledger.py` |
| `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`. Exit `0` mesure valide / `2` instrument ou snapshot incomplet. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) |
| `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/mcp-maintenance/` | Maintenance MCP (config, docs, scripts) — cf `README_MCP_MAINTENANCE.md` |
| `scripts/validation/dispatch.py` + `matrix.yml` | Matrice de validation / dispatch |
Expand Down
Loading
Loading