La porte de qualité en CI
Le harness existe, il est bon, et personne ne le lance. Le développeur pressé fusionne le vendredi soir, l'évaluation tournera « lundi ». Un harness qui dépend de la discipline de chacun finit consulté après l'incident. La seule évaluation qui protège est celle qui s'exécute sur chaque pull request et dont le code de sortie empêche la fusion.
Le mécanisme
Le workflow (github-actions.yml) a deux tâches. La première, gratuite, tourne sur toute PR. La seconde appelle le modèle et ne tourne que si les secrets sont disponibles.
sequenceDiagram participant PR as "Pull request" participant T as "tâche tests (sans clé)" participant E as "tâche evaluation" participant C as "actions/cache : .evalh-cache" participant G as "Gemini API ou Vertex AI" PR->>T: "make test + dataset-hash --check" T-->>E: "vert" E->>C: "restaurer le cache le plus récent" E->>E: "worktree de la branche cible, jeu de données de la PR" E->>G: "référence : seulement les cas absents du cache" E->>G: "candidat : cas dont modèle, prompt ou code ont changé" E->>E: "compare + porte : code 0, 1 ou 2" E-->>PR: "résumé dans la PR + rapport HTML en artefact" E->>C: "sauvegarder le cache"
Les codes de sortie sont le contrat avec la CI :
| Code | Commande | Sens | Effet sur la PR |
|---|---|---|---|
| 0 | compare | porte franchie | fusion autorisée |
| 1 | compare | porte bloquée (seuil, non-infériorité, erreurs, budget) | fusion bloquée, rapport à lire |
| 2 | compare, tout sous-commande | comparaison impossible (jeux différents, fichier invalide) | fusion bloquée, problème de configuration |
| 3 | run | budget atteint, cas non exécutés | étape en échec avant la porte |
Pour que la porte bloque réellement, déclarez la tâche evaluation comme vérification obligatoire dans la règle de protection de la branche principale (Settings > Branches > Require status checks).
La mise en œuvre
Trois choix du workflow méritent d'être compris avant d'être copiés.
La référence est recalculée, pas stockée. On extrait la branche cible dans un git worktree et on l'exécute avec son propre code et son propre prompt. Une référence stockée (un summary.json versionné) se périme dès que le jeu de données ou le harness change. Grâce au cache partagé, la recalculer coûte zéro tant que rien n'a bougé sur la branche cible.
- name: Référence (branche cible de la PR)
run: |
BASE=${{ github.event.pull_request.base.sha || 'origin/main' }}
git worktree add ../base "$BASE"
cp -r data/. ../base/data/ # jeu de données de la PR : même empreinte des deux côtés
cd ../base
$GITHUB_WORKSPACE/.venv/bin/python -m evalh run --suite suites/triage.yaml --out $GITHUB_WORKSPACE/runs/baseLes PR venues de forks n'appellent pas le modèle. GitHub ne leur transmet pas les secrets ; la condition head.repo.full_name == github.repository évite une étape rouge incompréhensible, et la tâche tests tourne quand même. N'utilisez pas pull_request_target pour contourner cette limite : ce déclencheur exécute le code de la PR avec les secrets du dépôt.
Une seule évaluation par branche à la fois. concurrency: cancel-in-progress annule l'exécution précédente à chaque nouveau commit : on ne paie que le dernier état.
Pour Vertex AI, le fichier contient une variante commentée : authentification par fédération d'identité (google-github-actions/auth), sans clé stockée, avec un compte de service limité à roles/aiplatform.user.
Résultat obtenu : la PR « prompt court » (2026-10-16, Vertex AI, extrait)
La simulation reproduit les étapes du workflow sur un poste : une copie du dépôt dont suites/triage.yaml pointe vers app/prompts/triage_v2_court.txt (la PR), le dépôt d'origine comme branche cible, un EVALH_CACHE commun. Le workflow lui-même est validé par actionlint (aucune erreur).
--- Référence
triage · gemini-3.8-flash · 30 cas · {'ok': 30, 'erreur': 0, 'budget': 0} · dépensé 0.0000 USD · cache 120/120 · 0.0 s
--- Candidat
triage · gemini-3.8-flash · 30 cas · {'ok': 30, 'erreur': 0, 'budget': 0} · dépensé 0.2566 USD · cache 30/120 · 174.2 s
--- Calibration
juge gemini-3.8-flash · 22 réponses annotées · accord brut 95% · kappa de Cohen 0.90 · 0.0000 USD
--- Porte
métrique A B B-A IC95 p_holm
priorite 0.867 0.833 -0.033 [-0.233, +0.167] 1.000
montant 1.000 0.967 -0.033 [-0.100, +0.000] 1.000
similarite 0.904 0.865 -0.039 [-0.055, -0.024] 0.002
juge_reponse 1.000 0.667 -0.333 [-0.500, -0.167] 0.014
cas qui changent : 20 / 30
ÉCHEC non-infériorité priorite B-A -0.033, IC95 [-0.233, +0.167], marge -0.1
ÉCHEC seuil juge_reponse 0.667 pour un minimum de 0.8
ÉCHEC non-infériorité juge_reponse B-A -0.333, IC95 [-0.500, -0.167], marge -0.1
OK calibration du juge kappa=0.90 : juge fiable, ses métriques bloquent
PORTE BLOQUÉE
exit=1La référence et la calibration sortent du cache, à 0 USD. Le candidat coûte 0,2566 USD, dont 0,07 pour l'application. Le prompt court économise des jetons d'entrée, et le coût de l'application augmente quand même, de 2,00 à 2,33 USD pour 1 000 tickets : davantage de jetons de sortie, réflexion comprise (les réponses sont 10 % plus longues en mots). Et un tiers des réponses deviennent inacceptables, régression significative même après la correction de Holm (p 0,014). La veille, sur l'API Gemini avec gemini-3.5-flash-lite, la même PR avait été bloquée pour la même raison (−0,233) :
Le diff du rapport donne la lecture métier : le prompt court a supprimé la politique de remboursement, et le modèle promet ce que la politique interdit.
La porte bloque une PR qui ne touche que le README. Le rapport indique « Comparaison refusée : jeux de données différents ». Quelle est l'explication la plus probable ?
Le code 2 vient de compare quand les empreintes de jeu diffèrent. Le workflow recopie data/ de la PR dans le worktree de la référence pour éviter exactement ce cas ; s'il a été retiré, ou si la PR est très en retard sur main, les deux côtés n'évaluent plus les mêmes cas. Un changement de modèle modifierait les scores, pas l'empreinte. Un cache corrompu produirait une erreur de lecture JSON, pas ce message.
Après trois semaines, l'équipe se plaint : la porte bloque une PR sur cinq pour la priorité, alors que la catégorie, le juge et le coût sont bons. Quelle réponse est la plus saine ?
Une porte qui bloque sans raison est rapidement contournée, mais la désactiver supprime la protection sur toutes les métriques. La cause usuelle d'une métrique instable est un jeu trop petit ou des étiquettes discutables (leçon 1.2) ; les deux se corrigent. Retirer la métrique est défendable seulement si le métier dit que la priorité n'a pas d'importance, ce qui est rarement le cas pour un service client.
Ce qui casse en production
- Coût multiplié par les commits. Sans cache partagé, chaque push repaie la référence et le candidat, juge compris. La clé
evalh-${{ github.sha }}avecrestore-keys: evalh-reprend le cache le plus récent ; les PR lisent le cache de la branche principale, mais GitHub isole les caches écrits par les PR entre elles. - Variance de la cible. Le lendemain, la même référence non mise en cache aurait donné d'autres sorties (leçon 2.1). Le cache stabilise la référence entre deux commits d'une même PR ; videz-le volontairement (nouvelle
key) quand le fournisseur change de version de modèle. - Budget dépassé en CI.
runsort en code 3 et l'étape échoue avant la porte : le résumé de PR est vide. Le rapport HTML, téléversé avecif: always(), montre quels cas ont été exécutés. - Évaluation qui dépasse le délai. 174 s pour 30 cas avec juge sur Vertex ; 600 cas dépasseraient
timeout-minutes: 30. Augmentezconcurrencedans la limite du quota (leçon 2.1), ou séparez un jeu rapide (PR) d'un jeu complet (planifié). - Secret exposé dans les journaux. Le workflow ne fait jamais
echod'une variable d'environnement et le harness n'écrit pas la clé. Préférez la fédération d'identité : aucun secret à faire fuiter.
La règle de décision
Mettez la porte en CI dès que plus d'une personne modifie le prompt ou le modèle. Rendez-la obligatoire sur la branche principale, recalculez la référence au lieu de la stocker, partagez le cache, et ne la contournez jamais par continue-on-error. Si une PR doit passer malgré la porte (correctif urgent), faites-le par une dérogation explicite et tracée (un label relu par un second développeur), pas en désactivant l'étape. Surveillez le coût par PR et le taux de blocage : au-delà d'un blocage sur cinq sans régression réelle, revoyez le jeu ou les marges.
Pratique
Exercice. Ajoutez au workflow l'évaluation de la suite agent (trajectoires) sans multiplier les étapes : référence, candidat, porte. Quels chemins doivent déclencher le workflow ?
Voir la solution
Dans l'étape de référence, après la commande existante :
$GITHUB_WORKSPACE/.venv/bin/python -m evalh run --suite suites/agent.yaml --out $GITHUB_WORKSPACE/runs/base-agentDans l'étape du candidat : .venv/bin/python -m evalh run --suite suites/agent.yaml --out runs/head-agent. Dans l'étape de la porte, deux comparaisons dont les codes se combinent :
set -o pipefail
rc=0
.venv/bin/python -m evalh compare runs/base runs/head --suite suites/triage.yaml --html runs/rapport.html | tee runs/porte.txt || rc=1
.venv/bin/python -m evalh compare runs/base-agent runs/head-agent --suite suites/agent.yaml --html runs/rapport-agent.html | tee -a runs/porte.txt || rc=1
exit $rcLes chemins déclencheurs existants (app/**, suites/**, data/**, evalh/**) couvrent déjà le prompt de l'agent et ses outils. || rc=1 évite que la première porte bloquée empêche la seconde de s'exécuter : le rapport de PR montre les deux.
- Je sais ce que signifie chaque code de sortie et ce qu'il bloque.
- Je recalcule la référence sur la branche cible avec le jeu de données de la PR.
- Je partage le cache entre référence, candidat et exécutions successives.
- Je n'utilise pas
pull_request_targetet je sais pourquoi. - J'ai rendu la tâche d'évaluation obligatoire dans la protection de branche.