CI/CD et release¶
Tout est automatisé par GitHub Actions. Cette page cartographie les 22 workflows et le processus de publication.
Les workflows¶
| Workflow | Déclencheur | Rôle | Bloque la PR ? |
|---|---|---|---|
maven.yml · job build |
push main + PR |
« Java CI » : ./mvnw -B verify -Djacoco.haltOnFailure=true (compilation + tous les tests dont ArchUnit + seuils de couverture JaCoCo bloquants + hygiène des dépendances, dependency:analyze-only avec failOnWarning) |
Oui |
maven.yml · job emballage |
push main + PR |
Assemblage du fat-jar (package -DskipTests), smoke-test, idempotence, app-image, démarrage réel, archive portable. Verse l'app-image au job bats. En parallèle de build |
Oui |
maven.yml · job ordre-alternatif |
push main + PR |
Rejoue la suite en fork unique et ordre inverse, réparti en quatre lots depuis #5329, la graine tournant d'un passage à l'autre | Oui |
maven.yml · job bats |
push main + PR |
E2E CLI bats sur le lanceur empaqueté, 131 cas. Sorti d'emballage en #5301 : il en faisait 80 % du temps, et un cas rouge cachait l'état de l'emballage |
Oui |
maven.yml · job second-compilateur |
push main + PR |
Recompile tout avec le compilateur Eclipse (-Pecj), sans les tests : ce que javac accepte, un autre compilateur conforme ne l'accepte pas forcément (cf. plus bas). En parallèle des deux autres |
Oui |
maven.yml · job fuseau-alternatif |
push main + PR |
Rejoue toute la suite sous America/Cayenne : ce que le produit calcule pour une nuit ne dépend pas du fuseau de la machine (ADR 3450). TZ passe par l'environnement, hérité des forks surefire, et FuseauDExecutionTest vérifie depuis l'intérieur que la zone est bien appliquée |
Oui |
maven.yml · job plateforme-de-test |
push main + PR |
Monte la plateforme de test de l'ADR 5641 par Testcontainers - l'API Vigie-Chiro et son front web aux révisions d'scripts/plateforme-de-test/epingles.properties (#6060), Mongo, le faux S3 en TLS, l'état de départ déclaré - puis y joue Companion sous -Pplateforme-de-test, sans aucun jeton de la plateforme nationale. Un navigateur conteneurisé y lit la fiche web, pour relire ce que Companion a écrit (#6061, #5645). Quand le job rougit, il dépose les journaux de l'API, du faux S3 et de Mongo, avec les rapports surefire, en artefact journaux-de-la-plateforme-<tentative> (#6183). Ubuntu seulement, faute de Docker ailleurs ; le tag reste exclu de ./mvnw test. Sa portée inclut scripts/plateforme-de-test/** : une épingle remontée change le verdict sans toucher une ligne de Java (#5666) |
Oui |
lint.yml · job duree-du-portail |
push main + PR |
Compare la médiane des 12 dernières exécutions réussies sur main à celle des 12 d'avant, et avertit au-delà de 20 % d'écart. Une CI riche se dégrade par accumulation, jamais d'un coup : chaque ajout coûte trente secondes que personne ne remarque. Deux médianes, et non une exécution contre un seuil : sur trente exécutions, deux durent le double des autres, et un butoir aurait rougi sans qu'aucune PR soit fautive (#3508) |
Non - il avertit |
maven.yml · job duree-de-l-atelier-des-gardes |
push main + PR |
La même mesure, sur lint.yml : deux médianes glissantes de douze exécutions vertes, avertissement au-delà de 20 % d'écart. Elle manquait, et lint.yml était le seul atelier que rien ne suivait alors que l'instrument prend le workflow en argument (#5540). Les deux mesures sont croisées - celle-ci lit lint.yml depuis maven.yml, duree-du-portail lit maven.yml depuis lint.yml - parce qu'un atelier qui se mesure entre dans la fenêtre qu'il calcule. maven.yml se mesurait ainsi depuis #3508 : mesuré à 0,9 % du flux et 0,2 point de dérive, donc bénin, et corrigé quand même, une rétroaction se composant là où un compte se constate. Un cas de --auto-test refuse désormais qu'un atelier mesure celui où il vit, et il a rougi sur l'état d'avant |
Non - il avertit |
| suite-sous-windows-et-macos.yml | hebdomadaire (mardi 6 h UTC) + manuel | Lance la suite entière sous Windows et macOS, et conclut : rouge dès le premier échec, TestFX compté à part. Manuel jusqu'à #3526, le temps de savoir ce que la suite y donnait - 11 échecs sous Windows au premier relevé, 0 sous macOS. Programmé la veille du train de publication, dont il est désormais la condition (cf. plus bas). En manuel il peut être ciblé sur quelques classes (#3754, 2 min contre 48) : un passage ciblé sert à instruire, jamais à prouver | Oui |
lint.yml · job lint |
push main + PR |
Le lint au sens propre : Spotless, ruff, shellcheck | Oui |
lint.yml · job methode |
push main + PR |
Les gardes de méthode : la prose, les inventaires, les concordances, et test-compile pmd:pmd qui produit le rapport que le cliquet 4617 juge juste après - le producteur vit avec son consommateur |
Oui |
lint.yml · job temoins |
push main + PR |
Les bancs de mutation : chaque garde perd sa détection, sa suite doit rougir. Porté depuis #5365, il ne s'exerce que si le diff touche un garde | Oui |
| docs.yml | push/PR sur la doc | Construit les deux sites MkDocs (--strict) ; déploie Pages (dormant tant que ENABLE_PAGES ≠ true) |
Build oui |
| titre-pr.yml | PR (dont edited) |
Le titre de la PR suit Conventional Commits (c'est lui que semantic-release lira, cf. ci-dessous) | Non - informatif, et volontairement (cf. ci-dessous) |
| corps-pr.yml | PR (dont edited) |
Le corps de la PR passe la part mécanisable de la grille de prose : cadratin, apostrophe courbe, élision sans apostrophe. Ce corps n'est jamais commis, et c'est pourquoi aucun garde de fichiers ne l'atteignait ; il est pourtant publié dès qu'il part (ADR 4453) | Non - informatif, comme le titre |
| critere-de-fin.yml | issue (opened, edited, reopened) |
Un lot ouvert sans dire comment on saura qu'il est fini reçoit un rappel en commentaire, une seule fois. Il rappelle et ne refuse pas : l'arbitrage de #4961 a écarté le rouge qui tombe sur qui n'a pas la main. La forge n'émet aucun évènement au rattachement d'une sous-issue, donc un lot rattaché après coup et jamais réédité lui échappe | Non - il rappelle, et ne peut faire rougir aucune demande |
| capture-vues.yml | push main |
Régénère les aperçus PNG (cf. Captures) | — |
| release.yml | hebdomadaire (mercredi 6 h UTC) + manuel | Version + Release + installeurs natifs (dormant tant que ENABLE_RELEASE ≠ true). Le train de publication depuis l'ADR 2744 - la ligne disait encore « push main » neuf jours après le changement. Ne part pas sans preuve fraîche des plateformes, sauf contournement écrit (cf. plus bas) |
— |
| api-live.yml | hebdomadaire (lundi) + manuel | Contrat de l'API Vigie-Chiro, en lecture seule ; sépare « jeton mort » (warning) de « contrat cassé » (rouge), et rougit au bout de trois semaines sans vérification réelle (cf. ci-dessous) | — |
| codeql.yml | push main + PR + hebdomadaire (lundi 5 h UTC) |
Analyse statique de sécurité CodeQL sur le code Java (cf. plus bas). Le schedule n'est pas décoratif : les requêtes CodeQL évoluent, donc une base de code inchangée peut devenir signalable sans qu'aucun commit l'ait touchée |
Oui sur PR |
| securite-dependances.yml | hebdomadaire (lundi 6 h UTC) + PR sur pom.xml |
Rapport de vulnérabilités des dépendances livrées (cf. plus bas), et deux mesures de fraîcheur : les actions épinglées et les outillages npm figés. Le filtre de chemins inclut le workflow lui-même : une étape que seul un schedule exerce peut être fusionnée cassée, et ce chemin la fait tourner sur la PR qui la modifie |
— |
| adr-rapport.yml | hebdomadaire + manuel | Rapport ADR (calibration des cliquets et des loupes) | — |
| mutation-model.yml | quotidien (3 h UTC) + manuel | Mesure de mutation PIT sur un paquet model par tour (rotation sans état, cycle de 17 jours), E2E et commun.api exclus : bilan dans le résumé du job, rapport détaillé en artefact |
— |
| mutation-ihm.yml | quotidien (5 h UTC) + manuel | Mesure de mutation PIT sur les vues d'une feature par tour (rotation sans état, cycle de 15 jours), E2E exclus | — |
| releve-des-bancs-instables.yml | quotidien (7 h UTC) + manuel | Taux d'intermittence des bancs, lu dans les exécutions passées : il ne rejoue rien, « les runs passés SONT les tirages ». Il existait et n'était jamais joué avant #5617 - lint.yml ne lançait que son auto-test - donc son verdict ne déclenchait rien parce qu'il n'était jamais produit. Nocturne parce qu'il dézippe l'archive de journaux de chaque tentative rejouée : le coût est borné par les relances et non par les runs, huit archives pour 144 tirages sur sept jours. Il ne refuse rien - un taux n'est pas un verdict sur une demande, et bloquer punirait celle qui a croisé le banc - son seul code non nul étant un refus de conclure. Sortie portée au résumé de l'exécution |
— |
| flatpak.yml | appelé par le train (workflow_call depuis release.yml), ou manuel |
Paquet Flatpak (cf. plus bas) | — |
| winget.yml | manuel (workflow_dispatch) |
Soumission d'une version choisie à winget-pkgs (cf. plus bas) | — |
| recette-filmee.yml | appelé par le train (workflow_call depuis release.yml), ou manuel |
Éprouve qu'un runner pilote un test filmé, et pas seulement qu'il l'exécute. Porte son témoin : sans gestionnaire de fenêtres, le lancement doit être refusé (cf. plus bas). Avec publier_les_clips, il verse le tournage complet sur clips-recette et, quand le train lui passe une version, une copie préfixée bash- sur le tag de cette version, qui lui ne bougera jamais (#4258) |
— |
| tournage-recette.yml | manuel (workflow_dispatch) |
Tourne les clips d'une session, sur la plateforme de son choix - ubuntu, windows ou macos, le banc en Java pur n'ayant besoin d'aucun écran. Répond à la limite que l'EPIC #4133 se donnait : « à 400 clips, ce serait des heures par passage ». La liste des classes est dérivée par CorrespondanceRecetteTest, jamais tenue à la main. Appelé aussi par le train (workflow_call), il verse alors ses clips préfixés java- sur le tag de la version, à côté de ceux du banc bash : c'est la transition qui se prépare, et le jour venu il n'y aura qu'un des deux appels à retirer (#4258). Il ne touche jamais à une pré-version roulante : clips-java est un instantané daté que la page de comparaison interroge. Depuis #4304 il porte un drapeau connecte : le tournage parle alors à la VRAIE plateforme, en lecture, avec un secret propre au tournage posé dans l'env: du seul pas qui filme. Sans le secret il refuse de partir, un écran hors ligne étant convaincant et muet sur son objet |
— |
| comparer-tournages.yml | manuel (workflow_dispatch) |
Dit ce qui a changé entre deux tournages : présence du cas, image finale accolée, carte des différences, durée. Prend deux sources - un tag de version, clips-recette, ou un numéro d'exécution de tournage-recette.yml, pour comparer une branche à main avant fusion (#5930) - et normalise les préfixes de banc, sans quoi bash-Truc.mp4 et Truc.mp4 passeraient pour deux cas différents. À la demande et non committé : la comparaison « dernière version contre tournante » change dès que l'un des deux bouge (#4274) |
— |
| mesurer-les-planchers.yml | manuel (workflow_dispatch) |
Mesure le plancher de bruit de chaque clip, c'est-à-dire de combien deux tournages du même commit diffèrent, avec l'instrument qui le lira : la même image de runner et les mêmes paquets que comparer-tournages.yml. Le même clip rend 0,020 % sur un poste et 0,134 % ici, et des planchers pris ailleurs faisaient annoncer une trentaine de clips « changés » pour un code identique (#5885). Reçoit des numéros d'exécution de tournage-recette.yml et ne tourne rien ; refuse des tournages de commits différents ; rend le fichier en artefact et n'écrit rien sur le dépôt, une demande le committe |
— |
L'image devcontainer pré-buildée a été retirée
Un workflow devcontainer-image.yml publiait une image sur GHCR pour accélérer le démarrage des
Codespaces. Il se déclenchait sur une branche solution absente de ce dépôt : il n'a jamais
tourné et l'image n'a jamais existé, si bien que le conteneur ne pouvait plus se construire.
Le .devcontainer/ reconstruit désormais depuis son Dockerfile et ses features (#2388).
Le vert du contrat API dit maintenant ce qu'il a vérifié
Un jeton VigieChiro vit 14 jours face à un passage hebdomadaire : il expire donc
régulièrement, et api-live.yml reste volontairement vert dans ce cas, avec un avertissement.
Un rouge permanent ne signalerait plus rien.
L'angle mort, mesuré en #2748 : ce vert-là ne distinguait pas « contrat vérifié » de « contrat sauté ». Deux passages verts d'affilée n'avaient rien vérifié, la dernière exécution réelle remontant à 16 jours. Personne ne l'avait vu, et c'est le point : il n'y avait rien à voir.
Chaque passage se termine désormais par une veille de fraîcheur
(veille_contrat_api.py),
dont le verdict s'affiche dans le résumé du run, vert compris :
- sous 21 jours (trois passages hebdomadaires manqués) : vert, avec la date de la dernière vérification réelle ;
- au-delà : rouge. Tolérer une expiration reste juste ; ne plus jamais vérifier ne l'est pas.
Elle ne persiste rien : l'historique des passages est la date recherchée, lue par
actions: read. Un fichier commité, un artefact (90 jours) ou un cache (7 jours) deviendraient
chacun une seconde chose à surveiller, dont la première panne serait, ici encore, un silence.
Elle reconnaît un passage vérifié au nom de son étape (Contrat API (lecture seule)).
Renommer cette étape sans reporter le nom dans ETAPE_CONTRAT casserait la détection : la veille
refuse alors de conclure et dit que c'est elle qui est en cause, plutôt que d'annoncer un
rassurant « jamais joué ». Son autotest tourne à chaque PR dans lint.yml, seul endroit où on la
voit à l'œuvre entre deux lundis.
Workflows « dormants »
Pages et release ne s'activent que via des variables de dépôt (ENABLE_PAGES,
ENABLE_RELEASE = true). Tant qu'elles sont absentes, ces étapes ne rougissent pas la CI.
Quels ateliers portent un suivi de durée, et pourquoi les autres non (#5591)¶
mesure_duree_portail.py compare deux médianes glissantes de douze exécutions réussies sur
main, donc il lui en faut vingt-quatre. Ce n'est pas un réglage : c'est la condition sans
laquelle il refuse de conclure, et elle décide à elle seule quels ateliers peuvent être suivis.
Mesuré le 2026-09-30 en jouant l'instrument sur les vingt ateliers, avec sa propre requête
(?branch=main&status=success&per_page=24) plutôt qu'un comptage réécrit.
| Atelier | Exécutions vertes sur main |
Suivi |
|---|---|---|
critere-de-fin.yml |
assez | possible |
release.yml |
assez | possible |
codeql.yml |
assez | possible |
lint.yml |
assez | suivi depuis #5540 |
maven.yml |
assez | suivi depuis #3508 |
capture-vues.yml |
assez | possible |
docs.yml |
assez | possible |
mutation-ihm.yml |
assez | possible |
mutation-model.yml |
assez | possible |
recette-filmee.yml |
assez | possible |
api-live.yml |
16 | non : sous les 24 |
tournage-recette.yml |
16 | non : sous les 24 |
suite-sous-windows-et-macos.yml |
11 | non : sous les 24 |
adr-rapport.yml |
10 | non : sous les 24 |
securite-dependances.yml |
8 | non : sous les 24 |
flatpak.yml |
6 | non : sous les 24 |
comparer-tournages.yml |
3 | non : sous les 24 |
winget.yml |
1 | non : sous les 24 |
releve-des-bancs-instables.yml |
aucune, neuf | non : sous les 24, et pour 24 nuits |
mesurer-les-planchers.yml |
aucune, par construction | non : manuel, et lancé sur la branche qui porte le fichier à compléter (#5885) |
titre-pr.yml |
aucune, par construction | non : ne tourne jamais sur main |
corps-pr.yml |
aucune, par construction | non : ne tourne jamais sur main |
Le relevé des bancs instables, et pourquoi il est nocturne (#5617)¶
releve-des-bancs-instables.yml joue scripts/methode/releve-les-bancs-instables.py chaque nuit à
7 h UTC, sur une fenêtre de trente jours, et porte sa sortie au résumé de l'exécution.
Il existait et n'était jamais joué. lint.yml lançait son --auto-test, de façon bloquante, et
rien d'autre : son verdict ne déclenchait rien parce qu'il n'était jamais produit. Six bancs du
dépôt rougissent par intermittence, le relevé sait dire leur taux, et personne ne le lui demandait.
Pas dans lint.yml, parce qu'il lit les exécutions passées et dézippe l'archive de journaux
de chaque tentative rejouée. Le coût est borné par le nombre de relances, pas de runs : sur sept
jours, 144 tirages pour 8 relances, donc huit archives. Trente jours en coûtent une trentaine, ce
qui tient dans une nuit et que personne n'attend.
Il ne refuse rien. Un taux d'intermittence n'est pas un verdict sur une demande, et un atelier qui
bloquerait sur un banc instable punirait celle qui a eu la malchance de le croiser. Son seul code non
nul est un refus de conclure - gh ou unzip absent, forge muette - et celui-là doit rougir.
Sa déclaration de limite a menti jusqu'à ce lot. Elle annonçait « atelier build seul, 7
invisibles », alors que joindreLesJournaux joint un journal par atelier de l'archive, soit les
huit qui lancent la suite. Une limitation déclarée à tort est pire qu'une réelle : qui lit « je ne
vois qu'un atelier sur huit » escompte un taux qui n'a pas besoin de l'être. Les taux restent des
minorants, mais pour l'autre moitié de la phrase - les autres flux qui lancent la suite.
Dix des vingt et un peuvent être suivis, deux ne pourront jamais l'être. Le vingt et unième est releve-des-bancs-instables.yml, neuf depuis #5617, qui n'aura pas vingt-quatre exécutions avant vingt-quatre nuits. titre-pr.yml et corps-pr.yml
ne se déclenchent que sur pull_request : ils n'ont aucune exécution sur main et n'en auront jamais.
Les huit autres sont sous le seuil parce qu'ils tournent rarement - un rapport du lundi, une release,
un tournage appelé - et le temps seul ne les y amènera pas tous.
Le critère, en une phrase : un atelier mérite un suivi quand il tourne sur main assez souvent
pour porter deux médianes de douze, et que son allongement ne serait signalé par rien d'autre. Un
atelier rare n'y échappe pas par indulgence : l'instrument ne saurait pas conclure sur lui, et un
avertissement qu'on ne peut pas fonder s'apprend à ignorer.
Ce que la mesure a trouvé en chemin, et qui n'est pas de la documentation. Trois des dix
mesurables dépassent le seuil de 20 % en ce moment, et rien ne le dit puisque rien ne les joue :
release.yml à +597 % (0,9 puis 6,4 min), recette-filmee.yml à +196 % (6,2 puis 18,3 min - ces
exécutions-là sont des tournages MANUELS et non le train, voir #5627), mutation-ihm.yml à +24 % (51,9 puis 64,5 min). Consigné en
#5615 plutôt que traité ici, chaque dérive demandant une décision distincte.
Et les deux derniers rendent un message faux. Sur titre-pr.yml et corps-pr.yml, l'instrument
écrit « Historique des exécutions illisible après trois tentatives » là où il n'y a rien à lire. Il
confond une absence avec un échec de lecture, ce qui est le défaut que ce dépôt refuse ailleurs.
Consigné avec les trois dérives, en #5615.
Le portail qualité (-Pquality-gate)¶
Le profil Maven quality-gate rend bloquants des contrôles tolérants par défaut :
- PMD :
failOnViolation=true, quelint.ymln'emploie pas - il lancetest-compile pmd:pmdet laisse le verdict au cliquet de l'ADR 4617, qui tolère une marge là oùfailOnViolationrefuse tout ; - JaCoCo : le seuil de couverture devient bloquant, exécuté par
maven.yml(verify -Djacoco.haltOnFailure=true). Les valeurs vivent dans lepom.xml, seule source.
Ces deux contrôles sont répartis sur deux workflows : lint.yml porte le statique (Spotless +
captures + PMD), maven.yml porte les tests + couverture. Localement :
./mvnw -B test-compile pmd:pmdpuispython3 scripts/adr/4617-code-mort-et-zone-de-test.pyreproduit le portail delint.yml;./mvnw -B verify -Djacoco.haltOnFailure=truereproduitmaven.yml, qui n'emploie pas le profil : la couverture y est bloquante, PMD non.
Spotless (Palantir Java Format) formate via un hook pre-commit et est vérifié par lint.yml (spotless:check).
« Sans objet » dans un récapitulatif, et pourquoi c'est sûr (#5376)¶
Un job vert dont le journal dit « sans objet » n'a rien vérifié, et c'est voulu. La règle est
qu'un job de demande tourne toujours et conclut ; sa première étape décide, et les suivantes en
dépendent. Sa portée est déclarée dans un seul endroit, .github/scripts/porte_du_job.py, où tout
job de demande figure : avec ses chemins, ou parmi les inconditionnels avec sa raison écrite.
Le filtre paths: de la forge est interdit ici, et ce n'est pas une préférence. Un job qui ne se
déclenche pas ne rend aucun verdict, or l'ADR 4571 refuse une fusion sans verdict complet et exclut
skipped de ses conclusions probantes ; et un job absent du récapitulatif ne se distingue pas
d'un job vert, ce qui est le motif de l'ADR 2748. Le silence explicite est donc préféré à l'absence,
et la différence est tout l'objet du patron.
Ce que ce silence coûte : 5 à 9 secondes par job, le temps d'un checkout et du git fetch de la
base. La forge facturant à la minute entamée, un job muet coûte une minute facturée. C'est le prix du
récapitulatif complet.
Une demande qui touche l'outillage rallume tout, et il ne faut pas s'en étonner : chaque portée nomme son propre atelier et le mécanisme qui la lit, sans quoi une modification du dispositif ne serait jamais éprouvée par lui.
Un nom de job ne désigne qu'un seul job, et cette contrainte porte le dispositif entier. Jusqu'à
5439, docs.yml et maven.yml avaient tous deux un job build : une seule déclaration les¶
satisfaisait, avec une raison écrite qui appartenait à l'un et était fausse pour l'autre. Le garde d'exhaustivité était vert sur un job qu'il n'avait jamais examiné.
La décision, ses deux verrous et la condition qui la périmerait - l'arrivée d'une protection de
branche sur main - sont dans l'ADR 5376.
Pourquoi build, emballage et bats sont trois jobs¶
maven.yml portait auparavant quatre préoccupations à la file dans un seul job. Deux coûts en
découlaient. Le premier, mesuré : 449 s de tests, puis 148 s d'E2E bats, puis 9 s d'idempotence en
série, soit ~10 min avant le moindre verdict. Le second, plus gênant, était une dépendance
fausse : les étapes de packaging ne s'exécutaient qu'après le succès des tests, donc une suite rouge
masquait l'état du packaging, qu'on n'apprenait qu'au tour suivant.
Or ces étapes ne dépendent pas de la suite de tests, mais de ce qu'on emballe : un
package -DskipTests (~20 s en local) puis l'app-image que le job construit déjà. D'où la séparation :
| Job | Ce dont il dépend | Ce qu'il prouve |
|---|---|---|
build |
la suite de tests | le comportement, et la couverture au seuil |
emballage |
l'assemblage, puis l'app-image | que le jar démarre, que le shade est idempotent, que l'archive portable survit à son emballage |
bats |
l'app-image que emballage verse |
que la CLI répond depuis le lanceur livré, sur 131 cas de shell |
Les 138 tests bats visaient le fat-jar par java -cp jusqu'à #4071,
c'est-à-dire un chemin qu'aucun utilisateur n'emprunte. Ils visent désormais bin/vigiechiro de
l'app-image construite au-dessus, donc le runtime jlink réellement livré. Ils viennent pour cette
raison après le garde-fou app-image, et non plus juste après le package - sans que rien ne change
de job ni de coût, l'app-image étant déjà bâtie là. Le fat-jar, lui, reste éprouvé par le smoke-test
qui le lance et lit son usage.
Les deux tournent en parallèle et rendent leur verdict indépendamment : le chemin critique se ramène au plus long des deux, et un packaging cassé rougit même quand les tests échouent.
Ce qui ne gagne rien à être optimisé
L'installation d'apt/bats coûte 9 s, pas davantage : c'est vérifié. Le reste du harnais,
ce sont les tests eux-mêmes, qui lancent chacun un processus complet. Chercher un cache apt
ici ne rapporte rien - l'hypothèse a été faite, mesurée, et démentie.
La release (semantic-release + jpackage)¶
À chaque train de publication - le mercredi à 6 h UTC, ou sur déclenchement manuel (#2744) -
semantic-release analyse les
Conventional Commits pour calculer la version, créer le
tag vX.Y.Z et la Release GitHub (en brouillon), et mettre à jour CHANGELOG.md (format
Keep a Changelog). Puis une matrice construit les installeurs
natifs et les attache à la Release.
sequenceDiagram
participant Dev
participant Main as Branche main
participant Rel as release.yml
participant SR as semantic-release
participant GH as Release GitHub
Dev->>Main: push (Conventional Commits)
Main->>Rel: déclenche (si ENABLE_RELEASE)
Rel->>SR: analyse les commits
SR->>Main: commit CHANGELOG.md [skip ci]
SR->>Main: tag vX.Y.Z
SR->>Main: note git refs/notes/semantic-release-vX.Y.Z
SR->>GH: Release (brouillon)
Rel->>Rel: job installers (matrice, profil -Pinstaller)
Rel->>GH: attache installeurs + archives portables à la Release
Rel->>GH: job publish (retire le brouillon)
L'ordre de ces écritures compte, et ce schéma le disait faux jusqu'à #5987
Il montrait le tag et la Release avant le commit du CHANGELOG.md. Mesuré sur le journal de
l'exécution 37453428765, le 2026-10-06 : prepare de @semantic-release/changelog à 11:00:41,
puis prepare de @semantic-release/git qui pousse le commit à 11:00:43, puis le cœur qui
pousse le tag et la note git, et seulement ensuite l'étape publish de
@semantic-release/github qui crée la Release.
Ce n'est pas un détail de dessin : l'ordre détermine ce qu'une publication interrompue laisse derrière elle. Avec l'ancien ordre, une version à moitié faite avait toujours au moins un brouillon ; avec le vrai, elle peut n'avoir qu'un commit, ou un commit et un tag. C'est ce que dit la section « Une publication peut s'arrêter en chemin » plus bas.
Chaque runner produit deux artefacts, à partir du même profil installer :
| Runner | Installeur | Archive portable | Architecture |
|---|---|---|---|
ubuntu-latest |
.deb |
…-linux-x64-portable.tar.gz |
x64 |
macos-latest |
.dmg |
…-macos-arm64-portable.zip |
arm64 (Apple Silicon) |
windows-latest |
.msi |
…-windows-x64-portable.zip |
x64 |
L'archive portable (#2107)¶
L'installeur demande des droits d'administration. C'est un obstacle pour qui veut simplement essayer le produit, ou l'utiliser sur une machine qu'il n'administre pas - un poste de laboratoire, un ordinateur prêté. L'archive portable est la marche du bas : on décompresse, on lance, rien ne s'installe.
Elle vient du même profil installer, avec -Djpackage.type=app-image : jpackage produit alors
le dossier autonome (lanceur natif + runtime + fat-jar) au lieu de l'emballer dans un installeur.
Aucune configuration Maven supplémentaire n'a été nécessaire.
Le format d'archive est choisi pour ce qu'il préserve, et ce n'est pas interchangeable :
tar.gz(Linux) garde le bit exécutable du lanceur ;ditto(macOS) est le seul outil qui préserve un bundle.appintact - unzip -rcasse ses liens symboliques et ses permissions, et l'application ne s'ouvre plus ;zip(Windows), où la notion de bit exécutable n'existe pas.
Le dossier est retiré après empaquetage
gh release upload échoue sur un répertoire. L'étape supprime donc VigieChiro/ (ou
VigieChiro.app) une fois l'archive faite, sans quoi le téléversement casse toute la publication.
L'AppImage (#2107)¶
Sous Linux uniquement, la même app-image donne aussi une AppImage : un fichier unique et
exécutable, qu'on rend exécutable et qu'on lance, sans rien décompresser. C'est le complément de
l'archive portable pour qui préfère un fichier à un dossier, et le seul des deux formats à
s'intégrer au menu des applications, grâce à son .desktop.
Elle est construite par
.github/scripts/construit_appimage.py,
à partir de trois éléments versionnés dans .github/appimage/ (le point d'entrée AppRun, le
.desktop, et l'icône reprise de celle que jpackage dépose dans lib/). Le script est lançable à
la main, ce qui permet de le vérifier sans passer par une release :
./mvnw -Pinstaller -Djpackage.type=app-image -DskipTests verify
python3 .github/scripts/construit_appimage.py 2.20.0 x86_64 # -> target/dist/*.AppImage
L'étape est placée avant l'empaquetage de l'archive portable, qui supprime
target/dist/VigieChiro : les deux formats partent de la même app-image.
Deux pièges rencontrés à la construction, tous deux silencieux à la lecture
Ne pas définir SOURCE_DATE_EPOCH. L'idée d'un artefact reproductible est tentante, mais
appimagetool passe déjà ses propres options de date à mksquashfs, qui refuse alors les deux
ensemble : SOURCE_DATE_EPOCH and command line options can't be used at the same time to set
timestamp(s). Le script le neutralise s'il vient de l'environnement.
Une seule catégorie principale dans le .desktop. Categories=Science;Biology;Education;
en déclare deux (Science et Education), et l'application apparaît deux fois dans le menu.
Seul Science est principal ici, Biology en étant une sous-catégorie.
--appimage-extract-and-run est passé à appimagetool parce que celui-ci est lui-même une AppImage :
il lui faut FUSE pour se monter, ce dont les conteneurs CI ne disposent pas toujours, avec un échec
obscur à la clé.
La dépendance invisible : desktop-file-validate
appimagetool valide le .desktop avec cet outil et s'arrête s'il ne le trouve pas. Il est
fourni par le paquet desktop-file-utils, présent sur la plupart des postes de développement
(les environnements de bureau le tirent) et absent des runners GitHub.
C'est exactement le genre d'écart qu'une vérification locale ne peut pas voir : la construction passait ici et a fait échouer la release v2.21.0, laissant la Release en brouillon. Le workflow l'installe donc explicitement, et le script contrôle sa présence pour que l'échec nomme le paquet au lieu de renvoyer le message d'appimagetool, qui ne le dit pas.
Leçon plus générale pour ce dépôt : un outil de build appelé indirectement par un autre outil est une dépendance qu'il faut déclarer, parce que rien ne la rend visible tant que le poste qui construit la possède.
Les emballages sont ouverts avant d'être publiés (#3617)¶
maven.yml prouve à chaque PR que l'app-image démarre (#2299, né de la v2.32.3). Personne
n'ouvrait les enveloppes. Or chacune a été choisie pour ce qu'elle préserve, ce qui est une
autre façon de dire que chacune a une façon connue de casser : tar.gz pour le bit exécutable,
ditto pour les liens d'un bundle .app, appimagetool qui a déjà fait échouer la v2.21.0.
Chaque emballage est donc ré-ouvert et lancé là où il est produit, par
verifie_demarrage_emballage.py :
| Où | Ce qui est ouvert |
|---|---|
maven.yml, chaque PR |
l'app-image, puis un aller-retour tar.gz : on empaquette, on ré-extrait ailleurs, on relance |
release.yml, runner Linux |
l'archive portable .tar.gz et l'AppImage |
release.yml, runner macOS |
l'archive portable, extraite par ditto (bundle .app) |
L'étape est placée avant le calcul des empreintes, à dessein : un artefact qui ne démarre pas ne doit être ni certifié, ni attesté, ni téléversé. Un emballage cassé bloque la publication au lieu d'arriver chez l'utilisateur.
Ce qui n'est pas couvert, et pourquoi c'est dit
L'archive portable Windows. Le script est en bash, et le suivi d'un lanceur en sous-système
graphique depuis Git Bash n'a pas pu être éprouvé : un faux échec y bloquerait une publication.
Le .msi, lui, est installé et lancé par winget.yml.
Les installeurs .deb et .dmg, qui demandent une installation et non une simple extraction.
Pourquoi un script plutôt que des lignes dans le workflow
Parce que release.yml n'est traversé par aucune PR. Une étape écrite là peut être fusionnée
cassée et ne se découvrir qu'au train suivant, en bloquant la publication - c'est le piège
général des étapes que seul un déclencheur rare exerce. Sortie en script, la logique porte son
--auto-test (joué à chaque PR par lint.yml) et maven.yml l'exerce en vrai.
Les empreintes SHA-256 (#2107)¶
Les installeurs ne sont pas signés. Sans empreinte, un utilisateur n'a donc aucun moyen de
vérifier ce qu'il télécharge. Chaque artefact est accompagné d'un fichier <nom>.sha256, produit par
le job installers juste avant le téléversement.
Une empreinte atteste la source, pas ce que le canal en a fait
La première version calculait les empreintes sur les artefacts re-téléchargés depuis la Release, au motif que « le transfert se trouve ainsi couvert ». C'était faux, et dangereux : une corruption survenue au téléversement se serait retrouvée certifiée conforme, et l'utilisateur aurait vérifié un binaire abîmé avec succès.
L'empreinte doit porter sur la sortie de build. Alors une corruption du canal fait échouer la vérification - ce qui est précisément le service attendu.
Un fichier par artefact, et non une liste unique. L'utilisateur télécharge un fichier : lui
demander de récupérer en plus une liste de sept empreintes dont six ne le concernent pas, puis d'y
filtrer sa ligne, est une friction que sha256sum -c mon-fichier.sha256 supprime. Ce choix a de plus
retiré de la chaîne un aller-retour de plusieurs centaines de mégaoctets, et le piège de
l'auto-exclusion qu'imposait une liste (le fichier ne devait pas figurer dans sa propre liste).
macOS n'a pas sha256sum
L'étape bascule sur shasum -a 256, qui produit exactement le même format : un .sha256
généré sous macOS se vérifie sous Linux, et réciproquement. C'est vérifié dans les deux sens.
Ce qu'une empreinte prouve, et ce qu'elle ne prouve pas. Elle atteste que le fichier est identique à celui publié : elle détecte un téléchargement corrompu ou tronqué. Elle ne remplace pas une signature - publiée au même endroit que les fichiers, elle n'atteste d'aucune identité. La signature de code n'est pas financée : l'ADR 2112 tranche que le produit s'arrête à la notification de mise à jour.
Chaque installeur embarque son runtime (jpackage, profil -Pinstaller) : l'utilisateur final
n'installe pas Java. Construire un installeur localement :
Le shade attache le fat-jar sous le classifier shaded (vigiechiro-*-shaded.jar, #1188) : l'artefact
principal vigiechiro-*.jar reste mince. jpackage empaquette donc le -shaded, et le packaging est
idempotent (le shade ne re-traite jamais sa propre sortie ; garde-fou d'idempotence dans maven.yml).
Le type de commit pilote la version
fix: → patch, feat: → minor, BREAKING CHANGE → major. Le [skip ci] du commit de CHANGELOG
évite que la release se redéclenche en boucle. Détails de conventions :
CONTRIBUTING.md.
Ce que semantic-release lit réellement : le titre de la PR
Les PR sont fusionnées en squash (squash_merge_commit_title = PR_TITLE,
squash_merge_commit_message = PR_BODY) : le titre de la PR devient le sujet du commit sur
main, son corps en devient le corps, et les messages des commits de branche sont écartés à
la fusion. C'est donc le titre qui pilote la version, et c'est lui que valide
titre-pr.yml.
Le second réglage a été posé le 2026-09-05, et la phrase ci-dessus était fausse jusque-là : le
dépôt était sur COMMIT_MESSAGES, donc les messages de branche n'étaient pas écartés, ils
étaient le corps. Sur les dix-huit fusions précédentes, quatorze n'y perdaient rien, portant
un seul commit dont le message était le texte ; les quatre demandes multi-commits perdaient le
corps que verifie_corps_pr.py avait validé. La bascule aligne la forge sur ce que cette page
décrivait déjà, et fait de ce garde celui de l'historique, ce que l'ADR 4453 suppose (#5248).
gh pr merge --squash se lance nu. Un --body-file écarte encore le corps validé et lui
substitue un texte que rien n'a lu, sous ce réglage comme sous l'autre. Un --subject fait de
même au sujet : il remplace celui que la forge compose, (#N) compris, et la ligne du CHANGELOG
perd son lien vers la demande sans que rien ne rougisse (#4775).
Pas d'espace avant le : : feat(scope): … publie, feat(scope) : … ne publie rien. Cette
seconde forme a arrêté la publication du 18 au 20 juillet 2026, en accumulant 58 commits
releasables sans faire rougir quoi que ce soit - « aucun changement pertinent » est un verdict
vert. .releaserc.json élargit désormais le headerPattern pour tolérer l'espace (sur le
commit-analyzer et le release-notes-generator, faute de quoi les notes sortiraient vides),
mais le garde-fou reste le contrôle du titre. Cf.
ADR 0040.
Pourquoi titre informe au lieu de bloquer¶
Le contrôle a été rendu obligatoire (ruleset titre-de-pr-conforme), le temps d'une heure, et
cette heure a suffi à casser deux automatismes. Le retour en arrière est délibéré, et vaut d'être
expliqué : c'est exactement le genre de décision qu'on retente sans en connaître les raisons.
Un check requis ne gouverne pas « les PR », il gouverne la branche - donc tout ce qui y écrit. Ce dépôt y écrit par deux chemins automatisés, et les deux se sont cassés :
| Chemin | Ce qui s'est passé |
|---|---|
PR d'aperçus (capture-vues.yml) |
BLOCKED, aucun check rapporté : le commit de la PR porte la marque de saut de CI, donc titre-pr.yml ne s'exécute jamais - et un check requis muet bloque la fusion pour toujours. On l'a longtemps attribué au GITHUB_TOKEN, qui ne déclencherait aucun workflow : c'est faux pour une PR, qui lance ses workflows quand son commit ne porte pas la marque (#5004) |
Push du CHANGELOG (semantic-release) |
GH013 … Required status check "titre" is expected : un push direct est soumis aux mêmes règles, et un commit poussé n'a évidemment aucun check |
Le second a arrêté la publication, c'est-à-dire précisément ce que le chantier #2104 venait de réparer. Trois releases ont échoué d'affilée avant que la règle ne soit retirée.
La dérogation qu'on attendrait est fermée : ajouter github-actions aux contournements d'un ruleset
de dépôt échoue en 422 (Actor GitHub Actions integration must be part of the ruleset source
or owner organization). Seul un ruleset d'organisation l'accepterait.
La décision : titre reste informatif. Il rougit sur un mauvais titre - c'est ainsi qu'il a
attrapé la PR #2122 le jour même - et cette information suffit. Le bénéfice du blocage était faible
(un seul mainteneur, qui dispose de toute façon du contournement administrateur) ; son coût a été
mesuré. Cf. ADR 0041.
Le check publié par le bot des captures est resté
capture-vues.yml exécute lui-même la validation, avec le même script, et publie le résultat comme check run. Ce mécanisme est né du besoin de débloquer, mais il se justifie encore sans lui : sans ce passage, une PR d'aperçus ne serait validée par rien du tout. Il ne publie jamais un succès en dur - un garde-fou qui ne sait que réussir ne garde rien.
Ce qu'il faut retenir pour la suite. Avant de rendre un check obligatoire, inventorier tous les
chemins d'écriture vers main, pas seulement les PR humaines - et se demander pour chacun comment
le check y rapportera.
Flatpak (#2111)¶
Le manifeste vit dans flatpak/,
qui porte aussi le mode d'emploi de construction locale. Trois points valent d'être connus d'ici :
Il extrait le .deb publié, il ne construit pas depuis les sources. Les builds Flatpak n'ont
aucun réseau, donc une résolution Maven y est impossible sans vendorer chaque dépendance
transitive. Même choix que Gluon Scene Builder, pour la même raison - et plus simple chez nous, le
fat-jar embarquant déjà JavaFX.
Il consomme donc directement le travail de #2107 : le .deb et son empreinte SHA-256 publiée
sont exactement ce que la source du manifeste demande.
La montée de version est automatique : le bloc x-checker-data du manifeste est lu par
flatpak-external-data-checker directement dans flatpak.yml, qui propose la mise à jour une fois le
paquet reconstruit et démarré avec succès.
Et le workflow part avec le train (#4103) : release.yml l'appelle en workflow_call après avoir
retiré le brouillon de la Release. Publier une version ne demande donc aucun geste côté paquet, ce qui
n'était vrai qu'en théorie tant que le déclenchement restait manuel.
C'est un workflow_call, et pas un release: released. Ce dernier ne partirait jamais : la
Release est dé-brouillonnée avec le GITHUB_TOKEN, et GitHub ne déclenche aucun workflow sur un
événement produit par ce jeton. Un workflow appelé n'est pas un événement - il s'exécute dans le run de
l'appelant - donc l'obstacle ne s'y applique pas. C'est le premier workflow_call du dépôt.
Le job dépend de publish, pas d'installers : le checker interroge releases/latest, qui
ignore les brouillons. Téléverser les assets ne suffit pas ; il faut que la Release en soit sortie.
Ce que le retard coûtait, avant : le paquet publié pouvait rester plusieurs versions en arrière
sans que rien ne le dise. Mesuré le 2026-08-21 - manifeste sur le .deb 2.185.0 quand la 2.187.0 était
publiée, donc la ligne de commande de #4071 absente du paquet pendant que la documentation
l'annonçait.
Le .desktop de jpackage est invalide
jpackage écrit Categories=Unknown, valeur que desktop-file-validate refuse. Le manifeste la
corrige au build - et c'est la première édition qu'il fait, car desktop-file-edit valide le
fichier à chaque appel et échouerait avant d'y arriver. Le .deb installé normalement, lui, garde
cette catégorie fautive.
Dépôt Flatpak auto-hébergé (#2111)¶
flatpak.yml publie le paquet - construit et démarré par la même vérification - dans un dépôt Flatpak
que ce projet héberge lui-même. C'est le seul canal Flatpak du projet.
En production depuis le 2026-08-15 : https://flatpak.echonuit.fr/fr.echonuit.VigieChiroCompanion.flatpakrepo
sert un dépôt signé (clé 1BA6A82DA9213B177B160E56CD450A9383707B17), reconstruit à chaque train de
publication, et à la demande par le workflow_dispatch de flatpak.yml. Installation côté utilisateur documentée dans la
documentation utilisateur.
Mécanisme : la construction de flatpak-builder exporte vers --repo, en plus du --install local
qui sert au démarrage réel. C'est ce dépôt-là, déjà éprouvé par le pas qui le précède, qu'un
.flatpakrepo généré à la volée puis
peaceiris/actions-gh-pages publient vers
echonuit/flatpak (branche gh-pages, domaine flatpak.echonuit.fr) - le même patron que
companion/companion-dev/brief dans docs.yml.
flatpak.yml ne tourne que sur workflow_dispatch : fusionner un correctif ne republie rien, il faut un
run manuel pour le prouver.
Les deux pas de publication (repo ostree + .flatpakrepo) restent gardés par la variable
ENABLE_FLATPAK_REPO et le secret FLATPAK_DEPLOY_TOKEN (PAT contents: write sur echonuit/flatpak
seul) : absents, ils s'effacent en ::notice:: plutôt que de rougir - mais les deux sont posés et actifs
en production.
Signature GPG : la clé (ed25519, générée hors CI le 2026-08-15) est câblée en deux parties -
FLATPAK_GPG_KEY_ID et la clé publique FLATPAK_GPG_PUBLIC_KEY_B64 vivent en clair dans flatpak.yml
(non sensibles : c'est la partie publique). La partie privée n'existe nulle part dans ce dépôt ni
dans une conversation - uniquement dans le secret FLATPAK_GPG_KEY (armored, encodé en base64, sortie
de gpg --export-secret-key --armor <ID> | base64 -w0), posé et actif. Tant que ce secret est absent,
garde-flatpak-signature retombe en silence sur le comportement non signé (NoGpgVerify=true,
flatpak-builder sans --gpg-sign) - même patron d'inertie que la publication elle-même. Une fois posé,
la construction signe l'export (--gpg-sign/--gpg-homedir) et le .flatpakrepo généré embarque
GPGKey= au lieu de NoGpgVerify=true.
Deux pannes ont retardé le premier run réel, toutes deux corrigées avant la mise en production :
base64 -dplantait sousset -euo pipefailà cause d'un artefact non-base64 en fin de secretFLATPAK_GPG_KEY, alors que l'import GPG lui-même avait déjà réussi sur les données reçues. Corrigé avecbase64 -d -i(--ignore-garbage).flatpak-builder --gpg-signouvrait un pinentry interactif pour déverrouiller la clé importée, et plantait avecPinentry: Inappropriate ioctl for devicefaute de terminal de contrôle sur le runner. La clé de signature CI doit rester sans passphrase : sa protection vient du contrôle d'accès au secret GitHub, pas d'un second secret interactif qu'aucune étape ne pourrait fournir.gpg-agent.conf/gpg.confconfigurent quand mêmepinentry-mode loopbackavant l'import, en garde-fou si une future clé en portait une par erreur.
winget (#2213)¶
Le paquet Echonuit.VigieChiroCompanion est servi par winget depuis le 2026-08-10
(winget-pkgs#405848, version 2.34.2). Son
identité est figée à vie : Scope: user (installation dans %LOCALAPPDATA%, sans UAC) et un
UpgradeCode constant, tous deux décidés avant la première soumission (ADR 0045,
ADR 0047).
Publier une version sur winget¶
Ce n'est pas automatique, et c'est délibéré (mêmes raisons que Flatpak : un dépôt communautaire à modération humaine face à un dépôt qui publiait 3 à 37 fois par jour). On pousse les versions qui apportent quelque chose à l'utilisateur :
Le workflow fait trois choses avant de soumettre, et la deuxième est celle qui compte :
- Il recalcule l'empreinte du MSI et la compare au
.sha256publié. Soumettre un manifeste qui décrit un fichier différent de celui servi ferait échouer la validation de winget après coup, et chez eux. - Il installe le MSI puis le LANCE, 45 secondes, sur un runner Windows, et relit le journal que l'application écrit. C'est le contrôle qui manquait à la v2.32.3 : paquet installable, validé par le pipeline de Microsoft, incapable de démarrer. Leur validation ne lance pas l'application.
- Il soumet via
winget-releaser, qui ouvre la PR depuis le forkechonuit/winget-pkgs.
max-versions-to-keep: 5 : le dépôt communautaire n'a pas vocation à archiver notre historique.
Le fork doit être à jour avant un dispatch
Le fork echonuit/winget-pkgs prend du retard entre deux soumissions, espacées de plusieurs
semaines par construction. Au 2026-08-11 il était 7969 commits en retard, donc antérieur à la
fusion de notre propre manifeste.
Ce n'est pas une précaution de principe : les versions récentes de winget-releaser lancent
komac sync-fork avant la mise à jour, ce que la nôtre (épinglée sur v2, un commit de
novembre 2024) ne fait pas.
La garde du secret rougit, et c'est un changement
winget.yml sortait en vert quand WINGET_TOKEN manquait. Ce choix était juste tant qu'il se
déclenchait sur release: released : rougir à chaque publication aurait été du bruit sur un canal
qu'on savait inerte.
Il a cessé de l'être quand le workflow est passé en workflow_dispatch seul. Un dispatch est
un geste délibéré : on le lance parce qu'on veut soumettre. Un vert qui n'a rien soumis annonce
une publication qui n'a pas eu lieu, et c'est le seul type de défaut qui se présente sous la
forme d'un succès.
Mesuré en ouvrant #2213 : le secret était absent, et le workflow n'avait jamais été exécuté depuis sa fusion. Le premier dispatch aurait rendu un vert sans rien publier.
« does not exist in microsoft/winget-pkgs » accuse le mauvais coupable
Message rendu par komac au premier dispatch réel, alors que le paquet y était depuis la veille. Il ne veut pas dire ce qu'il dit.
Komac résout le paquet avec le jeton qu'on lui donne. Un jeton qu'il ne peut pas employer rend une réponse vide, et une réponse vide se lit chez lui comme « le paquet n'existe pas ». Le coupable désigné est donc le paquet, quand la cause est le jeton.
Ce qu'il faut savoir avant d'y passer du temps, parce que chaque essai coûte un runner Windows et une installation de MSI :
- le paquet, le chemin, le fork et komac se vérifient en local, en quelques secondes :
GITHUB_TOKEN=$(gh auth token) komac list-versions Echonuit.VigieChiroCompanion; - un jeton valide mais copié avec un retour à la ligne produit exactement ce symptôme : il
s'authentifie quand on le teste à la main, et l'en-tête
Authorizationqu'il forme dans la CI est invalide. D'oùprintf '%s'et nonechopour le poser.
verifie_secret_winget.py --verifie-l-acces rend maintenant ce diagnostic au début du
workflow, en nommant la cause.
La cause réelle, et elle n'était dans aucune de ces listes : 8 jours de durée de vie
Ce qu'a fini par dire l'API, une fois la sonde réparée pour ne plus avaler sa réponse :
The 'Microsoft Open Source' enterprise forbids access via a personal access tokens (classic)
if the token's lifetime is greater than 8 days. (HTTP 403)
403, pas 404. Le jeton était du bon type, au bon scope, valide et sans espace parasite. Seule
sa durée de vie le disqualifiait, par une politique de l'entreprise qui héberge winget-pkgs.
Trois enseignements, et le troisième est le plus cher payé :
WINGET_TOKENse refait avant chaque soumission, avec une expiration de 8 jours au plus. C'est documenté dans Reprendre le dépôt.- Un message d'erreur d'outil tiers n'est pas un diagnostic. komac disait « le paquet n'existe pas » là où l'API disait « votre jeton vit trop longtemps ».
- Une sonde qui avale la réponse de l'API fabrique de faux diagnostics. La première version
du contrôle d'accès faisait
2>/dev/null: elle a conclu, avec aplomb, que le jeton n'avait pas les droits de lecture. C'était faux, et rien dans son verdict ne permettait de le voir. Un dispositif qui conclut sans montrer sa preuve appartient à la même famille que ceux que la section « Toute garde de CI porte sa propre preuve » combat - il se trouve seulement qu'ici, c'est la garde elle-même qui en était atteinte.
Ce que les ateliers exigent, relevé plutôt que recopié (#4340)¶
Un secret absent ne fait pas rougir un atelier : il fait sauter l'étape qui en dépend, en gris.
Une variable absente est pire, parce qu'elle éteint un job entier : ENABLE_RELEASE, ENABLE_PAGES
et ENABLE_FLATPAK_REPO sont des interrupteurs. Dans les deux cas, une pastille verte couvre ce qui
n'a pas eu lieu, et la section winget ci-dessus en raconte un cas mesuré.
python3 scripts/methode/releve-des-secrets.py # le relevé, lu dans .github/workflows/
python3 scripts/methode/releve-des-secrets.py --compare # et l'écart avec ce que le dépôt porte
--compare rend 1 quand un nom exigé n'est pas posé. Il signale aussi ce qui est posé sans être
demandé, ce qui n'est pas une faute mais mérite une question.
Une liste de secrets recopiée dans une page vieillit au premier atelier ajouté, et son vieillissement est silencieux. Celle-ci est lue dans les ateliers à chaque exécution.
Ce n'est pas un garde, et il n'a donc pas d'--auto-test. Il relève et confronte ; il ne refuse
rien, et verifie_inventaires_ci.py ne l'attend pas au tableau des gardes. En faire un garde
bloquant est une question ouverte : au 2026-08-24 le relevé et le dépôt concordent, 6 secrets et
3 variables, donc rien à refuser aujourd'hui.
Toute garde de CI porte sa propre preuve (#2947, #3293)¶
Une garde qui accepte à tort ne rougit pas : elle passe au vert, sur un dépôt propre, exactement
comme si elle faisait son travail. C'est le seul type de défaut qui se présente sous la forme d'un
succès - et c'est pourquoi chaque garde de ce dépôt répond à --auto-test.
| Garde | Ce qu'elle vérifie | Où elle tourne |
|---|---|---|
verifie_titre_pr.py |
Conventional Commits, cadratin, élision sans apostrophe | titre-pr.yml |
verifie_corps_pr.py |
cadratin, apostrophe courbe, élision sans apostrophe et fermeture écrite en français (« Ferme #N ») dans le corps d'une PR. Depuis #4749, les cinq traces d'outil qui se comptent aussi : un corps de PR n'est pas un fichier suivi, donc scripts/adr/4783-traces-d-outil.py ne le lisait pas. Les deux gardes lisent la même définition, scripts/_commun/traces.py. Un bloc clôturé reste épargné, et l'espace sans chasse que Dependabot place derrière une arobase aussi. Depuis #6283, un <details> refermé l'est également : Dependabot y recopie des notes de version en anglais, où eslint-plugin-n from passait pour une élision |
corps-pr.yml (autotest : lint.yml) |
4472-commentaire-en-corps.py |
les blocs de // qui débordent dans un corps de méthode, population que le cliquet 4359 ne voit pas. Deux cliquets depuis #5582, la production (ADR 4472) et la zone de test (ADR 5582), le code de sortie étant le pire des deux : sous un seuil unique, un gain de cinq blocs d'un côté payait une régression de cinq de l'autre (ADR 4682) |
lint.yml (cliquets ADR, autotest dédié) |
4650-double-clic-vise-la-donnee.py |
les double-clics de test qui visent une POSITION sans déclarer pourquoi. Il ne refuse pas l'appel positionnel - trois parcours E2E et un cas filmé en ont besoin, pour deux raisons différentes - il refuse l'appel muet, la frontière étant un jugement qui se déclare (ADR 5398). Il lit l'arbre et non un motif : la javadoc du helper cite doubleClickOn pour dire ce qu'il imite, et un grep la compte comme un appel. Cliquet à zéro |
lint.yml (cliquets ADR, autotest dédié) |
loupe-4472-densite-de-commentaire.py |
la densité de commentaire par classe et par méthode ; signale, ne bloque pas. Depuis #5430 les corps de méthode sont bornés par la structure : une accolade vivant dans une chaîne faisait avaler les méthodes suivantes, et la suite d'une signature écrite sur deux lignes comptait dans le dénominateur de 166 méthodes | adr-rapport.yml |
loupe-4992-lots-sans-critere.py |
les lots ouverts qui ne disent pas dans leur corps comment on saura qu'ils sont finis, sur les chantiers ouverts depuis le 2026-08-29. Elle signale, ne bloque pas, et ne juge pas la qualité d'un critère. Elle prend l'union des deux définitions d'un EPIC, le label et le titre, rater un chantier revenant à ne pas poser la question. Elle lit la forge, et sans gh authentifié elle sort en 2 plutôt que de rendre un rapport vide (ADR 4992). Elle refuse aussi à son plafond de collecte, et c'est ce qui l'a rendue muette plusieurs semaines : le plafond valait 1 600 quand le dépôt a franchi ce nombre, et sa ligne de verdict dit lus=<nombre de lots>, où la marge était invisible. Depuis #5558 le plafond est partagé avec loupe-5539, qui lit la même population, et la collecte se publie : collecte : 1870 issue(s) lue(s), plafond 4000 |
adr-rapport.yml, et à la main (autotest : lint.yml) |
loupe-5539-echeances-non-tenues.py |
les engagements datés qu'une issue n'a pas tenus, dans les deux sens : ouverte et échue, ou fermée avant sa propre date. Le signal n'est pas la date mais le verbe qui engage : 45 issues ouvertes portent une date et 56 de ces dates sont passées, presque toutes des dates de mesure. Une ligne qui nomme une AUTRE issue est écartée, car elle parle de l'échéance d'un autre. Au plafond de collecte elle avertit et conclut, là où loupe-4992 refuse : ses candidats se lisent un par un, et une collecte tronquée en tait sans en inventer. Le rapport ne relaie pas cet avertissement, et lus égal au plafond en est le seul signe (#5567). Rend 0 en signalant |
adr-rapport.yml (rapport du lundi) |
loupe-4712-lots-multi-pr.py |
les lots de chaque EPIC ouvert, mis sous les yeux pour que la question du sous-chantier se pose ; elle ne tranche pas, le nombre de PR qu'un lot portera étant un jugement. Elle lit la forge, et sans gh authentifié elle sort en 2 plutôt que de rendre un rapport vide (ADR 4712). Muette au rapport hebdomadaire jusqu'à #4992, faute d'un jeton sur l'étape qui le publie. Depuis #4967 un lot est aussi une sous-issue native et un EPIC se reconnaît au label ou au titre : elle est passée de 79 candidats à 143, et montre les lots de 26 EPIC au lieu de 18, dont le sas #4562 et ses 43 sous-issues. Sa liste finale dit « aucun lot ouvert », parce qu'elle ne collecte que les issues ouvertes |
adr-rapport.yml, à la main au découpage d'un chantier et à la passe 9 d'une clôture (autotest : lint.yml) |
4974-attente-reinventee.py |
tout appel à WaitForAsyncUtils.waitFor ou waitForAsyncFx hors de l'aide partagée : une attente réinventée là où Attente dit ce qu'elle attendait. La population n'est plus limitée aux méthodes privées depuis #4845, une attente écrite en clair dans un cas de test taisant exactement la même chose. Il lit les corps, jamais les noms, parce qu'un garde qui lit un nom se contourne en renommant : #4847 en avait compté 13 sous le nom attendre, la mesure sans les noms en trouvait 22. Depuis #5430 il lit les appels par la structure et non par un motif de ligne : une chaîne qui cite l'appel et la ligne médiane d'un commentaire de bloc cessent d'être comptées. Il vaut 5 (ADR 4974) |
lint.yml (cliquets ADR, autotest dédié) |
5068-clic-sur-reference-tenue.py |
les clickOn dont le premier argument est un nœud déjà résolu : la scène peut être nulle au moment du clic. Il borne les sites exposés, pas les défauts, et c'est un cliquet de dette assumée (#4696 fermée sur une mesure de 1/1234). Quatre formes écartées, chacune ayant fait surcompter : sélecteur littéral, constante String, concaténation de sélecteur ("#" + ID_VALIDER, la forme la plus fréquente du dépôt, neuf sites), citation en commentaire. Depuis #5430 l'argument est découpé par la structure : un appel écrit sur deux lignes cesse d'être retenu à l'aveugle, et une virgule vivant dans une chaîne ne coupe plus l'argument (ADR 5068) |
lint.yml (cliquets ADR, autotest dédié) |
4468-javadoc-non-relue.py |
les fichiers Java dont la javadoc n'a jamais été relue, ou l'a été puis réécrite : l'empreinte du manifeste scripts/methode/relus.txt en fait foi |
lint.yml (cliquets ADR, autotest dédié) |
scripts/methode/contrats-des-gardes.py |
le contrat de chaque garde - geste, ADR, population, seuil, verdict - et les écarts entre deux arbres, appariés par le geste et non par le nom de fichier. Hors CI : il lit deux arbres, et l'autre n'existe pas sur le runner (ADR 4636) | à la main, à l'ouverture d'un lot de portage |
.github/scripts/temoins_de_ci_non_decoratifs.py |
qu'aucun --auto-test de garde de CI ne reste vert quand le garde perd sa détection : le troisième banc, après celui des ADR et celui de la méthode, et il manquait. #5215 avait clos #4865 en affirmant qu'un garde converti « entre gratuitement dans un banc qui existe » ; mesuré à la clôture de #5218, il n'y entrait pas, et 43 gardes portaient 178 cas que rien ne mutait. Son verdict a trois valeurs et non deux : la neutralisation rend [], ce qui fait planter l'auto-test des gardes dont les fonctions rendent des tuples ou des chemins, et un rouge par plantage ne prouve rien (ADR 4918). Les non concluants se comptent donc à part et ne font pas rougir par eux-mêmes. Depuis #5497 ils sont tenus par une liste nommée, PLANTENT_SOUS_MUTATION, confrontée dans les deux sens (ADR 5743) : un garde qui plante sans y être nommé fait sortir le banc en 1, et une entrée qui ne plante plus aussi. Un auto-test qui refuse de jouer ne tient pas (#6030) : deux gardes ouvrent le leur par un préalable d'outil et sortent en 2 sans trace quand ImageMagick ou ffmpeg manque, ce que le runner du job temoins n'a pas. Le banc lisait ce rouge sans trace comme une preuve, et comptait 39 gardes éprouvés là où 37 avaient joué. Un code de sortie 2 est désormais « non concluant », avec ce que le garde a dit pour cause : c'est le canal de l'ADR 5774, où ce qui empêche de juger sort en 2. La table vaut donc sur tout hôte, 11 entrées partout, et NE_JOUENT_QU_AVEC, qui déclarait les outils de ces deux entrées pour qu'elles ne soient pas dites périmées, est retirée : elle bornait ce défaut de classification. Sa population se dérive de tous les ateliers et non du seul lint.yml, qui n'en nomme que 40 ; elle en vaut 47 au 2026-09-28. Depuis #5524 son exemption ne se lit plus sur le nom : elle se dérive du graphe d'appel, et le motif « auto » + « test » qu'elle employait épargnait autotestes, qui est la détection de verifie_inventaires_ci.py. Mesure statique : 64 fonctions de 19 gardes sont devenues mutables, pour un seul garde passé de « tient » à « non concluant » |
lint.yml (autotest dédié) |
scripts/methode/temoins-de-methode-non-decoratifs.py |
qu'aucun --auto-test de garde de méthode ne reste vert quand le garde perd sa détection - l'article A2 rendu mécanique hors de scripts/adr/, où verifie_temoins_non_decoratifs.py le fait. Son corpus n'est plus le seul dossier scripts/methode/ depuis #5397 : c'est tout ce que lint.yml lance sous scripts/, hors scripts/adr, et qui déclare un CONTRAT, la porte scripts/batterie.py comprise ; 30 gardes au 2026-10-06. Les deux gardes à contrat que la CI ne lance pas sont nommés dans HORS_PORTEE avec leur raison depuis #5479, et un garde du dossier ni couvert ni exempté fait rougir l'auto-test. La neutralisation s'insère avant le point d'entrée et non en fin de fichier ; ce point d'entrée se lit dans l'arbre depuis #5263, un motif se laissant tromper par une chaîne qui porte le même texte, et la machinerie de l'auto-test en est épargnée - dérivée du graphe d'appel depuis #5524, et non plus reconnue au motif « auto » + « test », qui protégeait aussi porte_un_auto_test, sa propre détection. Mesure statique : 14 fonctions de 5 gardes sont devenues mutables, à verdicts inchangés. Son verdict a trois valeurs depuis #5264 : un auto-test qui plante sous mutation n'a rien prouvé, il se compte « non concluant » et non « éprouvé » (ADR 5257). Refuse, en sortant 1, quand un garde n'a pas de point d'entrée où insérer la neutralisation : il le nommait en sortant 0, et six gardes sur quinze sont restés sans preuve sous une CI verte jusqu'à #4788. Il figure dans son propre corpus, ce que l'ADR 4770 a décidé, et il le dit depuis #5550 : une constante MOI écrite sous la forme que corpus() rend, un cas qui rougit si l'atelier cesse de le lancer, et un arrêt sur population fausse qui le fait rougir sous sa propre mutation, là où il plantait sans conclure. Ses non concluants sont nommés depuis #5497 : une liste PLANTENT_SOUS_MUTATION confrontée dans les deux sens, comme celle du banc des ADR, si bien qu'un garde neuf qui plante sous mutation fait sortir le banc en 1 au lieu de s'imprimer sous un vert |
lint.yml (autotest dédié) |
scripts/methode/tests-cites-existent.py |
que chaque -Dtest=Classe#methode cité par une page de méthode, une compétence ou un atelier nomme une classe et une méthode qui existent : sinon la commande rend BUILD SUCCESS sur zéro test. Les gabarits de prose sont écartés par une règle dérivée - une citation finit par Test - et non par une liste d'exceptions |
lint.yml (autotest dédié) |
scripts/methode/verifie-dependances-declarees.py |
qu'aucun import hors stdlib ne vive sans être déclaré dans pyproject.toml, sur les deux arbres de gardes et sur le Python enfoui dans les heredocs des gardes shell. Neuf d'entre eux portaient import yaml sans que rien ne déclare PyYAML : ils ne marchaient qu'en s'appuyant sur l'image du runner. Un import écrit en corps de fonction est paresseux par construction et reste hors champ, ce qui laisse scripts/graphify/rebuild.py tourner en CI sans installer graphify |
lint.yml (autotest dédié) |
scripts/methode/verifie-commandes-prescrites.py |
qu'une distribution déclarée dans [tool.vigiechiro.commandes] soit nommée dans une surface de méthode, et que ses commandes y soient prescrites. Le dépôt prescrivait graphify dans vingt fichiers sans nommer une seule fois graphifyy : or pip install graphify n'existe pas et npm i -g graphify installe un paquet tiers sans rapport - on n'obtient pas une erreur, on obtient le mauvais outil. Il cherche le mot entier, graphify étant une sous-chaîne de graphifyy (#4849) |
lint.yml (autotest dédié) |
scripts/methode/prepare-l-environnement.py |
pose ce qu'un worktree neuf n'a pas - l'outil OpenSpec et ruff - au lieu de laisser leurs gardes refuser. Appelé par .githooks/post-checkout sur le SHA nul, qui dit un worktree add ou un clone. Mesuré : le garde des specs passe de refus à verdict, et worktree add reste à 1 s. Il ne bloque jamais et ne se tait jamais - un outil introuvable est dit en une ligne, pas en trace. Depuis #5775 il ne cherche plus l'outil dans son propre PATH, où le poste ne l'a pas forcément mis : il demande au poste où vit le sien, en interrogeant le profil du shell que le poste déclare, avec un délai de garde. Et il rend 2 quand il n'a pas pu poser, au lieu de rendre 0 quoi qu'il arrive : la non-blocance est la propriété du crochet, qui se protège par son \|\| true. Aucun gardien en CI : le runner installe tout lui-même et ne joue pas ce chemin, donc son auto-test porte seul (#5406) |
lint.yml (autotest seul) |
scripts/methode/passes-citees-existent.py |
qu'une citation de passe de clôture désigne une passe qui existe : les bornes sont dérivées des titres ### N. du cycle, jamais écrites en dur. Mesuré en #4518 : une citation remise à l'ancienne numérotation laissait verts trente tests et les deux gardes de méthode. Ne voit pas une citation valide mais fausse, dont le numéro existe et dont le sens a glissé : son en-tête le dit et un cas d'auto-test l'éprouve (#4844) |
lint.yml |
scripts/methode/etapes-sans-renvoi-aval.py |
qu'aucune étape numérotée des six compétences du cycle ne délègue vers une compétence située plus loin - la prose, elle, reste libre de nommer la suite. Il refuse quand il ne trouve pas de liste d'étapes, parce que les six ne la titrent pas pareil et qu'un motif calé sur un seul titre en sauterait deux (ADR A3) | lint.yml (autotest dédié) |
scripts/methode/verifie-batterie-locale.py |
que la page qu'on lit avant de pousser, la section « batterie locale » d'ouvrir-une-pr, nomme chaque garde qui juge le dépôt en CI hors de ce que scripts/adr/rapport.py balaie. Sa population se dérive et ne s'énumère pas : une invocation --auto-test ne juge pas, un script sans CONTRAT produit sans juger, et les deux globs de rapport.py sont confrontés à son source. Elle s'arrête à scripts/, et l'ADR 5258 dit pourquoi : dans .github/scripts/, un seul des trente et un scripts déclare un contrat, si bien que la même règle y exclurait trente gardes en silence |
lint.yml (autotest dédié) |
5278-attente-hors-du-fil.py |
les appels a Attente.que dont le predicat lit le graphe de scene depuis le fil du test, la ou Attente.queSurLeFil existe depuis #4408 pour le lire sur le fil FX. La regle se derive des lectures de noeuds, en DEUX familles : celles qui cherchent un noeud (lookup(, queryAs, getItems(), getScene(), getChildren(), getText()) et celles qui lisent une propriete sur un noeud deja capture (isVisible, isDisabled, isManaged, isSelected, isFocused). La seconde famille manquait, et son absence laissait dix sites invisibles dont un dans chacun des trois bancs les plus accuses du releve (#5323). Depuis #5353 il suit la delegation : un predicat qui passe par une aide de son fichier est compte, transitivement, ce qui a rendu visibles trente-neuf sites que le motif ratait. Depuis #5430 l argument et le corps des aides sont bornes par la structure, et non par equilibrage de caracteres : une accolade vivant dans une chaine tronquait le corps d une aide, donc son lookup( disparaissait, donc l attente qui lui delegue echappait au cliquet. Depuis #6061 il ignore un fichier qui n'importe ni JavaFX ni TestFX : le getText() d'un element de page web, que Selenium lit dans un navigateur, n'est pas une lecture de noeud, et le garde l'accusait. Il compte et ne juge pas : queSurLeFil coute un aller-retour par tour de boucle, et un site legitimement laisse en que ecrit sa raison. Cliquet ouvert a 63, descendu a zero par les lots #5269, #5279 et #5323 |
lint.yml (cliquets ADR, autotest dedie) |
5707-geste-du-pointeur-hors-du-fil.py |
les gestes du pointeur - moveTo, clickOn, doubleClickOn, rightClickOn, drag, dropTo - qui situent leur cible depuis le fil du test. TestFX la situe en lisant getBoundsInLocal puis localToScene de chaque candidat, ce qui recalcule la geometrie du sous-arbre et itere les elements de tout Path qui s y trouve ; le caret d un champ de saisie en est un, que JavaFX rebatit a chaque clignotement. Une fois sur 558 tirages de trente jours, le banc meurt d une ConcurrentModificationException dont la pile est entierement dans JavaFX. Il complete 5278 par l autre bout : 5278 tient les PREDICATS d attente, 4246 tient les HELPERS qui lisent le graphe eux-memes, et ni l un ni l autre ne pouvait voir une lecture deleguee a la bibliotheque. Sa population CONTIENT strictement celle de 5068, 38 sites sur 38. Quatre formes ecartees : clickOn() nu, un premier argument MouseButton.*, press/release qui agissent ou le pointeur est, et la forme JUSTE pointSurLeFil(...) qui reste LUE pour que l auto-test prouve qu elle est distinguee. Un cliquet et non un banc : deux temoins Java ont ete ecrits et jetes, le fil JavaFX recalculant toute propriete partagee le premier et servant son cache au fil du test. Cliquet a 163, mesure 169 lus / 169 fautifs avant le lot, 169 / 163 apres |
lint.yml (cliquets ADR, autotest dedie) |
6022-annotation-qui-fait-taire.py |
l annotation et la marque qui font taire le portail qualite, dans src/main/java et src/test/java. Une @SuppressWarnings est refusee des qu une de ses valeurs sort de la liste FERMEE unchecked, rawtypes, ou n est pas un litteral : une liste de refus laisserait passer all, que PMD honore comme unused, PMD et PMD.<regle>. Une marque NOPMD est refusee dans un commentaire de ligne qui SUIT du code, seul endroit ou PMD la lit ; un bloc, des minuscules ou une ligne de commentaire seule ne font rien taire, et les deux mentions en prose du depot restent vertes. Ce que PMD honore a ete MESURE le 2026-10-07 sur une classe jetable, sous PMD 7.17.0. La lecture passe par l arbre syntaxique. Il ne lit pas les exclusions ecrites dans pmd-ruleset.xml. Cliquet a zero, qui se lit comme un refus (ADR 6022) |
lint.yml (cliquets ADR, autotest dedie) |
6142-banc-a-toolkit-sans-espion.py |
les classes de test qui montent le toolkit JavaFX sans porter l espion du fil. Un @ExtendWith de src/test/java qui nomme ApplicationExtension est suspect quand il ne nomme pas SansEcritureHorsDuFil dans la MEME annotation, lue par l arbre syntaxique : une javadoc qui cite l un ou l autre n annote rien. Cliquet a zero. Une seule exempte, dans une liste fermee : SansEcritureHorsDuFilTest, l auto-test de l extension. Il ne dit rien de ce que l espion VOIT une fois porte, et un toolkit demarre sans ApplicationExtension sort de sa population |
ADR 6142 |
5437-fixture-suppose-la-plateforme.py |
les appels POSIX qui JETTENT sur un systeme sans vue posix (setPosixFilePermissions, getPosixFilePermissions, asFileAttribute) quand rien ne declare l exigence. fromString en est exclue : elle analyse une chaine et ne touche aucun fichier. CINQ formes valent declaration, et ce sont celles que le depot employait deja : @EnabledIf sur la methode ou la classe, un assumeTrue en TETE de methode, un if dont la CONDITION nomme le predicat, un try / catch (UnsupportedOperationException), et une aide dont tous les appelants du fichier declarent. La lecture passe par l arbre syntaxique et non par un motif : sur l etat du 2026-09-06, un grep retenait SEPT fichiers la ou ce garde en retient deux, les cinq autres etant une citation en commentaire, deux assumeTrue, une aide qui delegue et une aide qui rattrape l exception. Ce qu il ne voit PAS : une aide appelee depuis un AUTRE fichier, et un @EnabledIf dont le predicat rendrait toujours vrai, suivi par son nom et non par son corps. Cliquet a zero des l ouverture, l ADR 3802 ayant ete appliquee par #5435 et #5436 |
lint.yml (cliquets ADR, autotest dedie) |
5307-designation-hors-fabrique.py |
les constructions du selecteur de fichiers NATIF hors de commun/view/Selecteurs.java, la fabrique posee par l'ADR 5307. Le choix du dispositif etait ecrit douze fois en dur, donc il n'existait aucun endroit ou le changer. Cliquet a zero, et il ne borne pas une dette : il compte une population qui doit rester vide, aucune raison legitime ne justifiant de construire le selecteur natif ailleurs. Manquer un site ne casse rien - l'ecran oublie ouvre le dialogue du systeme et a l'air juste - ce qui est precisement pourquoi un compte le tient plutot que la relecture |
lint.yml (cliquets ADR, autotest dedie) |
scripts/methode/releve-les-planchers.py |
relève chaque plancher à la mesure que son garde rend - il ne recopie aucun chiffre - aux trois endroits où un seuil s'écrit : l'en-tête floor:, la balise du corps de l'ADR, celle du journal. Il remplace le contenu de la balise en préservant le séparateur qu'il y trouve, l'espace insécable comprise. Hors CI : il écrit, et un garde ne répare pas ce qu'il surveille (ADR 4395) |
à la main, quand un plancher dit « à relever » |
scripts/methode/releve-les-bancs-instables.py |
combien de fois chaque banc a rougi, sur combien de tirages. Il ne lit pas les verdicts courants : une relance écrase l'échec précédent, si bien que sur 21 jours aucun commit n'apparaissait vu vert et rouge alors que 52 runs avaient été relancés. Il lit les tentatives, un run à deux tentatives finissant vert ayant échoué puis réussi sur le même commit. Et il retient l'ordre des échecs : le premier tombé est un suspect, ceux qui suivent sont ce qu'une cascade a emporté. Sans cette séparation il nommait 739 tests, soit 76 % des classes JavaFX du dépôt, et y figurer n'apprenait rien ; avec elle, 21 (#4811). Il nomme aussi derrière qui chaque victime tombe, et distingue un couplage d'une dispersion (#5312) : une victime à huit entraîneurs différents n'accuse personne. Il attribue enfin la course JavaFX de #4823 au dépôt plutôt qu'au runner, la taille seule ayant décidé jusqu'à #5344 ; relancer la fait passer, et ne la répare pas. Ce qu'il ne lit pas : un seul flux, maven.yml, et dedans un seul atelier, build. Les autres ateliers du même flux et tous les autres flux qui lancent la suite lui sont invisibles, si bien que ses taux sont des minorants - le flake de #4616 n'a jamais été vu que sous fuseau-alternatif. Sa sortie le déclare depuis #5273, et la liste des invisibles se dérive du workflow plutôt que de s'y recopier - une limite périmée est pire qu'une limite absente (article A3, ADR 3627), et c'est aussi pourquoi leur nombre ne figure pas dans cette page : une liste tenue à la main est ce qui dérive (article A5, ADR 2385). Hors CI : il lit la forge et télécharge les journaux de tentative, cinq minutes sur trois semaines (#4806). Et depuis #5544 il REFUSE quand la forge se tait, là où il rendait 0 en silence : son ancien appel rendait une chaîne vide si gh manquait ET avalait le code de retour, si bien qu'une forge répondant par une erreur devenait « aucun banc instable » - "".splitlines() rend [], la boucle ne tourne pas, et le relevé concluait. Un relevé ne bloque pas, c'est son régime, mais « je n'ai pas pu lire » n'est pas « rien à signaler ». Son appel vient désormais de scripts/_commun/forge.py, partagé avec loupe-4992, loupe-5539 et verifie_chantier_de_l_issue.py. Une copie reste, nommée : journalDeTentative rend des octets, exige unzip, et un journal vide y est légitime puisque les journaux de tentative expirent |
à la main, quand un chantier a besoin d'un taux plutôt que d'observations (autotest : lint.yml) |
scripts/methode/compte-les-reliquats.py |
ce que la suite laisse dans le dossier temporaire, en différentiel : un relevé avant, un après, et la différence. Compter le total ferait rougir le reliquat de la veille, et le garde serait désarmé en une semaine. Il refuse de conclure si le relevé d'avant manque. Ne peut pas être un test, la propriété ne s'observant qu'une fois tous les forks rendus (ADR 4859) | deux pas encadrant la suite dans maven.yml (autotest : lint.yml) |
scripts/methode/couverture-relecture.py |
le compteur qui ÉCRIT le manifeste de relecture : il refuse un chemin hors des deux arbres Java, et nomme les entrées qui ne désignent plus aucun fichier | lint.yml (autotest dédié) |
scripts/methode/couverture-openspec.py |
ce que la spécification vivante couvre du produit : les capacités spécifiées, leurs exigences, et les paquets qui n'en portent aucune. Ses grandeurs sont des repères et jamais un total, faute d'un dénominateur que le dépôt ne tranche pas. Elle ne juge pas ; ce qui refuse est le cliquet d'ADR 4922 | lint.yml (auto-test seul) |
scripts/methode/verifie-specs-valides.py |
que les specs principales d'OpenSpec valident, par l'outil épinglé. Il refuse sur un corpus vide plutôt que de conclure : l'outil sort en 0 en écrivant « No items found to validate », et un garde qui l'appellerait nu serait muet en ayant l'air sain. Il ne valide pas les changements actifs, légitimement incomplets pendant leur écriture. Depuis #5774 il sort en 2 sur un refus et non en 1, ce que sa fonction de jugement distinguait déjà avant de l'écraser au seuil de la sortie, et il nomme le prérequis le plus profond : l'interprète du shebang quand l'outil est posé mais inutilisable, là où il accusait auparavant la sortie de l'outil de ne porter aucune ligne « Totals: ». Et depuis la clôture de #5762, ses trois autres refus portent aussi la forme déclarée - ils écrivaient REFUS : sans champ de geste, donc lit_le_refus les rendait illisibles à la porte - tandis que sa branche d'écart réel cesse de porter ce mot : elle a jugé, elle sort en 1, et elle se faisait lire comme un empêchement |
lint.yml |
4475-stage-non-dimensionne.py |
un test TestFX qui pose une scène dimensionnée sans faire suivre son stage, alors que le stage primaire est partagé dans un fork | lint.yml (cliquets ADR) |
4476-javadoc-raconte-son-extraction.py |
une javadoc qui nomme dans la même phrase le verbe d'extraction et l'outil d'analyse qui l'a exigée | lint.yml (cliquets ADR) |
4477-longueur-des-adr.py |
une ADR qui raconte plus que sa décision et l'incident qui l'a produite | lint.yml (cliquets ADR) |
verifie_javadoc_sans_doublon.py |
deux lignes de javadoc identiques et consécutives, ou un bloc recopié sur le membre suivant : une coupe ratée, jamais une intention | lint.yml |
verifie_encart_de_revision.py |
une ADR dépassée le dit sous son titre, et l'encart n'annonce que ce que son en-tête déclare. Depuis #5579 il reconnaît une relation subie quelle que soit son orthographe : sa comparaison passait par un tuple littéral, donc amendée_par était lu et non reconnu, et il sortait vert sur une ADR amendée sans encart. La règle de lecture et de normalisation vit dans scripts/_commun/relations.py |
lint.yml |
verifie_taille_des_cibles.py |
une cible cliquable déclarée fait au moins 24 x 24 px (WCAG 2.5.8, niveau AA), décision de l'ADR 4462. Il ne lit que les dimensions chiffrées écrites dans un FXML sur six types de nœud : trois cibles sur 147 le 2026-10-07, les autres tenant leur taille de leur police | lint.yml |
verifie_lien_javadoc_formatable.py |
une ligne /// de plus de 120 caractères portant un lien markdown à texte espacé : le formateur la casserait en perdant le préfixe |
lint.yml |
verifie_temoins_non_decoratifs.py |
aucun témoin de verifie_scripts.py n'est décoratif : chaque garde perd sa détection, la suite doit rougir (article A2 rendu mécanique). Le corpus se partage en deux moitiés disjointes : les gardes que la suite charge, éprouvés en la jouant, et les autonomes, éprouvés par leur propre --auto-test (#5134). La partition ne tenait que sans portée : sous portée, les deux listes perdaient leur filtre et un garde tombait dans les deux, où la suite qui l'ignore restait verte quoi qu'il arrive, donc « décoratif » à tort (#5484). La mutation porte sur un arbre jetable depuis #4700 - scripts/ copié, le reste lié - donc l'interrompre ne peut plus laisser un garde neutralisé dans le dépôt. Son verdict a trois valeurs depuis #5264, sur ses deux moitiés : un plantage sous mutation se compte « non concluant » (ADR 5257), et la moitié des autonomes lit le point d'entrée dans l'arbre depuis #5263, là où elle prenait la première occurrence d'une chaîne. Un rouge MUET ne tient pas (#5499) : un auto-test qui sort non nul sans rien dire, hormis le [] que rend la neutralisation, n'a joué aucun cas, et il est désormais « non concluant » plutôt que « tient ». Six gardes étaient dans ce cas, leur point d'entrée étant lui-même neutralisé ; la neutralisation épargne donc main, auto_test et cas_d_auto_test par nom exact, jamais par motif - « auto » et « test » y protégeraient auto_test_rougit, qui est sa propre détection. Le souligné ne décidait pas mieux (#5524) : il épargnait toute fonction préfixée, donc une détection nommée _completude ou _forge par convention d'interne était hors d'atteinte, et son garde passait pour éprouvé ou décoratif sans que rien de vrai n'ait été mesuré. L'exemption se dérive désormais du graphe d'appel - est machinerie ce que seul le point d'entrée d'auto-test atteint - et le préfixe n'est conservé que comme filtre, ce qui rend le changement monotone. Mesure statique : 41 fonctions sont devenues mutables, à verdicts inchangés, et verifie_scripts.py est sorti de HORS_PORTEE, sa détection _completude étant enfin atteinte. Et le cas qui juge cette partition jugeait la population d'à côté (#5549) : il dérivait son corpus du dossier en excluant ce banc, quand le chemin par défaut l'inclut, la suite le chargeant. 49 contre 50, et les deux arithmétiques fermaient, ce qui rend l'angle mort discret. Un cas compare désormais les deux populations plutôt que d'en juger une seule. Et ce banc dit de lui-même ses deux réponses (#6031) : il ne se lance jamais, MOI in autonomes() est faux, et la suite qui le charge le mute, MOI in mutes() est vrai ; la seconde ne tenait que par conséquence. Et rien ne bornait les non concluants (#5743) : un plantage n'entrant pas dans les suspects, quatre gardes n'avaient rien établi pendant que la dernière ligne disait verdict=ok, et le compte pouvait passer de 4 à 40 sans qu'aucun chiffre bouge. Ils sont tenus par une liste nommée, PLANTENT_SOUS_MUTATION, et non par un cliquet : ce banc se déclare invariant, donc sans marge à relever, et une marge à quatre autoriserait d'échanger un plantage réparé contre un plantage neuf. Elle se vérifie dans les deux sens, comme HORS_PORTEE : un garde qui plante sans être nommé refuse, et une entrée qui ne plante plus refuse aussi. Le second sens n'est jugé que sur le corpus entier, sans quoi un diff d'un seul garde annoncerait quatre réparations imaginaires. Et HORS_PORTEE n'était confrontée que dans un sens (#5495) : un garde hors des deux moitiés sans y être déclaré faisait rougir, mais une entrée dont le fichier avait disparu restait retranchée sans rien dire, et aurait couvert un garde neuf du même nom. exemptions_perimees() nomme ces entrées ; la liste réelle étant vide, c'est un cas fabriqué sur un dossier jouet qui la tient |
lint.yml |
verifie_verdicts_declares.py |
qu'aucun appel de rapporte, rapporte_plancher ou loupe ne rende lus=? : un compte non déclaré rend le zéro du garde indiscernable de « n'a rien balayé », et le refus sur population vide ne le protège alors pas (ADR 5015). Les exceptions se nomment avec leur raison plutôt que de se compter : un cliquet à N se satisferait de convertir un garde et d'en ajouter un autre muet. La liaison de chaque nom est résolue par ast, un homonyme défini localement n'étant pas le verdict de _commun |
lint.yml |
verifie_contrats_tiennent.py |
qu'un contrat DÉCLARÉ ne contredise pas ce que le garde FAIT : imprime_contrat refusait un contrat incomplet, rien ne le confrontait au réel (ADR 4636). La règle n'est pas l'égalité mais l'absence de contradiction, parce que le vocabulaire diffère (RACINES vaut PRODUCTION + TESTS) et que le contrat sait souvent PLUS que l'inférence. Sa population se dérive de la déclaration et non d'une mention, par ast depuis #5144 : un grep sur --contrat comptait les fichiers qui en PARLENT. Sa taille se lit dans sa sortie (lus=), et plus aucun garde shell n'en fait partie : le dernier qui portait un contrat, verifie-butoirs.sh, a été converti par #5224. Chaque contrat est obtenu en lançant le garde, jamais en lisant son source |
lint.yml |
verifie_contrat_obligatoire.py |
qu'un point d'entrée Python du dépôt hors de .github DÉCLARE ce qu'il est (ADR 4636). Ils sont 75 au 2026-09-07, chiffre que le garde imprime lui-même (lus=75) plutôt que cette page ne le recopie, et la population s'est élargie en #5157, puis en #5192 à six dossiers hors de scripts/adr et scripts/methode ; rien n'empêchait le suivant d'arriver sans le sien, et un corpus complet se reperd sans bruit - un garde neuf copié sur un voisin emporte le bloc de verdict et pas la déclaration. C'est un invariant, pas un cliquet : il n'y a pas de marge à relever, et l'échappatoire est une liste d'exceptions nommées, vide à la livraison, et qui en compte cinq depuis #5192, chacune avec sa raison. Il ne juge pas le CONTENU du contrat, que verifie_contrats_tiennent.py confronte déjà |
lint.yml |
5188-corpus-shell.py |
ce qui reste en shell, et que la cible des deux langages condamne (ADR 5188). Un cliquet à 2, polarité descendante : il n'empêche pas qu'un script grossisse, il empêche la seule chose qui rendrait la cible inatteignable, qu'on en ajoute. Le script toléré, lance-test-filme.sh, est compté et non retiré : une tolérance est un délai, et le retirer ferait croire à une dispense. Sa population est ce que git ls-files suit, le même angle mort que les autres cliquets plutôt qu'un angle mort différent ici |
lint.yml |
loupe-5175-population-non-nommee.py |
un garde qui parcourt un chemin que sa population déclarée ne nomme pas (ADR 5175). Une loupe : elle rend 0. Quatre des cinq écarts mesurés sont des déclarations plus précises que le chemin, pas des populations fausses, et un garde qui refuserait crierait sur du juste. Elle ne lance rien : les contrats se lisent par ast, second recours de l'ADR 5102, plutôt que de doubler les 68 sous-processus de verifie_contrats_tiennent.py pour un dispositif qui ne juge pas |
lint.yml |
verifie_corpus_declare.py |
le corpus d'un garde s'importe du fonds commun scripts/_commun/ et ne se recopie pas ; le fonds s'exclut par son dossier depuis #5216, non par un nom de fichier : c'est ce refus qui permet à la liste des gardes à deux arbres de se dériver au lieu de s'énumérer (ADR 4586) |
lint.yml |
rapport.py |
chaque garde tourne dans SON dépôt, et non dans le répertoire de l'appelant : sans cwd, cinq cliquets sur dix-huit rendaient une autre valeur depuis ailleurs, et resserre_cliquets.py ramenait quatre ratchet: à zéro dans les vraies ADR en annonçant un succès (issue #4781) |
lint.yml |
verifie_epinglage.py |
actions figées sur un SHA, aucune divergence de version | lint.yml |
verifie_jeton.py |
aucun jeton VigieChiro en clair | lint.yml |
check_captures.py |
chaque vue a une capture, chaque capture existe et est présentée | lint.yml |
check_capture_mains.py |
chaque outil de capture est enregistré dans MAINS |
lint.yml |
check_doc_images.py |
chaque capture citée par la doc existe et est déclarée | docs.yml |
check_doc_videos.py |
chaque parcours filmé cité par la doc existe, a son scénario au banc, et son chemin résout dans le site construit. Dans la porte locale depuis #5909, par le contrat déclaré, et seul son mode par défaut : le mode --site suit chaque référence comme un navigateur sur un site construit, que la porte n'a pas. Ses chemins portent trois côtés là où son voisin des images en a deux, et le troisième est le banc : un parcours renommé rend orphelin un film présent, sans qu'aucune page ni aucun film n'ait bougé. #5809 avait converti trois gardes de ce dossier et laissé celui-ci, si bien qu'une page pointant un clip absent n'était attrapée qu'en CI |
docs.yml |
filme-un-parcours.sh |
le banc de documentation : lancement, geste visé par libellé avec balayage annoncé, carte, montage et plages accélérées, index converti, et l'exigence de résultat de chaque parcours (ADR 4013) | lint.yml (job banc-filme) |
verifie_permissions.py |
aucun plancher en écriture dans un workflow multi-jobs | lint.yml |
verifie_chemins_ascii.py |
aucun chemin suivi ne porte de caractère non-ASCII. git ls-files et git diff --name-only les échappent par défaut, ce qui les rend invisibles à tout outillage qui teste ensuite l'existence du fichier, en silence : une passe du graphe a ainsi écarté 50 fichiers de brief/ et pris 6 renommages pour 56 suppressions (ADR 5089). Tolérance zéro, les 101 chemins fautifs ayant été renommés dans le même lot. Il ne dit rien de la casse ni des espaces, que core.quotePath n'échappe pas |
lint.yml |
verifie_portee_des_secrets.py |
aucun secret VIGIECHIRO_* dans l'env: d'un job ni d'un workflow : la forme juste est l'env: d'un pas. Posé plus haut, il est offert à toute la suite de tests, que ConnexionModule pointe alors sur la production. Aucun rôle de la plateforme ne peut le rattraper - Lecteur est déclaré et aucune route ne l'accepte (#4303). Second contrôle : aucun appel de workflow ne transmet le trousseau entier par héritage. Une déclaration nominale côté appelé n achète rien tant que l appelant hérite, et release.yml le faisait pour trois appels dont un qui exécute les tests du produit (#4349) |
lint.yml |
revoque_jeton.py |
le jeton d un tournage connecté est rendu inutilisable en fin de run (POST /logout), et la règle qui compte est que 404 et 401 valent succès : le but n est pas « le serveur a répondu 200 » mais « ce jeton ne sert plus à personne ». Il ne fait jamais rougir le run, l incertitude sort en avertissement (#4305) |
tournage-recette.yml (autotest : lint.yml) |
verifie_jeton_vivant.py |
le jeton du tournage connecté est-il encore valide, et non seulement présent. Le tournage révoque le sien en fin de run sans retirer le secret : après un tournage il a l air parfaitement valide et ne vaut plus rien, et le scénario rougissait à trois pas de sa cause, sur un run qui finissait vert (#4328). Il rend trois verdicts et non deux, parce que « la plateforme ne répond pas » n appelle pas le même geste que « le jeton est mort ». C est la table de revoque_jeton.py lue à l ENVERS : ici 401 est un refus |
tournage-recette.yml (autotest : lint.yml) |
interroge_le_jeton.py |
un appel a la plateforme et son code HTTP, rien d autre. Les TROIS appelants le partagent - le controle du jeton, sa revocation, et le pas « Jeton valide ? » d api-live.yml - parce que la ligne recopiee est ce qui a mis le defaut du « HTTP 000000 » aux trois endroits (#4328). Il ne juge pas, et un cas le garde : les trois lectures d un 401 different, dont deux s opposent, et le run: d api-live tourne sous bash -e (#4385) |
api-live.yml, tournage-recette.yml (autotest : lint.yml) |
verifie_forme_du_jeton.py |
les TEXTES d un tournage - tournage.log, l index - avant qu ils ne partent en artefact. Un jeton Vigie-Chiro fait exactement 32 caractères de [A-Z0-9] (mesuré dans auth.py), et on exige au moins une lettre hors de A-F pour ne pas confondre avec une empreinte hexadécimale. Il tourne AVANT l envoi, parce qu un artefact de dépôt public se télécharge sans authentification, et l envoi en dépend. Il NE COUVRE PAS l image, et il le DIT dans son compte rendu : un garde muet sur sa portée est un faux vert avec des étapes en plus (#4327) |
tournage-recette.yml (autotest : lint.yml) |
verifie_decisions_du_tournage_connecte.py |
douze décisions des ateliers de tournage qui vivaient dans du YAML que rien ne gardait (#4331). Les cinq premières sont celles du tournage connecté : comparer-tournages.yml REFUSE la source clips-connectes et ne refuse PAS clips-plateforme-de-test (#5793, ADR 5641), publier-connecte et publier-plateforme-de-test dépendent de filmer et portent une fonction d état, le contrôle du jeton vient AVANT le pas qui filme, et filmer NOMME son artefact avec sa tentative, toute reprise le lisant de sa sortie (#5797 : une publication relancée versait l artefact de la tentative échouée), et le tournage précédent de la plateforme de test est recopié sur clips-plateforme-de-test-precedent AVANT toute écriture sur la pré-version courante, source que la comparaison ne refuse pas (#5854). Les quatre suivantes sont celles de mesurer-les-planchers.yml (#5885) : il REFUSE des tournages qui ne sont pas du même commit ou qui n ont pas conclu, témoins compris, parce qu un plancher pris entre deux commits range un changement du produit parmi le bruit ; un témoin n est jamais pris dans la mesure, sous le plancher de laquelle il resterait par construction ; l atelier ne porte aucune permission d écriture, le fichier se committant par une demande ; et il ne reprend une exécution que si elle porte UN artefact de clips, une exécution relancée en portant un par tentative (#5644, trouvé à la clôture). La dixième est celle de la comparaison d une exécution (#5930, ADR 5930) : comparer-tournages.yml prend un numéro d exécution pour source, jamais cherché parmi les versions, et le reprend avec les refus de la mesure, exigés des deux ateliers par les mêmes phrases et face au même leurre ; un seul ne se reprend pas, celui du même commit, deux commits étant l objet d une comparaison ; un artefact expiré se dit, par son nom ou par le rappel du délai de quatorze jours, dans les DEUX ateliers depuis #5979, et l atelier déclare actions: read. La onzième est celle du résumé de la mesure (#5979) : son pas se lance seul face à un journal qui porte un plancher à relire et face à un journal qui n en porte pas, la ligne du signal étant demandée à compare_tournages.py et non recopiée. La douzième est celle du versement de la plateforme de test (#6155) : publier-plateforme-de-test ne verse que depuis main, par une condition qui ne s'ouvre à rien d'autre, et dans un groupe de concurrence fixe qui n'annule pas le versement en cours ; le 12 octobre 2026, six tournages de branche lancés ensemble avaient écrasé les deux pré-versions, et deux d'entre eux s'étaient disputé la précédente jusqu'à un HTTP 404. Les refus ne sont pas relus mais LANCÉS - ils vivent dans un run:, donc du shell, face à un gh leurre - et le verdict se prend sur le MESSAGE, pas sur le code de sortie. Son auto-test fabrique trente-six copies cassées, une par décision et par bord |
lint.yml (autotest : lint.yml) |
cas_manquants_du_tournage.py |
lesquels des cas attendus le tournage n'a pas rendus, et non pas combien. L'oracle comparait deux nombres, « 111 cas sur 116 », sans dire quoi corriger ; et son attendu incluait les cas des scénarios recette-connectee, exclus du build sans jeton, si bien qu'un tournage non connecté ne pouvait pas l'atteindre et que le train a cessé de verser ses clips Java pendant cinq jours (#5012). Un index illisible le fait refuser, jamais conclure |
tournage-recette.yml (autotest : lint.yml) |
verdict_du_tournage.py |
ce qu'un tournage a vraiment donné : combien de tests ont rougi, lu dans les rapports XML de surefire et jamais dans les .txt, qui mentent sur les @Nested. Il compte des tests et non des cas de recette, et sort du compte la classe que l'atelier joue sans la filmer, tant qu'elle est verte et rapportée (#5865). Le tournage tourne sous failure.ignore - on veut les clips d'un cas qui rougit -, si bien qu'un scénario rouge laissait le job vert et son clip se versait sans marque (#4351). Il rapporte, il ne juge pas |
tournage-recette.yml (autotest : lint.yml) |
verifie_butoirs.py |
tout job porte un timeout-minutes : sans butoir, GitHub laisse courir six heures (ADR 4028) |
lint.yml |
verifie_sorties_sous_windows.py |
un script de .github/scripts qu'un job capable de Windows lance appelle ecrire_en_utf8() de _console à son chargement : la console de Windows encode en cp1252, et le tri des échecs de plateforme mourait sur le « ⚠️ » de son compte rendu après des tests verts (#5197). Un runs-on choisi à l'exécution compte comme Windows |
lint.yml |
installer_paquets.py |
la porte d'installation : elle écarte ce qui est déjà présent, borne et reprend, et câble le cache des .deb (ADR 4034) |
lint.yml (ses cas) et les cinq workflows qui installent |
verifie_apt.py |
aucun workflow n'appelle apt-get en direct, le cache est branché (un par job, la variable sur chaque installation), et les paquets à post-installation - fonts-*, flatpak*, ffmpeg - ne passent PAS par le cache de fichiers (ADR 4034) |
lint.yml |
verifie_conditions_booleennes.py |
une entrée type: boolean n'est jamais comparée à une chaîne dans un if: : == 'true' vaut toujours faux, != 'true' toujours vrai, et le témoin « sans gestionnaire de fenêtres » installait donc quand même openbox |
lint.yml |
verifie_conditions_de_job.py |
la condition d'un job appuyé sur un needs porte une fonction d'état : sans elle, GitHub l'enveloppe en success() && (...) sur tout le graphe amont, et le saut d'un ancêtre la rend inévaluable. La 2.186.0 a eu son tag et sa Release, puis aucun installeur, sur un run vert (ADR 4079) |
lint.yml |
verifie_portees_de_ci.py |
une portée de job tient ce qu'elle promet : bijection job / portée, aucune porte décorative, aucun chemin mort, chaque portée nomme son propre atelier et le mécanisme, et aucun chemin écrit dans un run: n'échappe à la portée de son job. Le mode de panne qu'elle ferme est le faux vert : une portée qui ne correspond à rien fait écrire « sans objet » à un job qui aurait dû juger (chantier #5294) |
lint.yml |
scripts/methode/mesure-le-cout-du-geste.py |
la durée de la suite pour plusieurs variantes d'un même geste, avec sa dispersion. Il ne compare jamais deux arbres : la paire d'origine de #5732 opposait un vieux main à un neuf, et six lots avaient touché des fichiers de test entre-temps, si bien que l'écart les portait. Ici un seul arbre, et les variantes ne diffèrent que par le fichier permuté. Il importe mediane, dispersion_robuste et ECART_MINIMAL_EN_MAD de mesure_duree_portail.py plutôt que de les réécrire (ADR 5628). Ses trois refus ne se confondent pas : une JVM de bâti étrangère pendant une passe la rejoue - trois tentatives, comme le insiste() de l'ADR 2748 - parce que deux séries de cinquante minutes ont été perdues sur un poste que quatre sessions partagent, et faire dépendre une heure de machine d'une promesse humaine est fragile par construction ; un /proc illisible sort en 2, le verdict portant alors sur le poste et non sur la mesure (ADR 5407) ; une suite qui a rougi n'est pas comptée, une passe à cinq erreurs n'ayant pas fait le même travail qu'une passe verte. Il reconnaît une JVM de bâti à son exécutable (/proc/<pid>/exe) et non au texte de sa ligne de commande, où un script qui parle de java se reconnaissait lui-même, puis à son répertoire de travail, qui départage les siennes de celles des pairs - et jamais un serveur de langage d'une JVM de bâti, ce que le marqueur fait avant. Ce qu'il ne lit pas : il ne mesure que ce qu'il a joué lui-même, et il ne sait pas isoler l'effet d'un seul lot comme le ferait la CI sur une fenêtre. Trois défauts de mesure l'ont traversé sans rien faire rougir, et c'est ce qui les rendait coûteux : des durées toutes multiples de la cadence de sondage, soit la boucle mesurant elle-même ; une aberrante de compilation qui n'abîmait pas la médiane mais écrasait l'écart absolu médian, donc corrompait la mesure de confiance plutôt que la mesure ; et la reconnaissance d'une JVM par un nom au lieu d'une propriété. Hors CI : la mesure demande le poste seul, donc elle se lance à la main ; seul son auto-test tourne en CI |
à la main, quand un lot a besoin du coût d'un geste (autotest : lint.yml) |
scripts/methode/releve-les-harnais-muets.py |
les harnais d'auto-test qui ne peuvent pas nommer le cas qui lève. Il refuse depuis #5570, au-dessus d'un cliquet de 81, resserré à 79 par #5192, qui a converti deux harnais : un cliquet posé à la valeur mesurée n'exige rien des 81 et exige tout des suivants, là où la phrase qu'il portait jusque-là, « refuser reviendrait à bloquer le dépôt sur une dette », était l'argument d'un butoir. Son compte était subi : 74 muets en septembre, 75 trois semaines plus tard. Il dérive sa population de ce que le harnais fait de son échec - la marque posée en ligne, ou un cas passé en valeur à une aide qui tient le libellé - et jamais de la forme de son écriture. C'est ce qui l'a fait trouver scripts/batterie.py, que les populations de #5460 et #5461 manquaient toutes deux, définies l'une par une verifie locale et l'autre par cas.append : la porte n'écrit ni l'un ni l'autre et portait le défaut sur dix-sept sites. Son témoin négatif est ce qui le tient : un harnais entièrement différé ne figure dans aucune des deux formes. Son balayage est récursif depuis #5570, et il ne l'était pas : huit gardes dont la porte joue l'auto-test étaient hors population, six d'entre eux muets, donc un cliquet posé avant cette réparation aurait verrouillé le mauvais chiffre. Mesure du 2026-10-01 : lus=101 \| muets=81, dont 46 en forme B et 35 en forme A, et 20 entièrement différés |
lint.yml (autotest dédié) |
partition_de_la_suite.py |
l'union des quatre lots d'ordre-alternatif est le corpus de test, confrontée classe par classe et pour toutes les graines. Un compte égal ne prouve pas une couverture égale : c'est le défaut de #4544 monté d'un cran, où un passage tronqué annonçait 758 classes en en jouant 618. Refuse aussi un lot vide, un recouvrement et un déséquilibre (#5329) |
lint.yml |
scripts/batterie.py |
la porte d'entrée unique : ce qu'un diff engage, et ce qu'il n'engage pas. Un garde qui ne déclare pas ses chemins est lancé, et un garde illisible est signalé puis lancé : le défaut penche du côté coûteux, jamais du côté muet. Elle refuse d'engager zéro garde sur un diff non vide (ADR 5340). Elle refuse aussi les fichiers neufs hors de l'index : la porte voit leurs chemins, mais les gardes fondés sur git ls-files les excluraient encore de leur population |
lint.yml Depuis #5481 elle joue chaque garde avec les arguments des ateliers - dix d'entre eux écrivent dans le mode sans argument au lieu de juger -, elle joue ruff, et sa ligne de verdict nomme ce qu'elle n'a PAS joué : les classes Java, les contrôles de la prose de la demande, et les scripts de .github/scripts qui ne déclarent pas de CONTRAT (#5525). Ces contrôles-là se dérivent depuis #5931, des ateliers qui lient une variable à github.event.pull_request.title ou .body et la passent en argument : la ligne en nommait deux sur trois, écrits à la main, et verifie_chantier_de_l_issue.py manquait. Le critère n'est pas « ce que la CI lance avec un argument », qui rendrait dix-sept scripts dont installer_paquets.py, mais la nature de l'argument : un texte que la demande porte et dont aucune copie locale ne dispose. Depuis #5555 elle joue aussi les --auto-test que les ateliers lancent sous scripts/ et que sa population ignore. Leur nombre se dérive et n'est pas écrit ici : il monte à chaque garde qu'un atelier gagne, et un compte figé redeviendrait faux sans que rien ne le dise. Coût mesuré le 2026-09-29 : 1,64 s pour ceux d'alors. Quatre n'avaient pour premier lecteur que la CI, neuf sont des loupes et des relevés, hors de la population parce qu'ils ne jugent pas. Le critère est le mode et non le dossier : rapport_mutation.py --markdown s'exclut de lui-même en réclamant un passage de PIT. Et la porte s'exclut d'elle-même, sans quoi son auto-test se relance dans sa propre exécution - 6,32 s et un rouge étranger. Son verdict porte désormais trois comptes : gardes, auto-tests d'atelier, outils. Et elle classe par les MARQUES et non par le code (#5890) : code=1 avec « REFUS : » rend muet, sans elles rouge. Son repli à deux lignes choisit lesquelles selon la forme de la sortie, les dernières sur une trace d'exception dont la cause est le dernier mot, les premières sinon. Depuis #5525 sa population s'étend à .github/scripts, par la même règle qu'ailleurs : le CONTRAT déclaré, jamais le dossier - la CI y lance aussi revoque_jeton.py et installer_paquets.py, et une règle fondée sur le chemin ferait révoquer un jeton depuis un poste. Trois gardes y entrent, et ce qui reste dehors passe de 35 à 32. Les trois portent leurs chemins, sans quoi la règle du repli les lancerait à chaque diff : le banc de mutation de CI met 10,36 s, qu'un changement de javadoc n'a pas à payer. Et depuis #5809 elle s'étend aussi à .github/assets, par la même règle : les trois gardes de captures y déclarent un CONTRAT de dispositif invariant, et les six autres fichiers du dossier n'en portent aucun, donc restent dehors : ce sont des outils de rendu, pas des gardes. Elle avait rendu « 0 refus » sur un diff qui ajoutait une capture sans la déclarer, que la CI refusait deux fois. Leurs chemins les séparent : un PNG engage la complétude et les images de doc, une page de docs/ la seconde seule, un outil Capture* le garde des mains, et un diff de prose n'en engage aucun. Population vue : 75 gardes, dont 44 de scripts/adr, 24 de scripts/methode, 3 de .github/scripts et 4 de .github/assets - le quatrième depuis #5909, check_doc_videos.py, qui juge et refuse comme ses voisins et que #5809 avait laissé dehors. Son tir Maven s'annonce et va jusqu'au contrôle des dépendances (#5994, #6000) : quand le diff engage le rapport PMD, elle écrit dès le relevé, puis juste avant de lancer, qu'une JVM part, dans quel arbre, par quelle commande et pour combien de temps, et le redit sous sa ligne de verdict - son compte rendu ne nommait que ce qu'elle ne joue pas, et une session en avait conclu qu'aucun Maven n'était parti. Ce même tir joue analyze-only@verdict-dependances, lié à la phase verify que seul build atteignait : un refus y compte parmi les refus de la porte, qui nomme la dépendance. La version du greffon se lit dans le pom.xml, et la commande, l'annonce et la lecture du rendu s'éprouvent sans JVM, par un lanceur injecté. |
gardes-java-declares.py |
une classe *Test.java qui construit un chemin vers de la prose est déclarée dans batterie.GARDES_JAVA, ou elle fait rougir. La population se dérive de l'arbre et la déclaration lui est confrontée : une liste qu'un garde confronte n'est plus une liste (ADR 5373) Et la population du CODE depuis #5884 : treize classes du paquet architecture jugeaient la structure du dépôt sans que la porte en nomme aucune, ni jouée ni imprimée, et un lot qui en cassait une l'apprenait de build huit minutes plus tard. Deux signaux la dérivent, un chemin construit vers src/, pom.xml ou les migrations, et ClassFileImporter pour ArchUnit qui n'en construit aucun. Elle est bornée au paquet : sans cette borne le signal rend 37 classes, vingt-quatre tests d'IHM citant une source sans juger la structure. Et son trou est nommé plutôt que tu : ButoirsTestFxTest ne lit rien, elle vérifie des propriétés système que le pom calibre, donc elle est déclarée à la main et une quatorzième de la même forme échapperait au garde. lus compte désormais l'union des deux populations, 17, une classe pouvant juger les deux. |
lint.yml |
scripts/adr/5340-chemins-non-declares.py |
cliquet sur les gardes qui portent un contrat sans y déclarer leurs chemins, donc lancés à chaque appel de la porte. Il borne l'imprécision du repli et la fait descendre. Son auto-test vérifie l'accord du cliquet et de la porte plutôt qu'un second parcours des contrats, qui les ferait diverger (défaut mesuré en #5175) |
lint.yml |
mesure_minutes_par_pr.py |
ce qu'une poussée paie et attend, par job - son unité est le headSha, qui déclenche les ateliers et donc qui coûte, et le relevé affiche l'écart avec le nombre de demandes distinctes plutôt que de confondre les deux (#5587) : minutes facturées (lues dans billable, arrondi compris), horloge jusqu'au dernier verdict, et taux de « sans objet », où un job ne compte muet que si toute la suite de son pas de portée est sautée : lire « une étape sautée » comptait muet un job qui avait jugé et sauté sa seule étape de publication (#5381). Il relève et ne refuse rien ; seul son auto-test tourne en CI. mesure_duree_portail.py ne peut pas le remplacer : il lit main, où toutes les portées rendent « oui », donc il afficherait zéro gain par construction (chantier #5294). Depuis #5521 il rend ses médianes PAR CLASSE, avec leur effectif : le coût est bimodal depuis le régime des portées, et un chiffre global suit le mélange de la fenêtre plutôt que le régime - 36,0 min sur une fenêtre à 82 % sans Java, 62,5 min sur une à 39 %, pour le même régime. Et il lit les fichiers de la branche et non du commit de tête, par compare et non par commits/<sha>/pulls, qui rend vide sur un commit intermédiaire : une forge muette rend « indéterminée », jamais « sans Java » |
lint.yml |
porte_du_job.py |
l'appariement d'une portée : ** traverse les /, * ne les traverse pas, et un motif sans / n'apparie que la racine. C'est lui qui décide si un job tourne, donc son dérapage ferait écrire « sans objet » à des jobs qui auraient dû juger. Un job inconnu de PORTEES fait rougir, jamais taire (chantier #5294) |
lint.yml |
clips_orphelins.py |
désigne les clips de la pré-version roulante que le dernier tournage n'a pas produits, et refuse un dossier de tournage vide : --clobber ne retire rien, donc un cas renommé laissait son clip en ligne, montrant un comportement disparu |
recette-filmee.yml (autotest : lint.yml) |
verifie_renvois_workflows.py |
chaque workflow_run vise le name: d'un workflow existant |
lint.yml |
verifie_secret_winget.py |
WINGET_TOKEN est posé, propre, et utilisable avant qu'une soumission ne parte |
winget.yml (autotest : lint.yml) |
verifie_demarrage_emballage.py |
un emballage de distribution, une fois ouvert, démarre et ne lève aucune erreur de chargement | maven.yml et release.yml (autotest : lint.yml) |
veille_plateformes.py |
la suite a été éprouvée sous Windows et macOS il y a moins de 10 jours, par un passage programmé | release.yml (autotest : lint.yml) |
resout_le_tag_de_version.py |
le tag que l'atelier de publication consomme, reprise comprise. Le premier moyen reste le tag porté par HEAD ; un repli ne sert qu'à la reprise, et il est borné par la version et non par le graphe - est candidat un tag dont la version dépasse la plus haute version publiée, et plusieurs candidats font refuser plutôt que d'en choisir un. Deux bornes plausibles ont été écartées par la mesure : « le plus haut tag sans publication » refuserait dès aujourd'hui, les 17 séquelles de ce dépôt entrant dedans ; et « les tags qui ne sont pas ancêtres de la dernière publiée » les sélectionnerait toutes les 17, leurs commits n'étant pas sur main après une réécriture d'historique. Une forge muette refuse au lieu de rendre une liste vide, parce qu'ici le vide signifierait « aucune publication », c'est-à-dire l'alerte même. Six cas, dont le cinquième tient les 17 séquelles ; trois mutations jouées, appariement annoncé d'avance, trois conformes |
release.yml (autotest : lint.yml) |
signale_une_version_sans_publication.py |
qu'aucun tag de version n'existe sans sa publication, ce que rien ne disait : qui regarde main voit un tag et un journal d'apparence normale, et il faut interroger la forge pour découvrir qu'il n'y a pas de publication. Il ne signale pas « un tag sans publication », vrai 17 fois et donc inutile, mais un tag dont la version dépasse la plus haute version publiée - la borne est celle de resout_le_tag_de_version.py, dont il réemploie le calcul au lieu de le recopier, les deux posant la même question et en tirant deux lectures. Il s'éteint de lui-même : après un train réussi aucun tag ne dépasse la dernière publiée, donc aucune liste d'exceptions à tenir. Son if porte !cancelled() et non l'état de publish, sans quoi il serait sauté exactement quand il sert (ADR 4079). Une forge muette refuse par un code distinct de l'alerte, le vide signifiant ici « aucune publication ». Huit cas, dont deux sur la décision seule ; trois mutations jouées, appariement annoncé d'avance, trois conformes |
release.yml, en fin de train (autotest : lint.yml) |
trie_les_echecs_de_plateforme.py |
ce que la suite donne sur une plateforme, TestFX à part, et si le passage est bien allé au bout : un job coupé par timeout-minutes rendait le même tableau sous le titre « toutes les classes de test », 618 sur 758, sans un échec et avec un code 0 (#4544) |
suite-sous-windows-et-macos.yml (autotest : lint.yml) |
veille_contrat_api.py |
le contrat d'API a réellement tourné il y a moins de trois semaines | api-live.yml (autotest : lint.yml) |
verifie_fraicheur_actions.py |
un épinglage cohérent peut être périmé : il date les SHA épinglés | securite-dependances.yml et winget.yml (autotest : lint.yml) |
verifie_fraicheur_npm.py |
un outillage npm figé par lockfile peut être périmé sans que rien ne le dise, Dependabot ne pouvant ouvrir aucune demande sous .github/openspec/ ni .github/release/ (#6095) : il compare chaque dépendance épinglée au registre, et date le retard depuis la première version stable manquée. Un registre muet le fait refuser en 2, il ne rend pas « à jour » |
securite-dependances.yml, le lundi ; en constat sur une demande qui touche le garde, l'atelier ou un manifeste (autotest : lint.yml) |
verifie_affichage_flatpak.py |
le Flatpak déclare ce qu'il faut pour démarrer sur un bureau Wayland | flatpak.yml (autotest : lint.yml) |
mesure_duree_portail.py |
l'allongement d'un atelier, médiane contre médiane. Il déclare la composition de ses deux fenêtres et refuse de conclure quand elles ne sont pas faites des mêmes déclencheurs (#5626) : release.yml annonçait +709 % en comparant douze poussées d'août, retirées de son bloc on: depuis, à des releases de septembre - un faux positif qui ne se serait jamais résorbé. Et il ne conclut pas sans la dispersion (#5628) : un écart plus petit que la dispersion de ses propres fenêtres est un tirage, pas une dérive - mutation-ihm.yml annonçait +59 % pour 0,42 dispersion, à corpus constant et avec la même dispersion des deux côtés. La dispersion d'une médiane est l'écart absolu médian et non l'écart-type : groupé sur les 24 il inclut le changement de régime, donc aucun des dix ateliers n'atteignait une unité, release.yml et ses +708 % compris, et un seuil là aurait rendu l'avertisseur entièrement muet. Deux ateliers cessent d'avertir, et c'est l'effet voulu. Il rend donc quatre réponses, après les trois de #5615 : une mesure, « pas encore d'historique », et « sans objet » quand l'atelier ne se déclenche pas sur main - titre-pr.yml et corps-pr.yml n'y auront jamais d'exécution, et leur annoncer « historique illisible » produisait un avertissement qu'on ne peut jamais lever. Le prédicat se dérive du bloc on: et a été confronté aux exécutions réelles des vingt ateliers, 0 désaccord. Il prend l'atelier en argument depuis #3508, et depuis #5540 il en suit deux : les mesures sont croisées, aucun atelier ne se mesurant plus lui-même |
maven.yml (il y lit lint.yml) et lint.yml (il y lit maven.yml) - il avertit, il ne bloque pas (autotest : lint.yml) |
verifie_cloture_consignee.py |
un EPIC clos sans trace de clôture. Le dépôt écrit à trois endroits que tout chantier se clôt par quatorze passes, et rien ne le vérifiait : 43 sur 64 n'en portaient aucune. La cause n'était pas l'inattention - la compétence clore-un-chantier ne mentionnait nulle part le modèle à coller, et qui la suivait à la lettre ne laissait donc aucune trace (ADR 4659). Cliquet à 65, qui ne peut que descendre : les anciennes sont assumées, une de plus rougit. Il refuse si la forge ne répond pas, et depuis #4967 si sa collecte touche le plafond, un corpus tronqué le faisant passer au lieu de rougir. Le cliquet est passé de 42 à 65 parce que #4967 a corrigé sa population : il demandait --label epic, donc 58 EPIC clos sur 154 ne lui étaient jamais soumis, dont 23 sans trace |
lint.yml - bloquant (autotest : lint.yml, hors ligne) |
verifie_specification_consignee.py |
une clôture qui n'a pas répondu à la passe 10, celle de l'archivage OpenSpec. Cliquet de déficit et non fraction de couverture : la question « combien de capacités sur combien » n'a pas de dénominateur, le dépôt en offrant cinq incompatibles. Il reconnaît la ligne à son contenu et non à son numéro, #4840 ayant renuméroté le cycle. Un EPIC clos sans AUCUNE trace ne lui appartient pas : c'est le cliquet voisin de #4659 qui monte alors. Cliquet à 2 depuis #4967, qui a corrigé sa population de la même façon : 21 EPIC clos lui échappaient, dont 20 portaient la marque, et #5414 est la trouvaille | lint.yml |
Un troisième cliquet de forge se décide avant d'être fini (#4954)
Les deux ci-dessus partagent 59 lignes : lire un cliquet dans un en-tête d'ADR, interroger la
forge en refusant si gh manque, descendre le seuil quand le dépôt en porte moins, et le banc
joue avec son injection hors ligne.
L'extraction a été écartée en connaissance de cause : une bibliothèque partagée entre deux gardes de CI crée un mode de défaillance commun, et muter une fonction partagée éprouve la bibliothèque plutôt que le garde. Leurs prémisses ont d'ailleurs divergé depuis (#4948), et la part commune est tombée de 65 % et 54 % à 55 % et 48 %.
Mais deux instances ne font pas un patron, et trois si. Si vous écrivez un troisième cliquet qui interroge la forge, la copie devient une dette qui se paie trois fois : rouvrez #4954 et tranchez avant de le finir, pas après.
| lance-test-filme.sh | un runner pilote un test filmé, et refuse de le lancer sans gestionnaire de fenêtres ; son montage refuse un film arrêté avant l'ordre d'arrêt, dont tous les clips seraient décalés (#6017) | recette-filmee.yml, que le train appelle après la publication et qui se lance aussi à la main (autotest : lint.yml, job banc-filme) |
| filtrer_bruit_cartes.py | rend leur version committée aux aperçus de carte dont seul le fond a changé | capture-vues.yml |
| compare_apercus.py | montre, sur une PR, les écrans qu'elle change : avant/après accolés, part de pixels, et le dit quand aucun ne change | capture-vues.yml (autotest : lint.yml) |
| compare_tournages.py | montre ce qui a changé entre deux tournages : présence du cas, image finale accolée, carte des différences, durée. Le chiffre trie, la carte localise ; une mesure impossible se compte au lieu de passer pour « rien n'a changé », et une comparaison sans aucun cas commun sort en 1, son index écrit (#5934). Mesure aussi les planchers par cas, toutes paires, et écrit en en-tête l'instrument qui les a pris : il refuse de comparer contre des planchers pris par un autre, ou de les compléter, en nommant les deux (#5885). Aucun rapport ne se calcule contre moins de 0,0105 %, et la mesure tient en cinquième colonne les paires écoulées depuis que chaque plancher a été approché : à dix-huit, elle nomme le cas à relire (#4309). Les clips qui ont deux fins n'ont pas de plancher, et la comparaison le dit avec leur issue : leur compte et leurs noms se lisent dans comparer-deux-tournages.md, où un test les confronte à la table SANS_PLANCHER (#5953) | comparer-tournages.yml, mesurer-les-planchers.yml (autotest : lint.yml) |
| verifie_chantier_de_l_issue.py | l'issue que la demande de fusion ferme appartient à un chantier, et son corps dit le même que la forge : rattacher demande deux gestes, et le garde ne lisait que le second jusqu'à #5210. La marque se reconnaît sur une ligne à elle seule, forme mesurée sur 35 issues sur 35 quand « en tête de corps » n'en rendait que 15 : une prose qui cite la forme n'en porte pas une. Porté du bash au Python par #5210, le cliquet du corpus shell passant de 50 à 49 - sa première descente. Son parent n'est ni absent, ni le sas des suites #4562, d'où rien ne se traite. Il ne juge que cette issue-là, jamais le dépôt entier | corps-pr.yml (autotest : lint.yml) |
| rappelle_le_critere_de_fin.py | un lot dit dans son corps comment on saura qu'il est fini. Une LOUPE : elle rend un texte à poster, ne refuse rien, et ne juge pas la qualité du critère. Elle reconnaît un lot par son parent ou par la marque « Fait partie de #N », le sas #4562 exclu. Elle parle à DEUX moments depuis #5211 : à l'ouverture de l'issue, et à l'ouverture d'une demande qui ferme le lot, où elle NOMME les lots muets. Le second existe parce que le premier ne repasse pas et que le trou n'était pas tenu par ce qu'on croyait : la phrase « la loupe hebdomadaire balaie le stock » était fausse, cette loupe ne lisant que les sous-issues OUVERTES le lundi à 6 h UTC, soit 13 % des 386 lots clos nés depuis la règle. Aucun des deux ne refuse, l'arbitrage de #4961 ayant écarté le rouge et non le signalement | critere-de-fin.yml (autotest : lint.yml) |
| concordances-du-cycle.py | les quatre concordances qui relient commandes, tableau des passes et compétences : une commande ouvre une compétence qui existe, le tableau en nomme une qui existe, la description annonce la passe que le tableau attribue, aucune compétence n'est orpheline. Il ne vérifie pas qu'une compétence dise vrai | lint.yml |
| releve-des-secrets.py | ce que les ateliers exigent du dépôt en secrets et en variables, et l'écart avec ce qu'il porte (--compare). Il RELÈVE, il ne refuse pas : son auto-test pose son propre corpus d'ateliers plutôt que de lire .github/workflows/, sinon sa réponse suivrait le dépôt au lieu de le prouver. Aucun atelier ne le lance : il se joue à la main quand on ajoute un secret | aucun (autotest : lint.yml) |
| convertit-adr-okf.py | la conversion d'une ADR du format à puces vers l'en-tête OKF. Un générateur : sans --ecrit il rend un aperçu. Son auto-test garde le défaut que sa propre javadoc nomme, une version qui n'avait retenu que la dernière tranche du corps et amputait 172 ADR sans que l'aperçu tronqué ne le montre. Aucun atelier ne le lance : la conversion est faite | aucun (autotest : lint.yml) |
| verifie_inventaires_ci.py | les trois inventaires que la CI tient sur elle-même concordent avec la réalité | lint.yml |
| verifie_noms_d_etapes.py | aucun nom d'étape ou de job ne perd de texte à l'analyse YAML (un # non cité ouvre un commentaire) | lint.yml |
| verifie_verdict_avant_fusion.py | une pull request ne se fusionne pas tant que son commit de tête n'a pas un verdict complet : ni zéro run, ni un run rapide conclu pendant que les gardes bloquants courent encore, ni un atelier dont le dernier run a été interrompu (#4581). #4560 a été fusionnée à 17:13:02Z avec zéro run sur 909aeafa8 : les sept qu'elle a fini par avoir sont nés à 17:15, deux minutes trop tard, et main en est resté rouge sur un garde bloquant. Il ne juge pas la couleur - passer outre un rouge visible reste le choix assumé par l'ADR 0041 -, seulement l'absence de couleur, qu'elle n'avait pas prévue (#4571). Un atelier annulé n'a pas de couleur : le tableau final de #4560, quatre ateliers annulés à côté de trois contrôles légers verts, le fait sortir en 1 en les nommant. Seul le dernier run de chaque atelier compte, lu par workflow_id et run_number, parce que Corps de PR et Titre de PR sont rejoués à chaque édition de la demande | à la main avant de fusionner (autotest : lint.yml) |
| scripts/adr/verifie_scripts.py | chaque détecteur ADR voit sa violation témoin et ignore la même en commentaire, et aucun détecteur n'est sans cas | lint.yml |
| scripts/adr/4359-javadoc-narratif.py | la prose de javadoc ne remonte pas au-delà de son cliquet : un bloc qui dépasse le seuil de sa nature raconte là où le contrat suffirait. Le seuil est par nature, et non plat : 15 lignes de prose pour un type (classe, record, enum, interface), 8 pour une méthode, un champ, une constante d'enum ou le reste (SEUILS, dans le script). Un bloc de type de neuf à quinze lignes n'est donc pas compté. Le cliquet compte les lignes au-delà du seuil, et les étiquettes de contrat ne comptent pas comme de la prose (ADR 4359) | lint.yml |
| scripts/adr/4359-blocs-relus.py | le registre des blocs de javadoc relus et gardés volontairement ne porte aucune entrée périmée. L'empreinte porte sur le TEXTE du bloc : une édition l'invalide, ce qui force une nouvelle lecture au lieu de la présumer. Il ne fait pas baisser le cliquet - il mémorise une revue, il ne desserre pas une dette (ADR 4359). Son verdict se lit sur une ligne qui commence par INVARIANT 4359, et non par ADR 4359 : il partage son numéro avec le cliquet de la javadoc, et rapport.py range par numéro (#6059) | lint.yml |
| scripts/adr/4617-code-mort-et-zone-de-test.py | ce que le portail trouve, un cliquet par zone et le pire des deux verdicts : la production est à zéro, ce qui se lit comme un refus (ADR 4682), la zone de test à 40 (ADR 4617). Un compteur unique laisserait une régression d'un côté se payer par un gain de l'autre. Il lit le rapport de PMD et non les sources, refuse quand ce rapport manque plutôt que de rendre zéro, et écarte les littéraux dupliqués de la seule zone de test, où les répéter est le geste juste | lint.yml |
| scripts/adr/4395-renvois-en-javadoc.py | le nombre de renvois #N portés par la javadoc de production ne descend pas. C'est un PLANCHER et non un cliquet : sa polarité est inversée, descendre est la perte. Il existe parce que la résorption de la javadoc coupe la prose où vivent ces citations, et qu'un renvoi perdu ne casse rien - il cesse d'ouvrir. Plancher à 3 111, article A30 | lint.yml |
| scripts/adr/5087-versions-hors-des-checks.py | les versions d artefact qu aucun flux declenche par une demande n exerce, confrontees a ce qu un manifeste atteste. La demande #5078 bumpait pitest-maven et ses dix-neuf checks sont passes au vert sans qu aucun n execute pitest ; trois bumps ont eu ce sort - #61, #2269, #5078 - et la question « quel check aurait rougi » n a ete posee sur le troisieme qu a cause d un rouge de runner sans rapport. La carte se DERIVE du pom.xml et des flux, elle ne se tient pas : sur neuf profils, ecj porte deux versions mais maven.yml l exerce en demande, et mutation porte les deux seules qui sont exposees, pitest.version et pitest.junit5.version. Le manifeste est scripts/methode/versions-verifiees.txt, et la valeur y EST la marque : si le pom bouge et que la ligne ne suit pas, le garde refuse - meme patron que l empreinte de relus.txt. Un INVARIANT et non un cliquet, une divergence etant fausse ou inexistante, jamais une dette qui descend. Deux pieges de derivation sont tenus par des cas plutot que par de la prudence : un releve libre des -P rend deux flux sur huit a tort - -Process et -PassThru sont des drapeaux PowerShell, multi-PR un mot compose - d ou la resolution contre les noms que le pom declare ; et trois profils sur neuf s activent par <os>, donc invisibles a cette lecture, et il les NOMME plutot que de les taire. Ce qu il ne fait PAS : tester pitest. Il refuse le SILENCE, le filet qui repond etant nocturne et refusant explicitement de juger une demande. Son verdict se lit sur une ligne qui commence par INVARIANT 5087, et une version que le manifeste ne porte pas le fait sortir en 1 (#6059) | lint.yml (cliquets ADR, ou il entre par le glob scripts/adr/[0-9]*.py), et la porte locale |
| scripts/graphify/pont_ressources.py | l'index de classe Java préfère la classe à la page X.java, et le compte des commandes CLI couvertes reste borné par le nombre de commandes | lint.yml |
| scripts/graphify/pont_doc_code.py | une citation de classe part du nœud de page de la page qui la porte, où qu'elle vive, racine comprise. La passe ne fabrique plus de nœud pour une page que la structure ne porte pas, et résorbe ceux qu'elle fabriquait : repliés sur la page quand elle est là, sortis sinon du suffixe _doc, que le moteur réserve au jumeau d'un document et qui lui faisait supprimer le nœud de page. Cinq pages de la racine, dont AGENTS.md, y avaient perdu le leur. Elle se joue comme rebuild.py la joue, et doit lui rendre la main (#5868). Son parcours se dérive des nœuds de page du graphe et non d'une liste de dossiers : une page d'openspec/ est reliée, le journal des versions et .claude/skills, copie de .agents/skills, ne le sont pas, et une page partie du disque ne se lit pas (ADR 5904) | lint.yml |
| scripts/graphify/pont_ci.py | une page de la racine qui nomme un workflow ou le pom.xml dans un span de code y est reliée. Le choix du nœud de page s'importe de la passe précédente au lieu de s'y recopier : la copie portait le même filtre à trois dossiers, et 18 citations de cinq pages n'étaient pas reliées (#5868). Il emprunte aussi le parcours dérivé du graphe (ADR 5904). Un atelier est relié au script qu'il lance, quelle que soit l'écriture du chemin : .github/, ./.github/, ./scripts/ ou scripts/. Un retrait de caractères mangeait le point de .github, et 205 invocations sur 321 se perdaient sans un mot (#5919) | lint.yml |
| scripts/graphify/rebuild.py | GRAPH_REPORT.md annonce le corpus réel, et non un corpus vide suivi d'un verdict qui le contredit ; et les libellés de communautés se reportent par recouvrement de membres, depuis une référence prise avant graphify update ., qui les renomme toutes (#5814). Sa mise à jour relit aussi la structure de toutes les pages du graphe, entre l'outil et les ponts : le moteur ne relit plus une page qui porte une couche, et ses titres dataient du jour où elle l'avait reçue. Elle dit combien d'arêtes sémantiques tombent avec un titre disparu, sans compter celles d'un titre gardé (ADR 5877). Elle refuse avant de lancer l'outil quand l'interprète n'importe pas graphify, comme le chemin du crochet de commit : lancé, l'outil réécrirait le graphe, et l'import suivant tomberait après coup (ADR 5407, clôture de #5812). Ce chemin du crochet, qui n'extrait que le code encore sur le disque, et l'ordre des passes ont leurs cas. Depuis l'ADR 5941, ce chemin relit aussi la structure des pages que le commit touche, dans la même extraction que le code, et dit les titres qu'il retire. Depuis l'ADR 5940, la reconstruction garde la partition : un nœud qui existait reste dans sa communauté, un neuf va chez la majorité de ses voisins, et les libellés se reportent par identifiant ; --repartitionne la refait, sur les deux chemins. Ces libellés partent avec le graphe à son écriture : chaque nœud porte alors en community_name celui de sa communauté, et c'est ce nom que graphify explain et graphify query affichent (#5964). Un drapeau qu'il ne connaît pas le fait refuser avant de rien toucher, au lieu d'être écarté avec les fichiers (#5966) | lint.yml |
| scripts/graphify/couche_semantique.py | les cinq parades de la fusion des lots sémantiques du graphe (ADR 5790) : un lot ne réémet pas un identifiant de structure, un nœud homonyme d'un titre se replie sur lui, le champ des membres d'une hyperarête se normalise, une mise à jour déclare l'identifiant sémantique qu'elle lâche, et une hyperarête sans identifiant en reçoit un. Il rend aussi les pages à réextraire, en comparant l'empreinte git de chaque page du périmètre à celle que la fusion a notée, et refuse au lieu de rendre une liste vide quand il n'a ni graphe ni empreinte (#5814). Son périmètre exclut nommément un registre réécrit à chaque relecture, et oublie retire la couche d'une page sortie du périmètre, sans toucher celle d'une autre (#5857). Sa consigne nomme chaque champ qu'il lit. Depuis l'ADR 5904, la fiche nomme les hyperarêtes de la page et l'audit refuse celle qu'un rendu lâche sans la déclarer, ou dont un membre n'existe nulle part. La commande fusionne elle-même se joue avec un faux moteur : elle refuse un lot qui fait tomber un identifiant de structure, refuse un graphe qui n'est pas celui de son arbre, dit ce qu'une fusion à vide perd, et retire en le déclarant un membre d'hyperarête qui ne désigne plus rien. Depuis l'ADR 5936, l'audit se joue aussi sur deux lots : il refuse un libellé que portent deux énoncés, admet l'extrémité d'arête que la fiche range sous ailleurs, et écrit une ligne pour un lot sain ; et la fusion nomme l'énoncé que le moteur a fondu dans un autre. Sa commande cherche rend les énoncés qui répondent à une question en langage courant, par leurs mots et ceux de leur justification, sans passer par le moteur : un symbole de code homonyme n'y capte rien (ADR 5939). Elle déplie œ et æ au lieu de les retirer, si bien que « noeud » trouve « nœud », et le script refuse un --nombre qui n'est pas un entier d'au moins un comme un drapeau qu'il ne connaît pas (#5966). Il ne juge aucune demande : il découpe, audite et fusionne pour qui refait la couche | lint.yml |
| scripts/adr/verifie_okf.py | chaque ADR porte un en-tête typé, se rattache à un article de la constitution, et gage le niveau de vérification qu'elle déclare : une « humaine » qui nomme un applicateur n'en est pas une, une « humaine » qui ne dit POURQUOI rien ne la tient - ni verification_note: ni loupe: - laisse le test de documentation sur une référence vide, une « certaine » ou une « probable » dont l'enforced_by ne peut pas rougir non plus, le refus nommant le niveau autant que la forme, et le corpus ne descend pas sous son plancher. Depuis #5579 il refuse aussi un verbe de relation portant une espace, un verbe étant une clé et non une phrase : les deux fait évoluer du corpus étaient illisibles par tout motif de clé, donc leurs relations échappaient au garde d'encart. Ses comparaisons de vocabulaire passent maintenant par scripts/_commun/relations.py. Depuis #5700 il refuse aussi une ligne de l'index amputée, dont le compte de cellules diffère de celui de l'en-tête du tableau : six lignes sur 329 l'étaient, dont cinq sans leur colonne « Chantier », et le contrôle d'atteignabilite ne voyait rien puisque l'ADR y était bien citée. Le compte de référence se prend sur l'en-tête \| # \| et non sur un nombre inscrit dans le garde, et un « | » échappé reste de la prose | lint.yml |
| scripts/adr/verifie_gages_joues.py | la seconde espèce de l'ADR 5483, que rien ne gardait : une fonction qui JUGE et qu'aucun harnais n'appelle. Sa voisine ci-dessus refuse un enforced_by: d'en-tête que la demande ne joue pas ; celle-ci refuse le gage de code. La population se dérive de ce que la fonction déclare - l'annotation list[tuple[str, bool]], un contrat et non un idiome (ADR 5452) - et l'appel se résout par les imports, jamais par le nom : verifie_grammaire est déclaré six fois, et chercher le nom trouverait un appelant pour les six dès qu'un seul est appelé. Mesuré au commit et non au jour, deux des six gages sont nés inertes, dont un pendant vingt-deux jours ; joué sur l'état d'avant #5590 il les nomme tous les deux. Le nom de module se dérive de la racine d'import qui couvre le fichier, et le dépôt en a deux - scripts/ et .github/scripts/ : jusqu'à #5657 il n'en connaissait qu'une, si bien qu'un gage déclaré dans .github/scripts/ se serait dit inerte tout en étant joué. Tolérance zéro, sans cliquet | lint.yml |
| scripts/methode/matrice-constitution.py | la matrice de la constitution concorde avec les en-têtes des ADR, et nomme les articles que rien ne tient : c'est la liste des gardes qui restent à écrire | lint.yml |
| scripts/methode/matrice-ergonomie.py | la matrice de l'annexe des heuristiques concorde avec les en-têtes, et nomme les heuristiques que rien ne sert | lint.yml |
| scripts/methode/mesure-registre.py | aucun connecteur lourd n'ouvre une phrase : c'est le motif d'écartement que le registre éditorial invoque pour ne pas en faire une règle, et il se retournerait en silence | lint.yml |
| scripts/mkdocs/bandeau_adr.py | le bandeau d'une page d'ADR porte statut, article, chantier et vérification, se pose sous le titre, et arrête la construction sur un article que la constitution ne déclare pas | lint.yml |
| scripts/methode/synchronise-adaptateurs.py | .claude/skills/ est une copie à jour de .agents/skills/ : une compétence corrigée à la source ne laisse pas un agent lire l'ancienne version. Il regarde dans les deux sens : ce qui manque à la copie, et ce qu'elle porte en trop. Un dossier sans source est un orphelin, que tout renommage laissait derrière lui pendant que le garde annonçait « adaptateurs à jour » ; mesuré sur les cinq renommages de #4565. En écriture il est supprimé et nommé, en --verifie il fait rougir (#4593) | lint.yml |
| scripts/methode/verifie-version-openspec.py | la version d'OpenSpec résolue par le lockfile vaut le generatedBy que les douze fichiers de l'outil déclarent, et le manifeste l'épingle exactement : un intervalle laisserait le prochain npm install déplacer la version sans qu'aucun diff du manifeste ne le montre. Il lit le lockfile plutôt que d'appeler openspec --version, parce que c'est la version du dépôt qui fait foi et non celle du poste (#4512). Il refuse par entrée de corpus et non sur le total : un chemin disparu laisse les autres rendre des fichiers, et un refus sur le total resterait vert en n'ayant lu qu'une partie de ce qu'il annonce (#4566) | lint.yml |
| scripts/methode/verifie-adoption-openspec.py | les six compétences OpenSpec adoptées par #4515 portent toujours les deux marqueurs que l'amont ne peut pas produire, langue: fr et origine:, dans les deux arbres. openspec update --force les rend à l'anglais amont : mesuré au foyer isolé, le marqueur retombe de 12/12 à 0/12 et le garde rougit, alors que la même commande sans --force ne réécrit rien et le laisse vert. Il refuse par entrée de corpus et non sur le total, pour qu'un arbre disparu ne le laisse pas vert sur la moitié de sa portée (#4516) | lint.yml |
| scripts/methode/verifie-renvois-competences.py | tout renvoi barre-oblique cité par une compétence, /realiser comme /opsx:continue, désigne une commande qui existe sous .claude/commands/, ou la compétence déclare le flux optionnel et donne le repli. C'est une autre famille que le garde des sous-commandes : celui-ci lit les commandes du client, l'autre les invocations de la ligne de commande, et rien ne reliait un /nom cité à l'existence de son fichier. Son motif a été éprouvé avant d'être cru, deux lectures fausses corrigées : les quatre /realiser <autre> que l'apostrophe fermante manquait, et /tmp qui se présentait comme une commande absente et reste exempté nominativement (#4564) | lint.yml |
| scripts/methode/verifie-controle-du-titre.py | CONTRIBUTING.md et les deux copies de la compétence clore-une-issue nomment verifie_titre_pr.py, et ce script refuse encore l'espace avant les deux-points. Le dépôt pratique deux conventions à un caractère d'écart, et c'est la plus écrite qui est fausse en PR : 62 des 100 derniers titres d'issue portent l'espace, les 100 dernières PR fusionnées sont à zéro. Le défaut n'entre pas au commit, les quatre PR rouges du 2026-08-26 partant toutes d'une branche aux sujets conformes, mais à la frappe du titre. Le garde relance le script cité sur un titre fautif et sur un titre conforme : une méthode qui nommerait une commande devenue permissive vaut moins que rien (#4598) | lint.yml |
| scripts/methode/verifie-normalisation-git.py | aucun blob texte n'est range en CRLF contre .gitattributes (#5781). Un outil qui commite sans les filtres de git range un blob en CRLF : mesure, le bump de dependabot #5749 a commite mvnw.cmd ainsi, et la demande suivante (#5778) en a porte la renormalisation complete sans l'avoir voulue, son diff cessant de dire ce qu'elle faisait. Il lit git ls-files --eol et refuse un fichier dont l'index est i/crlf ou i/mixed alors que ses attributs le disent texte ; git add --renormalize le repare. Son auto-test indexe un blob CRLF sans filtre dans un depot jetable, comme dependabot. | lint.yml |
| scripts/methode/verifie-marqueurs-de-conflit.py | aucun fichier suivi ne porte de marqueur de conflit (#4329). Le 23 août 2026, main a porté trois marqueurs dans design.css pendant plusieurs heures (#4294, retirés par #4310) : la feuille livrée était amputée à partir du premier, et seule la suite Java le voyait, dans un ordre d'exécution sur deux. Il lit les fichiers que git ls-files rend et refuse une ligne qui commence par l'une des trois formes que git écrit : sept chevrons ouvrants puis une espace, sept signes égal seuls sur la ligne, sept chevrons fermants puis une espace. Un marqueur cité en cours de ligne passe, un signe égal dans un tableau aussi, et les binaires sont écartés. Tolérance zéro, sans liste d'exemptions : l'arbre n'en porte aucun. Il compte les fichiers lus et refuse de conclure sur zéro, parce que ne rien trouver est son vert. Son auto-test indexe un fichier en conflit dans un dépôt jetable, et le garde le nomme avec ses lignes. Il rend le défaut visible plus tôt ; il n'empêche pas de fusionner une demande rouge. | lint.yml |
| scripts/methode/verifie-sous-commandes-openspec.py | les invocations citées dans les douze fichiers d'OpenSpec existent dans l'outil épinglé, à deux niveaux : un garde qui ne lirait que le premier mot déclarerait openspec new valide et laisserait passer openspec new frobnicate. Il compare à l'outil épinglé et jamais à celui du PATH, qui peut être d'une autre version. Il est vert sur le corpus d'aujourd'hui, donc tout son poids porte sur son auto-test, dont le premier cas est un témoin vert et le dernier rejoue le faux positif de l'en-tête YAML (#4514). Il refuse par entrée de corpus et non sur le total : un chemin disparu laisse les autres rendre des fichiers, et un refus sur le total resterait vert en n'ayant lu qu'une partie de ce qu'il annonce (#4566). Depuis #5774, ce qui l'empêche de juger ne voyage plus dans la liste des constats : un refus y héritait du titre « Invocations d'OpenSpec qui n'existent pas » et accusait donc le diff de ce qui tenait au poste. Il sort en 2, dans la forme déclarée que la porte lit, et nomme l'interprète du shebang quand l'outil est posé sans être utilisable | lint.yml, job outillage-release |
| scripts/qualite/appelants.py | qui appelle une methode, quand le depot en declare 188 du meme nom. Il ne juge rien et n'a pas de cliquet : il REPOND, et c'est le premier consommateur de l'index des appels que #5473 a livre et que rien n'importait. Le service est la desambiguisation : le depot porte 1 424 noms declares dans plusieurs classes sur 10 360 (1 235 sur 9 835 avant #5564), et sur preparer la question passe de 243 fichiers a lire par grep -rl a neuf porteurs appeles d'ailleurs, dont le premier voit ses dix appelants nommes. Il refuse si l'index manque, seul cas ou il sort non nul, parce qu'un index absent rendrait « aucun appelant » pour TOUTE methode, ce qui se lit comme du code mort. Ce qu'il ne dit PAS : « sans appelant externe » n'est pas du code mort mais l'etat normal d'un helper prive, et cela vaut 69 % du corpus. Aucun banc ne le mute, et la raison n'est PAS son dossier : mesure sur la branche de #5532, il entre dans la derivation brute du banc de methode, que lint.yml alimente, et c'est le filtre declare_un_contrat qui l'en retire - avec trois autres, de graphify et mkdocs. Lui donner un CONTRAT le ferait donc entrer au banc sans un mot. Ses six mutations ont ete jouees a la main, et deux d'elles ont demasque un cas qui ne pouvait pas rougir Et ses cas de LECTEUR sont joues depuis #5565 : le verifie_grammaire() de son module de _commun n'etait appele par RIEN, mesure du 2026-09-29, alors que celui d'arbre.py l'est par scripts/adr/4472-commentaire-en-corps.py. Un gage inerte au sens de l'ADR 5546, que son --auto-test joue maintenant. | lint.yml (autotest seul), et la porte locale depuis #5555, qui joue les auto-tests que les ateliers lancent sous scripts/ |
| scripts/qualite/implemente.py | qui tient ce contrat, la deuxieme des trois questions de la frontiere de #5464. Il ne juge rien et n'a pas de cliquet : il REPOND. Le service est la RESOLUTION, non le compte : un grep sur implements Contrat rate la classe qui l'obtient par sa mere, et rate les 25 contrats imbriques du depot - EcritureAtomique.Attente, TransportVigieChiro.CorpsAEnvoyer - dont la declaration ne porte pas le nom du fichier. Sur les 123 contrats, DaoGenerique en compte 30 porteurs et SelecteurFichier 35. Il refuse si l'index manque, et son refus se derive de celui de index.py par refus_de plutot que de s'y recopier : deux loupes ont partage un plafond en s'y conduisant differemment (#5567), et un cas surveille ici que les DEUX lecteurs refusent pareil sur la meme absence. Ce qu'il ne dit PAS : un contrat sans porteur n'est pas mort, et 27 des 123 sont dans ce cas. Aucun banc ne le MUTE - la porte le JOUE depuis #5555, ce qui n est pas la meme chose, et scripts/qualite reste retire de la population des trois bancs par declare_un_contrat : ses cinq mutations Python ont ete jouees a la main, comme les sept du Java Et ses cas de LECTEUR sont joues depuis #5565 : le verifie_grammaire() de son module de _commun n'etait appele par RIEN, mesure du 2026-09-29, alors que celui d'arbre.py l'est par scripts/adr/4472-commentaire-en-corps.py. Un gage inerte au sens de l'ADR 5546, que son --auto-test joue maintenant. | lint.yml (autotest seul), et la porte locale depuis #5555, qui joue les auto-tests que les ateliers lancent sous scripts/ |
| scripts/qualite/lecteurs.py | qui lit ce champ, hors de sa classe, la TROISIEME et derniere question de la frontiere de #5464. Il ne juge rien et n'a pas de cliquet : il REPOND. Le service est la RESOLUTION : un grep sur un nom de champ ne le distingue pas de la variable locale, du parametre ni de la methode homonymes, et 995 des 3 730 (au 2026-09-29) noms de champ du depot sont declares dans plusieurs classes - service dans 168, dossier dans 131, ID_USER dans 105. Sur service, grep -rn rend 3 398 lignes la ou l'outil rend 168 declarations dont 24 lues d'ailleurs. Il refuse si l'index manque. Les LECTURES seules, limite declaree et non oubli : confondre CtFieldRead et CtFieldWrite ferait passer un champ qu'un constructeur ecrit et que personne ne lit pour « utilise ailleurs », et un cas Java le tient - rien dans la prose ne rougirait. Ce qu'il ne dit PAS : un champ sans lecteur externe n'est pas mort, et c'est 92 % du corpus, 8 196 sur 8 934, le taux le plus raide des trois index. Aucun banc ne le mute. NEUF mutations Python et CINQ Java, jouees a la main, huit et cinq tuees : trois portent sur ce fichier (prefixe capture, tri inverse, muettes listees au lieu d etre comptees), trois sur le squelette partage outil_d_index.py, deux sur son lecteur champs.py, une sur charge_index. Citer les seules mutations DU FICHIER sous-estimerait l assise : un outil qui delegue son refus se mute aussi chez son delegue. Le neuvieme est un SURVIVANT declare - court-circuiter l --auto-test d un outil est indetectable de l interieur, scripts/batterie.py ne lisant que le code de sortie (lignes 703, 792, 815 et 899), et le dispositif qui l attraperait est un banc dont scripts/qualite est exclu par declare_un_contrat (mesure portee sur #5192). Une de ces mutations a demasque une concentration : le branchement --auto-test partage eteignait les TROIS outils sans etre vu, d ou son retour chez chaque outil | lint.yml (autotest seul), et la porte locale, qui joue les auto-tests que les ateliers lancent sous scripts/ |
| scripts/plateforme-de-test/faux_s3.py | le faux stockage d'objets de la plateforme de test (chantier #5640). Il ne juge rien : il SERT, dans son conteneur (faux-s3.Dockerfile), en TLS avec un certificat genere au demarrage. Un PUT garde les octets, rend ETag = MD5 et n'exige aucun Content-Type, parce que Companion envoie chaque partie d'un multipart sans ce type (#5597) ; GET /_journal rend les PUT recus. Celui de l'API amont ne peut pas servir : HTTP seul, sans ETag, il corrompt les binaires et plante sans Content-Type. Ce qu'il ne prouve PAS, la signature v2 : #5658. Son auto-test tourne en processus, sans TLS, sur sept cas ; trois mutations jouees a la main, trois tuees (premier octet jete, ETag qui n'est plus le MD5, Content-Type exige). La couche TLS s'eprouve dans l'image et par l'extension de #5663 | lint.yml (autotest seul), et la porte locale |
| scripts/plateforme-de-test/amorcer.py | materialise l'etat de depart DECLARE de la plateforme de test (etat-de-depart.json, ADR 4406). Il ne juge rien : il tourne DANS le conteneur de l'API, qui a deja pymongo, pose les index par bin/init_db.py, et frappe un jeton par utilisateur, rendu a l'appelant et jamais ecrit ailleurs que dans Mongo. Sa moitie PURE traduit la declaration en documents a identifiants et horodatages DERIVES, donc deux generations sont identiques hors jetons. Un objet se cite par sa cle, pas par nom, qui est un vrai champ de l'API. Mesure en l'ecrivant : le verrou d'un site n'est PAS exige pour y creer une participation, l'inscription validee l'est (#5662). Son auto-test joue neuf cas sans Mongo ; deux mutations jouees a la main, deux tuees (un role perdu, deux generations qui different) | lint.yml (autotest seul), et la porte locale |
| scripts/plateforme-de-test/releve_des_sautes.py | ce que la passe sur la plateforme de test a SAUTE, et pourquoi (#5748, chantier #5643). Un saut par assumeTrue sort vert, et une classe interrompue dans son @BeforeAll sort en « Tests run: 0 » : mesure sur #5760, la cible ignoree laissait les 29 sondes du contrat live sauter sans que le job rougisse. Il lit les rapports surefire du run (target/surefire-reports/TEST-*.xml, motif = texte de <skipped>), affiche les sautes par motif, et refuse tout saut, l'etat de depart portant ce que les sondes supposent (ADR 5746) ; deux diagnostics disent pourquoi quand ils le savent, un motif qui cite le jeton ou un verrou d'ecriture, et un rapport dont TOUS les tests sont sautes (une classe interrompue avant ses tests). Il refuse aussi aucun rapport lu, ou un rapport anterieur a --depuis. Joue en etape du job plateforme-de-test, meme quand Maven echoue ; son auto-test fabrique des rapports. | maven.yml (job plateforme-de-test), autotest dans lint.yml |
| scripts/plateforme-de-test/joue_l_extraction.py | ce que le serveur fait des archives d'une participation, joué par son propre code (#5970). La plateforme de test n'a pas de worker (ADR 5641) : une archive déposée y reste une archive. Il ne juge rien : il tourne DANS le conteneur de l'API, y lance extract_zipped_files_in_participation puis Participation.load_pjs, et rend un bilan en JSON, combien de sons avant et après, lesquels en double, combien de données pour l'analyse. Deux accès au stockage sont remplacés, parce qu'ils ne décident rien de ce qu'on observe et que ce conteneur ne joint pas le faux S3. ArchiveDuRepliSurLaPlateformeDeTestTest s'en sert pour établir qu'une séquence déjà en ligne revient en double quand l'archive du repli manuel la contient (ADR 5867). Son auto-test joue cinq cas sur la moitié pure, sans serveur ; trois mutations jouées à la main, trois tuées (un doublon vu seulement au troisième exemplaire, le préfixe qui ne borne plus les données, puis les fichiers) | lint.yml (autotest seul) ; le job plateforme-de-test de maven.yml le lance par son banc |
| surveille_la_demande.py | la fin des vérifications d'une demande, et rien d'autre : il ne juge pas la couleur, conclure que la demande est verte restant le travail de qui lit. Trois issues, donc trois codes - 0 conclu quelle que soit la couleur, 3 budget épuisé, 4 pas pu lire - parce que la règle « sortir en 0 quel que soit le verdict » ne distinguait pas « j'ai lu un verdict » de « je n'ai pas pu lire » : un moniteur sorti en 0 sur une coupure réseau laisse un journal qui se termine comme une surveillance conclue, vécu sur une demande qui était verte, 26 vérifications, sans qu'il l'ait jamais dit (#5621). Et une demande sans vérification encore n'est pas une panne : tant que la forge n'a pas créé les check-runs, gh pr checks --json sort en 1, n'écrit rien sur sa sortie et dit no checks reported sur l'erreur, ce que le script lit comme « rien encore » et non comme une lecture manquée, toute autre erreur restant une panne. Il rendait la main en 4 à l'ouverture, parce que cette ligne et son auto-test supposaient un [] que gh ne rend pas à ce moment-là (#5976) ; une demande qui n'aura jamais de vérification est sondée jusqu'au bout du budget, puis rendue en 3. Les deux moniteurs écrits à la main le 2026-09-30 étaient protégés de la liste vide par accident, leur garde -gt 0 visant ce cas-là | nulle part : une session le lance à l'ouverture d'une demande (autotest : lint.yml) |
| verifie_outils_absents_refusent.py | un appel à un outil externe qui lève au lieu de refuser. check=False ne couvre pas un exécutable introuvable : subprocess.run lève un FileNotFoundError avant qu'il y ait un code de sortie, et le dispositif s'arrête sur une trace au lieu d'une phrase. _forge.liste_issues promettait ce refus dans sa docstring et levait avant d'atteindre son propre test, et cette promesse a été citée - #5621 avait pris cette partition pour modèle (#5692). La protection se lit par l'arbre : un try ailleurs dans le même module ne protège rien, et chercher except OSError dans le texte ferait cette erreur. Deux formes admises - le filet qui rattrape, et la sonde shutil.which qui nomme la cause avant d'essayer, y compris extraite dans une fonction partagée. Ce garde a d'abord puni les deux : il accusait quatre sites sains, puis _forge.py APRÈS sa réparation, soit la factorisation qu'il devrait encourager. Deux régimes depuis #6058 : gh en tolérance zéro ; git, qu'il ne regardait pas et qui portait 61 appels nus, sous le cliquet descendant de l'ADR 5692 | lint.yml |
Et un analyseur les lit tous (#4108)¶
Ces gardes ne sont plus du shell : ils sont en Python depuis le chantier #5215, et le corpus que
shellcheck lit est tombé aux deux scripts que l'ADR 5188 laisse encore vivre. Le job lint les
passe à shellcheck, et ils sortent propres.
Ce nombre n'a pas de balise à lui : il EST le cliquet du corpus shell, tenu plus haut dans cette page. Lui en donner une seconde ferait deux endroits à corriger pour un seul fait.
Ce chiffre a été 42, puis 50, et chaque fois il est resté faux dans cette page pendant des jours. Le relevé ci-dessous existait déjà « pour que la prochaine dérive se voie » : il ne l'a pas vue, et la dérive était d'un facteur vingt-cinq. Une phrase qui se surveille elle-même ne surveille rien ; c'est la balise ci-dessus qui le fera, comme pour les autres comptes de cette page.
Les réglages vivent dans .shellcheckrc, à la racine, et non dans le YAML : un contributeur qui
lance shellcheck son-script.sh doit voir ce que voit la CI. Un réglage caché dans un workflow
ferait diverger les deux, et c'est le local qui mentirait, puisque c'est lui qu'on consulte avant de
pousser.
Une règle est exclue, SC2016 (« les expressions ne s'expansent pas dans des guillemets
simples »). Elle l'a été sur une mesure qui n'existe plus : 104 occurrences sur environ 130, quand le
corpus comptait 31 scripts. Sur les deux qui restent, shellcheck -i SC2016 en signale
zéro. L'exclusion ne protège donc plus rien, et son motif d'origine - une règle qui rougit cent
fois sur du code juste apprend à ne plus lire la sortie (ADR 3479) - ne
s'applique plus. Elle est conservée en attendant que le dernier script parte, et ce report est
consigné plutôt que tu.
Le seuil est -S info, et non warning. Au seuil warning, un rm -f $fichiers non quoté passe
au vert : c'est un SC2086, classé « info », et c'est exactement la remarque qui a trouvé un vrai
défaut dans clips-orphelins.sh (#4106). Un seuil qui laisse passer le défaut fondateur ne garde
rien. Le seuil est un drapeau et non un réglage parce que severity= n'est pas lu depuis
.shellcheckrc - vérifié.
La parité porte sur les réglages, pas sur la version. Mesuré en branchant ce pas : le runner
a signalé un SC2015 que shellcheck 0.11.0 ne signale plus en local. C'est donc la CI qui fait foi,
et un poste plus récent peut être plus permissif - l'inverse du sens rassurant. Vérifier
shellcheck --version avant de conclure d'un vert local.
Ce qui reste dehors, nommé plutôt que tu : deux notes de style, toutes deux des SC2129
(des redirections successives). Les deux SC2001 d'hier sont parties avec les scripts qui les
portaient. Un lancement à la main les montre ; la CI ne les impose pas.
Quatre neutralisations locales, chacune avec sa raison écrite sur place, et aucune globale :
deux SC2064/SC2046 où l'expansion immédiate et le découpage sont le remède et non le défaut
(le trap du banc de recette, le trajet du pointeur du banc de documentation), un SC2020 où le
doublon de \n est voulu, et deux SC2094 où shellcheck croit voir une écriture qui vit dans une
autre fonction. Les quatre premières auraient cassé un mécanisme qui marche si on les avait
« corrigées ».
Le modèle vient de #2947 (verifie_titre_pr.py) et il est le bon : le script se réinvoque
lui-même sur un cas connu, donc le cas de test et le chemin réel sont le même code par
construction. Les gardes qui balaient une arborescence l'appliquent en rendant leur racine
surchargeable par variable d'environnement, et en montant un bac jetable.
Deux exigences, apprises de ce qui a failli passer :
- Des contrôles négatifs. Une règle qui refuse tout est aussi inutile qu'une règle qui accepte
tout. Chaque auto-test contient des cas qui doivent rester verts - un
.fxmlhors d'un dossierview/, unCapture*sansmain, une capture que la doc ne cite pas. - Éprouver l'auto-test lui-même. En neutralisant une règle, le cas correspondant doit rougir - et lui seul. Vécu pendant #3293 : une première tentative de neutralisation n'avait rien modifié, et le vert obtenu ne prouvait rien.
Ce qui reste en shell, et où chacun descend (#5187)¶
Le dépôt vise deux langages : Java pour la production et son outillage, Python pour l'outillage
du projet. Les scripts shell se convertissent donc, et ils ne reçoivent pas de contrat : le
format --contrat est éprouvé en shell depuis #5009, mais y étendre le corpus serait investir dans
ce qui doit disparaître. Ce relevé mesure et borne ; il ne convertit rien.
Chaque chiffre est obtenu en lançant quelque chose. Aucun ne vient d'un motif sur le source, et c'est la seule façon de répondre à la question posée : ce qu'un script répond, pas ce à quoi il ressemble.
Ce relevé est daté : il a été pris le 2026-09-03, sur les cinquante fichiers que le dépôt comptait
ce jour-là. Ses chiffres ne se rejouent plus, la population ayant été presque toute convertie
depuis par les chantiers #5215 et #5235. Les scripts y sont nommés comme ils s'appellent
aujourd'hui, en .py pour ceux qui ont été convertis. Ce qui reste ne se lit pas dans ces
tableaux : le cliquet de
l'ADR 5188 en compte
2, et il les nomme.
La population¶
fichiers .sh versionnés |
50 |
vus par le find du job lint |
50 |
| écart entre les deux, dans les deux sens | 0 |
Les deux ensembles sont identiques, comparés par comm dans les deux directions. Il n'y a donc
qu'une population, pas deux qui se confondraient.
Ce que chacun répond¶
Un script est un garde s'il distingue --auto-test d'un drapeau quelconque. La question ne se
lit pas dans le source sans retomber sur la ressemblance ; elle se mesure par contraste, en
comparant sa sortie sous --auto-test à sa sortie sous --zzz-drapeau-inexistant.
| catégorie | n | ce qui la définit |
|---|---|---|
| gardes | 45 | distinguent le drapeau, et leur auto-test sort en 0, tous les 45 |
| outils | 5 | rendent la même chose avec les deux drapeaux : ils ne le voient pas |
Les cinq outils sont capture_screenshots.py, construit_appimage.py, mesure_pixels.py,
porte_sur_le_contrat_de_fichiers.py et icone/genere_icones.py.
Le contrôle négatif est construit_appimage.py : un workflow le lance, il sort en 1, et il
n'est pas compté comme garde parce qu'il rend exactement la même chose sous les deux drapeaux. Être
lancé par la CI et échouer ne fait pas un garde ; distinguer le drapeau, si.
Une mesure a dû être écartée, et c'est instructif. capture_screenshots.py paraissait distinguer
le drapeau. Le contrôle de non-déterminisme le dément : deux appels sous le MÊME drapeau diffèrent
déjà, parce que la question déclenche une compilation Maven puis un rendu JavaFX que le délai
d'attente coupe à un endroit variable. Un contraste ne conclut que sur un script déterministe, et
celui-ci ne l'est pas. Il est rangé parmi les outils.
Ce qu'il en reste, un par un¶
Un compte dit combien il reste ; il ne dit pas quoi convertir. Le dépôt nomme ses exceptions plutôt que de les compter, et une population qui doit disparaître se nomme pour la même raison.
Cette section portait la liste des cinquante, tenue à la main, et elle servait de plan de travail aux
chantiers de conversion. Chaque conversion y laissait une ligne qui annonçait encore « conversion »
pour un fichier qui n'était plus en shell (#5227). La liste est maintenant celle que rend le
cliquet, qui lit git ls-files '*.sh' :
Il nomme chaque script restant et, pour celui qui est toléré, la condition de sa tolérance. Ce que les autres sont devenus se lit dans les lots de #5215 et de #5235.
Ce que la CI atteint¶
| atteints depuis un workflow, transitivement | 49 |
| orphelins | 1 |
L'unique orphelin est icone/genere_icones.py, lancé à la main quand l'icône change.
La transitivité n'est pas un raffinement : mesurée sur les seuls workflows, la réponse était « 5 non
lancés ». Quatre d'entre eux sont en réalité appelés par un autre script ou par l'outillage Python,
et un script lancé par un garde est exécuté par la CI aussi sûrement qu'un script cité dans un run:.
Où chacun descend¶
Les cinquante disparaissent. Bash n'a pas d'état stable dans ce dépôt, et une tolérance est un délai daté, jamais une exemption (ADR 5188). La répartition ci-dessous est celle qui a été décidée le 2026-09-03, sur les cinquante : elle dit où chacun devait descendre, pas ce qui reste à faire.
| destination | n | ce qui la décide |
|---|---|---|
| conversion, sans condition | 49 | la cible des deux langages |
| conversion après une condition écrite | 1 | lance-test-filme.sh, 1 295 lignes d'orchestration, tolérées tant que le banc Java n'est pas définitivement validé. La condition est écrite pour qu'on sache la lever, et sa levée déclenche la conversion |
Le langage d'arrivée de chacun se choisit au moment de sa conversion, entre Java et Python selon ce qu'il outille. Ce n'est pas un arbitrage laissé ouvert sur le fond : rien ne reste en shell. Les cinq outils qui ne sont pas des gardes ne font pas exception ; ils n'ont simplement pas la même urgence, n'étant tenus par aucun auto-test.
Le sort de ce qui reste a été précisé depuis, par
l'ADR 5282. Elle décide que les
huit parcours filmés migrent vers le banc Java, et elle écrit que « le retrait de
filme-un-parcours.sh et la descente du cliquet » attendent que les huit soient portés. Elle est
aussi la levée de la condition qui tolérait lance-test-filme.sh : sa conversion suit la migration
des parcours.
Deux limites de ce relevé, déclarées¶
L'inventaire de la CI compte les gardes shell par une recherche textuelle.
verifie_inventaires_ci.py retire les lignes de commentaire puis cherche la chaîne --auto-test,
là où sa moitié Python fait un vrai contrôle par ast en excluant les docstrings depuis #5032.
Confronté à la mesure par contraste, il tombait juste : 45 des deux côtés, les mêmes 45 - une
mesure prise avant les conversions de #5210, #5219, #5221, #5229, #5231, #5233 et #5236, qui
avaient ramené la moitié shell à 6, contre 91 du côté Python, à la clôture du dernier de ces
lots, le 2026-09-04. Les deux moitiés ont bougé depuis. Le lot des outils, lui, n'y change rien :
aucun des quatre ne dispatchait --auto-test, donc aucun n'était compté.
La confrontation n'a pas été refaite depuis, et ce qui est déclaré ici est la fragilité de la
règle, pas la fraîcheur du chiffre : elle est fragile par construction, et c'est un risque écrit
plutôt que corrigé au passage.
Ses motifs ne balaient pas icone/. Le dossier n'y figure pas, donc un auto-test qui y
apparaîtrait serait invisible de l'inventaire. Sans conséquence aujourd'hui, genere_icones.py ne
mentionnant --auto-test nulle part. C'est la cécité à un dossier que ce même garde décrit à propos
de #4013, et elle a survécu à sa propre description.
Un rouge se classe avant de se rejouer (#4187)¶
Un rouge de CI se rejouait à l'aveugle, et le geste passait pour raisonnable : on avait tous vu la suite repartir verte sans qu'une ligne ait changé. Sur 21 jours, 55 passages sur 1 233 ont été rejoués à la main, soit 4,5 %, et personne ne savait ce que ce chiffre recouvrait.
Le relevé le dit maintenant :
| À qui le rouge appartient | Part | La conduite |
|---|---|---|
| DÉPÔT, un ou deux bancs qui vacillent | 56 % | Ne pas rejouer. Ouvrir ou nourrir l'issue du banc |
| CASCADE, annulé parce qu'une autre étape avait déjà rougi | 19 % | Ne pas rejouer. Lire l'étape qui a rougi la première |
| FORGE, artefact ou action indisponible | 11 % | Rejouer, une fois |
| RUNNER, JVM effondrée, couche graphique absente, ou cause qui la traverse | 9 % | Rejouer, une fois, et le consigner |
| INDÉTERMINÉ, aucune cause reconnue | 5 % | Lire le journal. Ne pas rejouer sans l'avoir lu |
Un rouge sur cinq seulement vaut un rejeu. Les quatre autres reviennent au tirage suivant, chez quelqu'un d'autre, sur une demande qui n'a rien à voir. C'est le coût réel du geste réflexe : il ne supprime pas le rouge, il le déplace et en efface la trace, puisque la tentative rejouée écrase le verdict de la précédente dans ce que la forge montre.
La cascade existe à deux niveaux, et un seul était modélisé. Le relevé distinguait déjà, entre tests, celui qui tombe en tête de ceux qu'il emporte. Le même phénomène joue entre jobs : 11 des 57 tentatives rouges ne portent aucun test tombé, seulement un « The operation was canceled » qui dit qu'une étape voisine avait déjà échoué. Chercher la cause dans le job annulé ne mène nulle part.
Ce qui compte est la fin du journal, pas le journal. Un premier classement lisait le texte entier
et rangeait 20 tentatives sur 20 sous « un garde a refusé », parce qu'un garde vert imprime aussi
le mot « REFUSE » en expliquant ce qu'il aurait refusé. De même, java.net.ConnectException apparaît
348 fois dans des journaux parfaitement sains : c'est une coupure réseau qu'un test provoque
exprès. Ce qui a fait échouer se lit autour de la dernière ligne d'erreur, et nulle part ailleurs.
Ce n'est pas le volume qui décide, c'est la COUCHE (#5036). Le classement tenait « runner » sur
deux signes : couche native absente, ou plus de cinquante tests tombés. Un défaut de rendu qui
n'emporte qu'un test lui échappait donc. Il lit désormais la cause profonde - celle que
Caused by: porte dans le bloc surefire - et regarde si elle traverse la couche graphique :
com.sun.glass, com.sun.prism, com.sun.javafx.tk, et le moteur de texte com.sun.javafx.text
que #4823 a fait ajouter. Cinq formulations plus séduisantes ont été réfutées sur des journaux réels ;
la plus instructive est que 61 % des piles d'une suite verte sont entièrement étrangères, si bien
qu'« une pile étrangère » ne distingue rien.
Quand un rouge de runner cesse d'en être un. Deux fois de suite sur la même semaine, avec la même signature, ce n'est plus le runner : c'est une dépendance du dépôt à quelque chose que l'image ne garantit pas. Les deux tentatives à couche graphique absente relevées sur 21 jours sont sous ce seuil, et restent donc classées runner.
Épinglage des actions et conteneurs (#2737)¶
Chaque uses: désigne un contenu, jamais un nom : une action est figée sur un SHA de commit,
un conteneur sur un digest.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: docker://ghcr.io/flathub/flatpak-external-data-checker@sha256:58cbad60…
Pourquoi, alors qu'un tag semble suffire. Un tag est déplaçable. actions/checkout@v7 peut être
repointé sur un autre commit sans que rien ne bouge chez nous : ce qui s'exécute dans nos workflows
changerait alors sans qu'aucun commit ne le dise. Un numéro plus précis n'y change rien - @v5.6.0
ressemble à une version figée, c'est un tag comme un autre.
C'est le prérequis du reste du lot : une attestation de provenance atteste d'un binaire produit par un code qu'on ne saurait pas identifier, et un SBOM décrit une construction non reproductible.
Le commentaire de version est obligatoire, et la garde le vérifie : sans lui, plus personne ne sait quelle version tourne, et une mise à jour Dependabot n'aurait rien de lisible à modifier.
Épingler ne gèle rien. Dependabot met à jour un SHA épinglé et son commentaire. On échange une mise à jour invisible contre une mise à jour qui passe par une PR.
La garde : verifie_epinglage.py,
dans le job lint. Elle refuse toute référence non figée et tout SHA sans commentaire de version.
Sans elle, la prochaine action ajoutée le serait par tag et l'épinglage se déferait en silence - la
forme même du défaut corrigé. Pour résoudre un tag :
gh api repos/<proprietaire>/<action>/git/ref/tags/<tag> --jq .object.sha
# si l'objet est de type « tag » (tag annoté), déréférencer :
gh api repos/<proprietaire>/<action>/git/tags/<sha> --jq .object.sha
La publication part à heure fixe (#2744)¶
release.yml ne se déclenche plus au push sur main mais le mercredi à 6 h UTC, plus
workflow_dispatch pour un correctif urgent.
Pourquoi : au push, le dépôt publiait 3 à 37 fois par jour - 497 tags, jusqu'à 31 dans la même
journée. Ces versions n'étaient pas vides (95 % de feat et fix sur les 120 dernières) mais
atomisées : une version = un changement, donc aucune validable par la recette (#1363) ni
descriptible. La cadence pesait par ailleurs sur une décision en aval : elle compte parmi les raisons du
déclenchement manuel de winget, sans en être la principale.
Détail et alternatives écartées : ADR 2744.
Le train ne part pas sans preuve des plateformes (#3526)¶
Le produit est livré en installeurs Windows, macOS et Linux. Jusqu'à #3526, rien dans la chaîne
n'avait jamais exécuté la suite ailleurs que sous Linux : le train publiait un .msi sur la foi d'un
vert obtenu sur un runner Ubuntu. Le premier passage sous Windows a rendu 11 échecs, dont un vrai
défaut produit (la couleur ANSI de la CLI, #3738) et un verrou de fichier que POSIX ne pouvait pas
révéler (#3693) - un dépôt qui aurait été publié tel quel.
La suite tourne donc le mardi, veille du train, et le train en fait sa condition : le job
preuve-des-plateformes interroge l'historique du workflow et refuse de publier si la dernière
preuve remonte à plus de 10 jours (un passage hebdomadaire manqué, plus la marge d'un schedule
retardé).
Seuls les passages complets comptent. Depuis #3754 un passage peut être ciblé sur quelques
classes, et l'API des runs ne dit pas quelles entrées ont été passées à un workflow_dispatch : le
compter certifierait la suite entière sur la preuve de deux classes. Cette distinction n'est pas
théorique - au moment d'écrire ces lignes, l'historique du dépôt contenait deux passages success,
dont l'un portait les onze échecs (le tri ne concluait pas encore) et l'autre ne couvrait que trois
classes. Sans le filtre, la veille aurait certifié la fraîcheur sur l'un ou l'autre.
Le workflow porte donc son périmètre dans le titre du run (run-name:), que la veille lit :
[complet] ou [ciblé]. Filtrer sur le seul déclencheur aurait été plus simple, et laissait le
train sans issue de secours : un mardi rouge sur une instabilité aurait bloqué la publication
jusqu'au mardi suivant, aucun passage manuel ne pouvant produire de preuve. Un passage complet lancé à
la main vaut donc preuve ; c'est le passage ciblé qui n'en est pas une.
Et une voie de secours, motivée par écrit (#3561)¶
Poser cette condition contredisait deux décisions, et la clôture du lot 3 l'a relevé :
- l'ADR 0041 pose la règle qu'on avait sautée - « avant de rendre un check obligatoire, inventorier
tous les chemins d'écriture vers
main[...] un chemin sans réponse est un blocage permanent ». L'inventaire tient en deux lignes : le train du mercredi, et leworkflow_dispatch. La seconde était vide ; - l'ADR 2744 décide pourtant en toutes lettres : « Pourquoi
workflow_dispatchreste : un correctif urgent n'attend pas le train ».
Ce qui a tranché n'est pas le retard - un passage complet lancé à la main coûte ~50 min - mais qu'un test instable sans rapport (#3773) aurait retenu un correctif de sécurité.
Le workflow_dispatch de release.yml porte donc une entrée raison_du_contournement, vide par
défaut. Renseignée, elle saute la garde, et la raison part dans le titre du run - donc dans
l'historique des exécutions, pas seulement dans le log d'un job - puis dans son résumé. En dessous de
20 caractères, elle est refusée : un contournement dont la trace est « x » n'en laisse pas.
Deux pièges, écrits sur place dans le workflow parce qu'ils se reproduisent :
- tester
inputs.raison_du_contournement == ''seul aurait désarmé la garde sur leschedule:inputsy est null, et une expression GitHub coule deux types différents en nombre, doncnull == ''y est vrai. La garde se serait sautée toute seule le mercredi, c'est-à-dire exactement les jours où elle sert. La condition teste d'abord le déclencheur ; needssur un job sauté saute le dépendant par défaut. Sans!cancelled()et un test explicite sur.result != 'failure', le contournement aurait empêché la publication au lieu de la permettre.
Comme ETAPE_CONTRAT un cran plus haut, la détection repose sur un nom. Si aucun run examiné
ne porte de marqueur, la veille refuse en disant que c'est elle qui est en cause - et distingue
les deux causes : des exécutions toutes antérieures à la pose du marqueur (qui se résout seule), ou un
run-name: renommé sans report.
Comme veille_contrat_api.py,
veille_plateformes.py
ne persiste rien : l'historique des exécutions est la date cherchée. Elle refuse explicitement
dans trois cas où un dispositif naïf rendrait un « 0 jour » rassurant : historique vide (la
question n'a pas été posée), aucune exécution programmée réussie (preuve absente, pas périmée),
date illisible. Son autotest tourne à chaque PR dans lint.yml.
Une différence avec la veille du contrat d'API, et elle compte : là-bas un failure prouve que
le contrat a été exercé ; ici le job de plateformes conclut, donc un failure est l'inverse d'une
preuve. Le compter rendrait la veille verte au moment précis où la suite est cassée.
Les artefacts publiés portent une attestation de provenance (#2742)¶
actions/attest-build-provenance s'applique à chaque artefact de target/dist (installeurs et
archives portables) et au SBOM, dans le job installers.
Ce que ça ajoute aux SHA-256 déjà publiés : l'empreinte prouve qu'un fichier est identique à celui publié, elle ne dit rien de qui l'a produit - et elle est publiée au même endroit que le fichier, donc sa confiance vaut celle qu'on accorde à la page du projet. L'attestation lie le binaire à un commit et à une exécution de ce workflow, et se vérifie contre Sigstore, hors de notre portée.
Elle est produite sur la sortie de build, avant tout téléversement, pour la même raison que
l'empreinte : une corruption survenue au téléversement se retrouverait sinon attestée. Les .sha256
sont exclus - attester une empreinte de trois lignes n'apprend rien.
Les deux droits ajoutés au job sont bornés : id-token: write ne sert qu'à prouver à Sigstore qui
construit, attestations: write n'écrit que dans le magasin d'attestations du dépôt. Ni l'un ni
l'autre ne touche au code, aux issues ou aux pull requests - le moindre privilège de #2739 tient.
Elle ne remplace pas la signature des installeurs (ADR 2112) : la signature parle aux systèmes d'exploitation (SmartScreen, Gatekeeper), l'attestation parle à qui veut auditer. Les deux sont complémentaires, aucune ne rend l'autre inutile.
L'inventaire des dépendances livrées, et sa surveillance (#2740)¶
Le fat-jar embarque toutes les dépendances résolues par Maven. Leur état de vulnérabilité n'était vérifié nulle part : ni nous ni personne ne pouvait affirmer l'absence de CVE connue.
Le SBOM : cyclonedx-maven-plugin produit target/sbom.json (CycloneDX 1.6) à la phase package,
et il est joint à chaque Release, à côté des SHA-256, sous le nom sbom-vX.Y.Z.json.
Il décrit ce qui est livré : portées compile et runtime, jamais test. Y mettre JUnit, AssertJ
et Mockito ferait alerter un scanner sur des paquets qu'aucun utilisateur n'exécute. 25 composants
au moment de la mise en place.
La surveillance : le workflow securite-dependances.yml reconstruit le SBOM et le scanne (grype),
à trois moments qui répondent à trois questions différentes :
| Déclenchement | La question |
|---|---|
schedule (lundi 6 h UTC) |
une vulnérabilité publiée cette nuit touche-t-elle du code que nous n'avons pas modifié ? |
pull_request sur pom.xml |
est-ce qu'on introduit une dépendance vulnérable ? |
workflow_dispatch |
vérification à la demande, avant une release |
Le seuil bloque à partir de haute, et ce choix tient à une mesure : le premier scan a rendu zéro composant vulnérable. Au moment où l'inventaire est propre, être strict ne coûte rien - et c'est le seul moment où on peut l'être sans avoir d'abord à trier une dette existante. Un seuil posé au-dessus d'un lot d'alertes déjà présentes ne bloque jamais rien.
Ce que le premier scan a trouvé, et comment il a été traité
Une seule dépendance en défaut : com.google.guava:guava 31.0.1-jre, qui n'est pas une
dépendance de ce projet - elle arrive par Guice 7. Deux avis, tous deux sur la création de
fichiers temporaires et corrigés en 32.0.0 : GHSA-5mg8-w23w-74h3 (faible) et GHSA-7g45-4rm6-3mm3
(modéré).
L'exposition réelle est nulle : le code applicatif n'importe aucune classe Guava, donc
n'appelle ni com.google.common.io.Files.createTempDir ni FileBackedOutputStream. Guava est
quand même contrainte à 33.4.8-jre dans le dependencyManagement : un inventaire qui signale ce
qu'on ne corrige pas cesse d'être lu.
Le premier grep de vérification était faux : Files.createTempDir correspond aussi au
début de Files.createTempDirectory, celui du JDK, employé par les outils de capture. Il
annonçait trois appels là où il n'y en avait aucun. La question « est-ce que ça nous concerne ? »
se pose sur les imports, pas sur la ressemblance des noms.
La seconde chaîne d'approvisionnement : celle qui construit (#3382)¶
Le SBOM répond de ce que le fat-jar embarque. Restaient les actions GitHub qui le fabriquent, le signent et le publient - un autre approvisionnement, aussi capable de vieillir, et que rien ne regardait.
verifie_epinglage.py garde leur cohérence : tout figé par SHA, aucune divergence entre deux
emplacements. C'est une propriété du dépôt, vraie indéfiniment, y compris quand l'amont a pris une
majeure d'avance. Un SHA figé reste figé.
Mesuré au 2026-08-06 : actions/attest-build-provenance était épinglée sur v3.0.0 (août 2025)
quand l'amont en était à v4.1.1 (juin 2026) - sur l'action qui signe la provenance des binaires
livrés. Huit autres actions étaient à jour. Rien n'avait rougi, et Dependabot, actif et proposant
d'autres montées la semaine même, ne l'avait jamais proposée, pour une raison qui reste inconnue :
les deux hypothèses examinées (le SHA porte deux tags ; release.yml serait ignoré) ont toutes deux
été réfutées.
D'où un second job dans securite-dependances.yml, hebdomadaire, qui mesure l'écart au lieu de
compter sur le mécanisme censé le combler :
| Écart constaté | Verdict |
|---|---|
| même version | rien à dire |
| retard dans la même majeure | avertissement, non bloquant |
| retard d'une majeure entière | rouge |
| version indéterminée après trois tentatives | rouge |
| commit épinglé 180 jours plus vieux que le HEAD amont | avertissement, non bloquant |
| commit épinglé 365 jours plus vieux que le HEAD amont | rouge |
La fraîcheur des outillages npm, sur la même règle (#6095)¶
Les deux outillages figés par lockfile, .github/release/ et .github/openspec/, n'avaient aucun
signal de retard. Dependabot porte une entrée pour le premier et ne peut ouvrir aucune demande : son
service refuse chaque soumission, 40 exécutions en échec sur 40 au 2026-10-06. Ce jour-là, deux
greffons de l'outillage de publication avaient une majeure de retard depuis juillet. Ils ont été
montés le jour même (#6099), pour que le job naisse vert.
Un troisième job du même atelier, fraicheur-des-outillages-npm, lance
verifie_fraicheur_npm.py. Sa règle est celle du tableau ci-dessus, et ses seuils sont les mêmes,
pour ne pas avoir deux gardes de fraîcheur qui jugent différemment :
| Écart constaté | Verdict |
|---|---|
| même version, ou préversion plus récente seule | rien à dire |
| retard dans la même majeure | signalé, non bloquant |
| retard d'une majeure entière | rouge |
| retard dans la même majeure depuis 365 jours | rouge |
| épinglage par intervalle, manifeste illisible | rouge |
| registre muet après trois tentatives | refus, code 2 |
Deux choses le séparent de son patron. L'âge n'est pas celui de la version épinglée mais celui du retard : les jours écoulés depuis la première version stable que l'épinglage n'a pas. Un paquet que son auteur ne republie plus est ancien et à jour. Et un registre muet ne rend pas un rouge mais un refus, parce qu'« indéterminé » n'est ni frais ni périmé.
Sur une demande qui ne touche que le garde ou son atelier, le job tourne en --constat : il écrit le
retard réel dans le récapitulatif sans faire rougir un diff qui n'y est pour rien. La demande prouve
que l'étape fonctionne ; le lundi juge. Une demande qui modifie un manifeste, elle, se juge : c'est
elle qui pose l'épinglage, et en constat une majeure de retard y passait verte jusqu'au lundi suivant
(#5290, dette 7). Le garde lit le diff par _portee, comme la portée du job.
Un tag qui ne bouge jamais rendait cette mesure aveugle (#2213)¶
Les quatre premières lignes comparent des tags. Elles ne peuvent donc rien voir quand l'amont n'en publie plus.
Mesuré le 2026-08-11 : vedantmgoyal9/winget-releaser ne porte qu'un tag v2, posé sur un commit de
novembre 2024, alors que sa branche par défaut vivait toujours (juillet 2026). Tag épinglé = tag
amont = v2 : aucun écart, verdict « à jour », et vingt et un mois de retard réel sur l'action
qui soumet nos paquets Windows. Le coût n'était pas théorique : release-notes-url y était
silencieusement ignoré, et komac sync-fork en était absent.
D'où la mesure d'âge, qui ne dépend d'aucun tag : la date du commit épinglé face à celle du HEAD amont. Elle mesure notre retard, pas le rythme de publication de l'amont - une action dormante reste à zéro jour, puisque son HEAD ne bouge pas non plus.
Les seuils sont calibrés sur une mesure, pas choisis : au moment de la pose, le pire écart du dépôt
était de 143 jours (anchore/scan-action). La garde est donc muette sur l'état sain du jour, et le
cas qui lui avait échappé (608 jours) est rouge.
Un épinglage hors tag reste licite, et c'est ce que winget-releaser exige désormais : le
commentaire dit alors l'intention (# main @ 2026-07-28). La garde distingue trois cas, parce que
confondre les deux derniers reviendrait à se rassurer :
| Le SHA ne porte aucun tag, et le commentaire… | Lecture |
|---|---|
annonce une version (# v7) |
le tag a été déplacé ou supprimé en amont : rouge |
annonce autre chose (# main @ …) |
épinglage hors tag assumé : seul l'âge juge |
| est absent | on ne peut pas trancher, donc on ne tranche pas au rassurant : rouge |
L'asymétrie est délibérée. L'amont publie pour des raisons qui ne nous regardent pas : un rouge à chaque release amont s'apprendrait à ignorer aussi vite qu'un garde muet. Une majeure de retard, elle, n'est pas du bruit de fond - c'est le cas qui a échappé à tout le monde pendant six mois.
Trois tentatives, parce que l'API bafouille. Vu en écrivant le garde : un appel qui rend la liste attendue, rejoué à l'identique, revient vide. Sans reprise, ce hoquet se lirait « version indéterminée », donc rouge. Un échec qui persiste reste rouge, et c'est voulu : un SHA qui ne porte plus aucun tag est en soi une nouvelle - le tag a été déplacé ou supprimé en amont.
L'autotest, lui, est hors ligne et tourne dans lint.yml à chaque PR : c'est le seul contrôle qui
voit ce garde entre deux lundis.
Deux compilateurs, pas un (#3366)¶
javac n'est pas la norme du langage, c'en est une mise en oeuvre. #3228 l'a coûté cher : une
lambda visant com.google.inject.Provider - une interface à méthode unique, mais non annotée
@FunctionalInterface - que javac accepte et qu'ecj refuse. Le défaut ne se manifeste pas à la
compilation Maven, mais quand l'IDE écrit ses classes en erreur dans le même target/classes, et
que le ./mvnw test suivant échoue à l'exécution, sur des tests sans rapport, avec un message qui
ne nomme jamais la cause. Une occurrence a produit 133 erreurs.
Le job second-compilateur recompile donc tout avec ecj (./mvnw -Pecj clean test-compile), sans
les tests ni la couverture : seule la compilation est rejouée, par un autre compilateur conforme.
Ce que la mesure a donné, avant de décider¶
L'issue demandait de mesurer plutôt que de croire. Sur le dépôt tel qu'il était :
| Divergences réelles trouvées | 4 |
| dont manquées par le balayage textuel de #3228 | 2 |
| Avertissements ecj | 1293 |
| Erreurs après correction | 0 |
Deux familles, et la seconde n'avait pas de nom :
- lambda visant un
Provider: deux occurrences dansCapturePassage, que le balayage textuel de #3228 n'avait pas vues. Corrigées en classe anonyme - et pas avecProviders.of, qui évaluerait dansconfigure()alors que le fournisseur n'a de sens qu'à l'injection ; - capture de générique sur
map(...).toList():Stream<Map<String, capture-of ?>>quejavacassigne àList<Map<String, ?>>et qu'ecj refuse. Deux occurrences, corrigées par un témoin de type explicite (.<Map<String, ?>>map(...)).
C'est l'argument décisif contre l'alternative étroite qui avait été envisagée, une garde textuelle sur la forme connue : le balayage textuel avait déjà tourné, et il en avait manqué deux. Une garde ne voit que ce qu'on lui a appris ; un compilateur voit ce qu'il refuse.
Les 1293 avertissements, et pourquoi on ne bloque pas dessus¶
Ce ne sont pas des défauts. Un job qui rougirait dessus serait désactivé en trois semaines, et on serait revenu au point de départ en ayant payé le trajet. Seules les erreurs bloquent.
module-info.java est exclu de cette passe, et c'est un renoncement assumé¶
Sous plexus-compiler-eclipse, ecj ne résout ni les modules automatiques (com.google.gson,
info.picocli) ni org.xerial.sqlitejdbc, et rendait six erreurs qui ne disent rien du code -
useModulePath=false n'y change rien. Le profil exclut donc module-info.java et compile sur le
classpath. javac vérifie le module à chaque build : ce second avis n'a pas à le refaire.
Le contrôle qui empêche ce job de mentir¶
Une faute dans le profil ferait retomber la compilation sur javac, et le job resterait vert en
n'ayant rien comparé. L'étape exige donc de voir deux passes Compiling with eclipse dans le
journal - une pour les sources, une pour les tests - et échoue sinon en le disant. Vérifié dans les
deux sens : 2 avec -Pecj, 0 sans.
Analyse statique de sécurité, et détection de secrets (#2741)¶
Le dépôt est public depuis #169. codeql.yml cherche ce que ni PMD ni les tests ne cherchent :
des chemins de données - une entrée qui atteint une commande, un chemin de fichier, une requête -
plutôt que des règles de style ou de structure. Sur main, sur les PR, et planifié le lundi : les
requêtes CodeQL évoluent, une base de code inchangée peut devenir signalable sans qu'un commit l'ait
touchée.
Deux choix qui méritent d'être dits :
- Le build est explicite, pas
autobuild. Ce projet a des exigences que la détection automatique ne devine pas (JDK 25, JavaFX 26 en dépendances Maven) - et unautobuildqui échoue rend une analyse vide, c'est-à-dire un vert qui ne veut rien dire. - Jeu de requêtes
security-extended: le dépôt refuse déjà les@SuppressWarningspour taire un avertissement qualité ; le même esprit veut qu'on voie d'abord tout ce qui est signalable, quitte à trier ensuite. Trier veut dire distinguer vrai positif et bruit, jamais supprimer en masse.
La détection de secrets : ce qui est actif, et ce qui ne peut pas l'être¶
| Réglage | État | Pourquoi |
|---|---|---|
secret_scanning |
✅ activé | gratuit sur dépôt public |
secret_scanning_push_protection |
✅ activé | gratuit sur dépôt public |
secret_scanning_non_provider_patterns |
❌ indisponible | GitHub Advanced Security |
secret_scanning_validity_checks |
❌ indisponible | GitHub Advanced Security |
L'API accepte d'activer les deux derniers et ne le fait pas : elle rend 200 en les laissant à
disabled. Il faut relire l'état pour s'en apercevoir - un appel qui réussit n'est pas un réglage
qui s'applique. L'organisation est au plan free, où advanced_security_enabled vaut false.
push_protection refuse un git push contenant un secret reconnu. C'est son intérêt, et c'est
aussi ce qui surprend un contributeur au mauvais moment.
Pourquoi une garde maison en plus (#2741)¶
Ce qui reste actif ne reconnaît que les motifs de fournisseurs : clés AWS, jetons GitHub, Stripe.
Or le secret que ce dépôt risque de laisser fuir est un jeton VigieChiro, lu dans
localStorage['auth-session-token'] : une chaîne opaque, sans préfixe distinctif, qu'aucun
catalogue ne connaît. Ce qui l'attraperait - les motifs personnalisés - demande GHAS.
verifie_jeton.py
cherche donc le contexte et non la forme : le nom de la clé, une affectation, et une valeur
littérale d'au moins 12 caractères. Les quatre usages légitimes du dépôt passent par construction (le
marque-place XXXX, la variable d'environnement, le secret Actions, la propriété Maven vide).
Un détecteur générique par entropie aurait été le mauvais outil ici : ce dépôt contient des empreintes SHA-256 en clair partout - manifestes de sauvegarde, fixtures de recette - et il aurait hurlé sur chacune.
La garde porte sa propre preuve : --autotest fait passer neuf lignes connues - quatre fuites,
cinq usages légitimes - par le même motif que le balayage, et la CI lance les deux modes. Sans
cela, un motif relâché passerait au vert sur un dépôt propre sans que rien ne le dise. Éprouvé :
en portant le seuil de 12 à 40 caractères, l'autotest signale les quatre fuites non détectées.
Elle lit le contenu versionné (git grep) : un fichier non suivi lui échappe. C'est le bon
périmètre pour une garde de CI - ce qui part chez tout le monde - mais ce n'est pas un filet local.
Les droits de publication sont déclarés par job (#2739)¶
Le plancher du workflow de release est en lecture seule ; chaque job déclare ce qu'il lui faut de plus.
| Job | Ce qu'il fait | Droits |
|---|---|---|
release |
tag, Release, commentaires sur les issues et PR que la version referme | contents + issues + pull-requests |
installers |
gh release upload, après compilation et jpackage sur trois systèmes |
contents |
publish |
gh release edit --draft=false |
contents |
Ce qui n'allait pas : un seul bloc permissions au niveau du workflow accordait les trois
droits en écriture aux trois jobs. La matrice d'installeurs - le job le plus exposé, qui compile et
empaquette - pouvait donc écrire des issues et des pull requests, ce dont elle n'a jamais eu l'usage.
Les trois autres workflows à droits d'écriture (adr-rapport, capture-vues, flatpak) sont
mono-job et utilisent réellement chacun des leurs : leur bloc au niveau workflow est déjà, de
fait, un bloc par job.
Et rien ne le laisse se défaire : verifie_permissions.py (#3294) refuse un plancher en écriture
dans un workflow à plusieurs jobs, où les droits seraient accordés à tous. Elle ne fige pas la
liste attendue par job - #2742 a dû ajouter id-token et attestations pour les attestations, et une
liste figée aurait rougi sur un ajout voulu, puis se serait fait élargir machinalement. Un workflow
mono-job garde son plancher : plancher et job y désignent la même chose.
Ce que cela ne fait pas : semantic-release s'exécute toujours dans un job en écriture. L'en
sortir suppose de réimplémenter en git + gh ce que font ses greffons d'écriture ; l'arbitrage,
rendu, est consigné sur #2739.
L'outillage de publication est figé, et répété à blanc (#2738)¶
semantic-release et ses greffons vivent dans
.github/release/,
avec package.json + lockfile versionnés, installés par npm ci.
Ce qui n'allait pas. Le job de publication faisait npx --yes -p semantic-release@24 … au
moment de publier, dans un job autorisé à écrire contenus, issues et PR. La résolution de versions
se faisait à chaque exécution : un greffon compromis entre deux runs se serait exécuté avec les droits
de publication sans qu'aucun diff du dépôt ne l'ait montré.
Deux configurations, et la seconde dérive de la première.
| Fichier | Greffons | Usage |
|---|---|---|
.releaserc.json (racine) |
les 5 | la publication, lancée depuis la racine |
.github/release/release.config.js |
les 2 de calcul | la répétition à blanc des PR |
La configuration d'analyse importe celle du dépôt et n'en garde que les greffons qui lisent : elle
ne recopie donc pas les parserOpts (ceux qui tolèrent « fix(ci) : sujet », espace avant les
deux-points). Une copie divergerait, et la version calculée en vérification ne serait plus celle que la
publication calculera.
La répétition à blanc, job outillage-release de lint.yml, à chaque PR : npm ci, une assertion
sur la dérivation de la configuration, puis semantic-release --dry-run. Elle existe parce que
aucune CI de PR ne traverse release.yml : il se déclenchait alors au push sur main, et depuis
2744 il part au train du mercredi. Dans les deux cas, une erreur d'installation ou de configuration¶
ne se verrait qu'à la prochaine release.
Elle ne peut pas publier : --dry-run n'écrit rien, et la configuration d'analyse n'embarque aucun
greffon d'écriture.
Ce qu'elle prouve dépend du déclencheur (#3345), et il faut le savoir avant de lire son vert :
| Déclencheur | Jusqu'où va semantic-release |
Ce que le vert dit |
|---|---|---|
pull_request |
s'arrête sur « triggered by a pull request », après avoir chargé les greffons | l'outillage s'installe, les greffons se chargent |
push sur main |
va jusqu'à l'analyse de l'historique et au calcul de version | le contrôle est réel |
Le job déclare contents: write pour lui seul, alors qu'il n'écrit rien : semantic-release
vérifie qu'il pourrait pousser un tag dès verifyConditions, y compris en --dry-run. Sous le
plancher contents: read du workflow il échouait donc sur main par EGITNOPERMISSION, sauf
quand un checkout devenu obsolète le faisait sortir plus tôt : le vert signifiait alors « contrôle
sauté ». Ce cas de sortie anticipée subsiste les jours de fusion dense ; le concurrency du workflow
le borne sans le supprimer.
Le binaire se lance depuis la racine pour publier (./.github/release/node_modules/.bin/semantic-release) :
c'est .releaserc.json qui fait alors foi. Lancé depuis .github/release/, c'est la configuration
d'analyse que cosmiconfig trouve en premier. Les deux ont été vérifiées en local, greffon par greffon.
Ce que le lockfile ne figeait pas : Node (#3264)¶
Les deux workflows qui installent cet outillage demandent node-version: "24", et non lts/*. Un
lockfile fige l'arbre ; lts/* laissait flotter le runtime qui l'exécute. Au prochain passage de
majeure LTS, le job de publication aurait changé de Node sans PR ni relecture - exactement ce que
2738 cherchait à empêcher pour les paquets, laissé ouvert pour l'interpréteur.¶
L'occasion l'a rendu concret : semantic-release@25 exige ^22.14.0 || >= 24.10.0. Avec lts/*, la
satisfaction de cette contrainte dépendait de ce que le runner avait en cache ce jour-là.
Les deux greffons d'écriture sont plus exigeants depuis leur montée (#6099) :
@semantic-release/changelog 7.0.0 et @semantic-release/git 11.0.1 demandent
^22.22.2 || >=24.15. Aucun check de demande ne les juge, la répétition à blanc ne chargeant que les
greffons de calcul : la façon de les éprouver sans publier est dans
.github/release/README.md.
L'arbre de publication et ses alertes¶
Le passage en semantic-release@25 (#3264) ramène npm audit de 18 paquets vulnérables (15
hautes) à 7 (2 hautes). Il réduit sans résoudre : les deux hautes restantes vivent dans le
npm que semantic-release embarque, et npm audit fix le dit lui-même (is a bundled dependency of
npm@… · It cannot be fixed automatically). Aucune version de semantic-release ne les corrige : il
faut que npm publie, et que semantic-release reprenne.
Un compte d'alertes Dependabot n'est pas une mesure d'exposition. GitHub auto-écarte les
avis de portée development, ce qu'est tout cet arbre : au 2026-08-04, quatre avis
(brace-expansion ×3, picomatch) l'ont été sans que le compte affiché bouge. Pour cet arbre, c'est
npm audit qui fait foi.
Le train ne commente pas les issues, et c'est le train qui l'impose¶
@semantic-release/github commente par défaut chaque issue et PR incluse dans une version. Le premier
départ du train, déclenché à la main le 2026-08-06 sur 104 commits, est tombé exactement là :
TypeError: Cannot destructure property 'repository' of '(intermediate value)' as it is undefined.
at @semantic-release/github/lib/success.js:81
La cause n'est pas un bogue de circonstance, c'est une conséquence structurelle de l'ADR 2744. Le
greffon découpe les commits en lots de 100 et construit, pour chaque lot, une requête GraphQL avec
un champ par commit, chacun demandant associatedPullRequests(first: 100) - soit 10 000 nœuds
dans une seule requête. GitHub la refuse, renvoie data: null, et le greffon déstructure repository
sur undefined.
Tant que la publication partait à chaque fusion, un lot comptait quelques commits. Depuis qu'elle part une fois par semaine, il en compte une centaine : l'échec se serait reproduit chaque mercredi, à 6 h UTC, sans personne pour le voir.
successCommentCondition: false supprime l'appel. Le commentaire perdu n'était de toute façon pas
souhaitable ici : il aurait notifié une centaine d'issues à chaque train.
Ce que cet échec laisse derrière lui est le vrai enseignement : le tag v2.184.0 avait été
créé et la Release déposée en brouillon avant l'étape qui a échoué. Le job release étant rouge,
installers et publish ont été sautés - donc ni binaires attachés, ni brouillon levé. Une
version peut donc exister à moitié. C'est ce qu'il faut regarder d'abord quand un train échoue :
gh release list avant gh run view.
Une publication peut s'arrêter en chemin : trois états, trois reprises (#5987)¶
Le train écrit quatre choses, dans cet ordre - commit du CHANGELOG.md, tag, note git, Release en
brouillon - puis deux jobs s'appuient dessus. Un échec à n'importe lequel de ces pas laisse une version
à moitié faite, et c'est un état qui ne se voit pas : main porte un tag et un journal d'apparence
normale, et il faut interroger la forge pour découvrir qu'il n'y a pas de publication.
Le premier geste, quand un train échoue
gh release list avant gh run view. Le journal dit ce qui a cassé ; la liste des
publications dit ce que la version a, et c'est elle qui décide de la reprise.
Les trois états observables¶
La note git est invisible de main, donc elle ne fabrique pas d'état distinct : son échec se lit
comme « tag posé, pas de publication ». Il reste trois états, et chacun a sa reprise.
| L'échec est tombé | La version a | Il lui manque | La reprise | Jouée ? |
|---|---|---|---|---|
| avant le tag | le commit chore(release) et l'entrée de CHANGELOG.md, sur main |
le tag, la note, la publication, les installeurs | poser le tag à la main sur le commit de version, puis relancer l'atelier | non, déduite : aucune panne connue ne s'est arrêtée là |
| après le tag, avant la publication | commit, journal, tag ; parfois la note | la publication, les installeurs | créer le brouillon à la main sur le tag, avec la section du CHANGELOG.md ; pousser la note si elle manque ; relancer l'atelier |
oui, sur v2.197.0 le 2026-10-06 : publication complète en dix-sept minutes, installeurs construits par la chaîne |
| après la publication | commit, journal, tag, note, brouillon | les installeurs attachés, et le brouillon reste brouillon | relancer l'atelier, qui téléverse sur la publication existante | non : #4083 a au contraire supprimé le brouillon, la relance butant alors sur la tête de main - obstacle levé depuis par #6023 |
Une seule de ces trois reprises a été jouée
Celle du deuxième état, et elle est vérifiable - exécution 37454826583, completed/success, 202
fichiers attachés. Les deux autres sont déduites du fonctionnement de la chaîne, et ce n'est
pas la même chose qu'un geste éprouvé.
La troisième mérite une mise en garde particulière : #4083 l'a écartée pour la 2.186.0, et non
parce qu'elle était fausse en principe. La relance butait sur la résolution du tag, qui dépendait
alors de la tête de main - main portait 46 commits de plus que le tag. Cet obstacle est levé
depuis #6023, et cette reprise est donc praticable aujourd'hui ; elle reste néanmoins non
jouée, et ce n'est pas la même chose qu'éprouvée.
Le premier état est le plus dangereux, et c'est le moins visible. Sans tag, le train suivant
recalcule la même version - il compte depuis le dernier tag, qui est celui d'avant - et
@semantic-release/changelog ajouterait une seconde entrée pour elle. Poser le tag à la main n'est
donc pas une commodité : c'est ce qui empêche le doublon. C'est la même raison qui a fait conserver
le tag v2.186.0 après l'arbitrage de #4083, et elle vaut dans les deux sens.
Ce que la reprise ne doit pas faire, et c'est tranché depuis #4083 : construire les installeurs à
la main. La chaîne reste seule productrice des artefacts et de leur attestation de provenance
(#2742). Seul l'objet « brouillon » se crée à la main, parce qu'il ne contient aucun binaire -
gh release upload exige une publication existante, et c'est tout ce que le brouillon apporte.
Les trois voies qui ne reprennent pas, et pourquoi¶
Essayées ou écartées sur les cas réels, pour éviter de les réessayer :
- Rejouer le job en échec. L'exécution est figée sur le commit d'avant la version. semantic-release y voit une branche en retard sur la forge et ne publie rien, et l'étape qui retrouve le tag ne trouve rien sur ce commit.
- Relancer l'atelier sans rien faire d'autre, au deuxième état. semantic-release n'a plus rien à
publier, le tag est bien retrouvé, mais
gh release uploadrefuse : il n'y a pas de publication où téléverser. C'est pourquoi le brouillon se crée avant de relancer. - Supprimer le tag et recommencer. Écarté par #4083 : le
CHANGELOG.mdporte déjà l'entrée, et semantic-release recréerait la même version avec une seconde entrée.
Ce dont la reprise ne dépend plus, et comment elle en a été affranchie¶
Jusqu'à #6023, l'étape qui résout le tag le cherchait par git describe --tags --exact-match HEAD.
La reprise dépendait donc de la tête de main : elle n'aboutissait que si cette tête était encore
le commit taggué. Une fusion entre l'échec et la relance, et installers comme publish étaient
sautés une seconde fois. Le 2026-10-06, main avait avancé six minutes après la relance - la reprise
a tenu de peu, et #4083 avait buté sur le même mur avec 46 commits d'écart.
La résolution vit maintenant dans .github/scripts/resout_le_tag_de_version.py, et elle garde ce
premier moyen pour le train normal. Un repli ne sert qu'à la reprise : est candidat un tag dont la
version dépasse la plus haute version publiée, et plusieurs candidats font refuser plutôt que
d'en choisir un. La borne est la version et non le graphe, pour la raison que l'encadré de la section
suivante donne. Elle est sortie du YAML pour être éprouvable : du shell dans release.yml ne se joue
qu'en publiant, et son banc porte sept cas.
Tout l'aval était déjà juste : il consomme needs.release.outputs.tag, et installers extrait
ref: <tag>. La fragilité était confinée à cette unique résolution, et c'est ce qui a rendu le
remède petit.
Trois occurrences, et un état qui persiste¶
| Version | Où elle s'est arrêtée | Ce qu'il en reste |
|---|---|---|
v2.21.0 |
pendant installers, sur desktop-file-validate absent du runner |
publication restée en brouillon |
v2.184.0 |
après le brouillon, sur le commentaire d'issues du greffon GitHub | ni binaires, ni brouillon levé |
v2.186.0 |
après le brouillon, sur une condition de job sans fonction d'état (ADR 4079) | brouillon supprimé après arbitrage (#4083) ; tag et journal conservés |
v2.197.0 |
après le tag, sur la poussée de la note git, rejetée | reprise en dix-sept minutes, publication complète par la chaîne |
Et l'état « tag sans publication » persiste au-delà de ces cas. Compté sur la forge le 2026-10-06
à 278de494a : 17 tags de version sans publication, sur 512 tags et 495 publications. Seize
forment un bloc contigu du 20 au 22 juillet 2026, de v2.26.1 à v2.35.1, qui est l'époque où la
dérive typographique empêchait semantic-release de reconnaître les sujets (#2104) ; le dix-septième est
le v2.186.0 de #4083.
Ne pas raisonner sur l'ancestralité de ces tags
Les 17 ne sont pas « derrière » la dernière version publiée : leurs commits ne sont pas sur
main du tout, 0 sur 17 étant des ancêtres de origin/main, une réécriture d'historique les
ayant orphelinés. C'est aussi ce qui réconcilie ce constat avec #4083, qui mesurait en août une
ancestralité alors vraie : une mesure de graphe sur ces tags est datée, et toute règle qui s'y
appuie rend un résultat plausible et faux. Les distinguer se fait par comparaison de versions,
pas par le graphe.
Un runner qui exécute n'est pas un runner qui pilote (#3710)¶
recette-filmee.yml répond à une seule question, et elle n'est pas celle que le verdict de Maven
donne : le runner pilote-t-il vraiment l'interface ?
Un lancement filmé tient à cinq conditions (cf. lance-test-filme.sh), dont deux dépendent de la
machine : un gestionnaire de fenêtres doit tourner sur le DISPLAY visé, et WAYLAND_DISPLAY
doit être absent.
Sans gestionnaire de fenêtres, le pointeur ne bouge pas - même pour xdotool - et pourtant
les tests s'exécutent sans erreur. Ils passent ou échouent pour de mauvaises raisons. Pire,
certains passent avec un robot mort : un test qui affirme qu'une valeur reste inchangée est
vrai si l'on ne clique nulle part.
Le témoin, qui est ce que ce workflow apporte vraiment¶
L'entrée sans_gestionnaire_de_fenetres n'installe pas le gestionnaire, et inverse le
verdict :
dans ce mode, un lancement réussi devient un échec du workflow, puisqu'il prouverait que la
vérification du pointeur ne garde rien.
Le gestionnaire choisi n'est pas neutre : openbox, et non matchbox (#3788)¶
matchbox-window-manager maximise tout ce qu'il affiche - c'est son parti pris, il est fait pour
de petits écrans. Le banc en a menti deux fois avant qu'on le voie :
- les tests de croissance de fenêtre (
Modales.suivreLaCroissance, #1534) ne pouvaient pas passer en fenêtré : une fenêtre déjà maximisée ne grandit pas. On a d'abord soupçonné le code ; - surtout, les clips de #3774 montraient la modale de connexion sur 1280 × 900, contenu tassé en haut. Or ces clips servent à faire juger des cas perceptifs par un humain : sur une mise en page qui n'est pas celle qu'on livre, qui juge, juge autre chose.
openbox honore les dimensions demandées par la fenêtre. Le banc montre alors l'application telle
qu'elle est livrée, ce qui est la condition pour qu'un regard porté sur un clip vaille quelque chose.
Ce qui fait foi n'est donc pas la présence du gestionnaire mais verifier_pointeur, qui teste le
comportement : un gestionnaire installé mais inopérant passerait un contrôle de présence, pas
celui-là.
La vidéo est conservée en artefact 14 jours : elle se revoit un temps, puis s'efface, et rien ne part dans git.
Ce que la séance écrit à côté du film : les repères (#3774)¶
Un film d'un bloc ne sert à personne pour trancher un cas : personne ne regardera trente minutes.
La séance dépose donc, à côté de la vidéo, un journal de repères
(target/recette-filmee/reperes.tsv) qui dit quand chaque cas s'est joué.
# repères de séance (#3774) : epoch_ms borne test cas
1786725329321 debut ConnexionViewModelTest.injoignable_conserve_le_jeton S1-07
1786725329581 fin ConnexionViewModelTest.injoignable_conserve_le_jeton S1-07
Les instants sont des millisecondes depuis l'époque, la même grandeur que date +%s%3N : c'est
ce qui permet au montage de les ramener à des positions dans la vidéo.
Tous les tests sont encadrés, y compris ceux qui ne citent aucun cas - leur colonne de cas est alors vide. Ce n'était pas le cas d'abord, et la première séance filmée réelle a montré pourquoi il le faut : le contrôle du montage vérifie que ce qui apparaît à l'écran tombe dans une plage connue, et un test non annoté qui ouvre une fenêtre lui semblait hors sujet. C'est l'index, et non ce journal, qui ne retient que les cas.
Deux propriétés vont ensemble, et seul le profil recette-filmee les pose :
recette.autodetection charge l'extension, recette.reperes lui dit où écrire. Un mvn test
ordinaire ne voit ni l'une ni l'autre, ne charge donc rien et n'écrit rien. CablageDesReperesTest
garde ce câblage, parce qu'il casserait en silence : une extension que le moteur n'appelle pas
produit un journal vide, et un journal vide ressemble à une séance où aucun test ne cite de cas.
C'est aussi pourquoi le journal part dans l'artefact avec le film : depuis la CI, c'est le seul moyen de constater que l'extension a bien été chargée. Le film, lui, sortirait pareil.
Le montage : un clip par test, un index par cas (#3774)¶
L'artefact contient clips/, un extrait par test cité, et son index.md, qui se lit par cas.
Un cas couvert par plusieurs tests a plusieurs lignes ; le clip, lui, est taillé sur le test,
parce que c'est ce que la JVM sait borner.
L'index ne donne aucune position dans le film livré, et c'est volontaire : ce film est écourté par luminance, si bien qu'une position calculée sur le brut y serait fausse. Le clip est le point d'entrée, pas un horodatage.
t0 se mesure, il ne se suppose pas. Le journal consigne des instants d'horloge, la vidéo se
compte depuis son début, et l'instant de l'image 0 n'est pas celui où l'on a lancé ffmpeg : il
s'initialise, et cette latence varie. On la rend sans objet en prenant l'heure au moment où l'on
demande l'arrêt, puis en retranchant la durée du fichier obtenu.
Cette mesure tient à une condition : que le film tourne encore quand on lui demande de s'arrêter.
Le film a un plafond, tiré du timeout-minutes du job qui filme, et une séance qui le dépasse laisse
un film arrêté seul : l'origine calculée part alors trop tard, et chaque clip montre un autre cas
que le sien. Le 6 octobre 2026, 90 clips ont été publiés ainsi, décalés de 158 secondes, sans
qu'aucun contrôle rougisse (#6017). Le montage refuse donc un film qui n'a pas reçu l'ordre
d'arrêt, et le dit en nommant le plafond à relever.
Le contrôle porte sur la couverture, pas sur la clarté des clips. Exiger qu'un clip soit clair
ferait rougir un test de ViewModel, qui cite des cas et n'ouvre légitimement aucune fenêtre. Ce qui
est exigé : les images où quelque chose est à l'écran doivent tomber dans les plages calculées.
Un t0 faux les fait tomber à côté sur un film où le geste est isolé. Dans une séance continue de
fenêtres, elles retombent dans les plages des cas voisins et la couverture reste haute : c'est le
refus ci-dessus qui tient ce cas-là.
Les plages sont celles de tous les tests, pas seulement des tests cités. La première séance
réelle a refusé un alignement correct pour cette raison : ConnexionModaleViewTest compte dix tests
dont trois annotés, et les sept autres ouvrent aussi des fenêtres - le contrôle jugeait hors sujet
les cinq sixièmes de ce qu'il voyait, et annonçait 16 %. C'était le même travers que celui qu'il
évitait par ailleurs : un garde qui crie sur du bon travail.
| Sur le film fabriqué de l'auto-test | Couverture |
|---|---|
| repères justes | 1,00 |
| repères décalés de 3 s | 0,00 |
| geste appartenant à un test non cité | 1,00 |
Un montage refusé ne laisse aucun clip et fait échouer le lancement, même quand les tests sont verts - c'est justement le cas où personne n'irait vérifier.
La tournage complet : tout ce qui cite un cas, en une séance (#3835)¶
Le montage taille déjà un clip par test et indexe par cas. Passer les seize classes citantes
à un même -Dtest=A,B,C rend donc, d'un coup, un index qui les couvre toutes : il n'y a rien à
fusionner, là où seize séances auraient donné seize artefacts et une comptabilité à tenir de tête.
La liste se dérive, elle ne se tient pas à la main. Un grep sur @CasDeRecette ramène deux
faux positifs sur dix-huit : l'annotation elle-même, dont la documentation contient un exemple, et
les fixtures qui imitent un test sans rien couvrir. C'est CorrespondanceRecetteTest qui dépose la
liste sous target/recette/classes-citantes.txt, parce qu'il balaie les annotations compilées et
honore @FixtureDeRecette. Une liste tenue à la main dériverait comme la prose dérivait avant #3728.
Deux refus, pour que le vide ne passe pas pour un résultat :
- la liste retirée avant d'être réécrite, en deux gestes distincts : le fichier survit d'un lancement à l'autre, si bien qu'une dérivation qui cesserait de tourner laisserait la liste d'hier en place et son garde resterait vert dessus ;
- une liste absente ou vide fait refuser la séance, plutôt que de filmer un écran noir et de rendre un index sans ligne - ce qui ressemble trait pour trait à une recette qui ne couvre rien.
À quoi elle sert : le garde de #3728 vérifie qu'un identifiant cité existe, jamais que le test fait ce que le cas décrit. Le tournage complet rend cette relecture possible en regardant, plutôt qu'en relisant seize classes.
Mais tous les cas ne s'auditent pas en regardant, et l'index le dit ligne par ligne. Un ViewModel cite des cas et n'ouvre aucune fenêtre : son clip est noir, et c'est le résultat juste. Cocher « vu » dessus serait un mensonge - un mensonge que le tournage complet aurait encouragé si elle avait proposé la même case à tout le monde.
| Colonne « Comment l'auditer » | Ce que ça veut dire |
|---|---|
| en regardant | quelque chose a paru à l'écran pendant ce test, le clip le montre |
| en lisant le test | rien n'a paru : l'audit est une lecture de code, le clip n'y ajoute rien |
La frontière est « aucune image utile », et non un seuil réglé à la main : soit quelque chose a paru, soit rien. Un nombre choisi aurait rangé un cas du mauvais côté sans qu'on le sache.
gh workflow run recette-filmee.yml # le passage normal
gh workflow run recette-filmee.yml -f sans_gestionnaire_de_fenetres=true # le témoin
Dépendances¶
Les mises à jour sont proposées par Dependabot
(.github/dependabot.yml),
mensuellement, pour maven et github-actions. Une troisième entrée vise l'outillage de
publication (npm, dans /.github/release) et n'a jamais ouvert de demande : la forge refuse
celles que Dependabot y soumet, 39 exécutions sur 39 en échec au 2026-10-06 (#6084). Les deux
outillages figés par lockfile, celui-ci et .github/openspec/, sont donc suivis par un garde et non
par Dependabot : verifie_fraicheur_npm.py, chaque lundi (voir « La fraîcheur des outillages npm »
plus haut). JavaFX (org.openjfx:*) est volontairement exclu de l'automatisation : ses
bumps ont un impact fort (rendu, Headless Platform) et se décident à la main.