Leçon 7 — Décider et automatiser

La porte de qualité en CI

9 min de lecture

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 :

CodeCommandeSensEffet sur la PR
0compareporte franchiefusion autorisée
1compareporte bloquée (seuil, non-infériorité, erreurs, budget)fusion bloquée, rapport à lire
2compare, tout sous-commandecomparaison impossible (jeux différents, fichier invalide)fusion bloquée, problème de configuration
3runbudget 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.

files/ci/github-actions.yml (extrait)
- 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/base

Les 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=1

La 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) :

Porte bloquée sur la PR du prompt court : non-infériorité de la priorité et du juge en échec (2026-10-15)

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.

Un cas du diff : T25, la version au prompt court invente un délai de livraison

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 ?

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 ?

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 }} avec restore-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. run sort en code 3 et l'étape échoue avant la porte : le résumé de PR est vide. Le rapport HTML, téléversé avec if: 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. Augmentez concurrence dans 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 echo d'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-agent

Dans 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 $rc

Les 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_target et je sais pourquoi.
  • J'ai rendu la tâche d'évaluation obligatoire dans la protection de branche.
Suivant