Le contrôle avant pull request qui tient lieu d'équipe
Sans second ingénieur pour relire un diff, chaque pull request passe des scanners, une revue par un modèle qui bloque sur la sécurité, et un reçu signé.
Personne d'autre ne relit mes pull requests avant leur merge. Dans une équipe, un relecteur repère la vérification d'autorisation subtilement fausse, demande si une nouvelle dépendance est sûre et refuse d'approuver une branche qui n'a jamais fait tourner ses tests. Seul, chacune de ces choses doit être quelque chose qui s'exécute, échoue bruyamment et ne peut pas être sautée par accident. Le 2026-08-28, trois changements ont donné au contrôle sa forme actuelle : des scanners qui demandent si le repo est sûr, une revue par un modèle qui bloque sur tout ce qu'elle classe en sécurité, et un contrôle local qui signe un reçu sans lequel la pull request ne peut pas s'ouvrir.
Demander si le repo est sûr#
Le contrôle savait bien demander si le code était correct, grâce aux formateurs, aux linters, aux vérificateurs de types et aux suites de tests, mais n'avait aucun moyen de demander si le repo était sûr. Rien ne cherchait un secret commité, rien ne comparait un lockfile à une base d'avis de sécurité, rien ne regardait une image de base, et rien n'auditait les workflows de CI, qui est pourtant l'endroit où le repo garde une clé d'application et deux déclencheurs qui tournent avec des droits d'écriture.
La plupart des nouveaux scanners sont des hooks pre-commit, parce que la CI exécute ces mêmes hooks sur tous les fichiers. Un hook est donc un contrôle deux fois : au commit contre le diff indexé, et en CI contre tout l'arbre, sans nouveau job. Le scanner de secrets est l'exception qui a besoin explicitement des deux moitiés, puisque son hook lit le diff indexé et qu'en CI rien n'est indexé : il n'y inspecterait rien et passerait. Le lancer sur tout l'arbre avant de l'activer a montré qu'il aurait échoué sur l'arbre auquel on l'ajoutait : six résultats, tous des données de test et des clés de signature jetables de la CI, désormais autorisés nommément, alors qu'un jeton planté est toujours détecté.
J'ai adopté Semgrep à l'inverse de la manière habituelle. Ruff couvre déjà les lints de sécurité Python, Semgrep ne sait pas analyser un composant Vue monofichier, et l'analyse de propagation entre fichiers est payante : les packs du registre auraient donc surtout signalé à nouveau ce que j'avais déjà. Ce que Semgrep sait faire et que rien d'autre ne fait ici, c'est exprimer un invariant de cette base de code sous forme de motif, dans les deux langages. Chacune des règles locales décrit une erreur réellement commise ici une fois, comme construire une URL destinée au navigateur à partir de l'en-tête Host de la requête. Les packs du registre tournent toujours, en signalement seul et sur les fichiers modifiés uniquement, et une règle qui y fait ses preuves est recopiée dans le jeu local, où elle devient bloquante.
Auditer les workflows a d'abord demandé de les assainir. Les actions tierces sont épinglées sur un commit, celles de GitHub restent sur des tags : ça fait neuf épinglages au lieu de quarante-quatre, parce qu'un tag est un pointeur modifiable et qu'une action tierce exécute ce que son auteur a poussé en dernier, dans un job qui détient le jeton du repo. Chaque workflow a reçu des permissions en lecture seule par défaut, et sept jobs qui n'avaient aucun timeout en ont reçu un, puisque sans lui un job garde son runner jusqu'à six heures. Dependabot est arrivé dans le même changement et pas après, parce que des épinglages ne restent sûrs que tant que quelque chose les fait avancer.
Avant que quoi que ce soit ne bloque, j'ai mesuré. Le scanner de dépendances ne trouvait rien sur 2230 paquets et les deux Dockerfiles étaient propres. Les trois images de base portaient des avis de gravité haute corrigeables, sur lesquels aucune pull request ne pouvait agir, puisque leurs tags bougent et que le remède consiste à ce que l'amont les reconstruise : cette étape signale donc sans bloquer.
Une revue par un modèle qui bloque là où un faux négatif coûte cher#
Chaque pull request reçoit une revue de Claude, exécutée en CI selon une grille écrite en huit catégories : logique métier, sécurité, performance, paiements, authentification, tests, maintenabilité et rédaction. Une seconde passe, plus poussée, tourne sur un modèle plus grand dès que le changement touche au code de sécurité ou d'authentification. Les constats arrivent dans un seul commentaire sur la pull request, et en ligne là où une ligne peut les porter.
Le contrôle qui lit ces constats bloquait uniquement sur la gravité. Les deux grilles demandent au relecteur de préférer un avertissement en cas de doute, ce qui est juste pour la plupart des catégories et exactement à l'envers pour celle où un défaut manqué coûte cher : une vraie faiblesse d'autorisation dont le relecteur n'était pas tout à fait sûr aurait été mergée. Un constat bloque maintenant quand sa gravité est une erreur, quand sa catégorie est la sécurité ou l'authentification à n'importe quelle gravité, ou quand la passe approfondie l'a produit. Les grilles ont changé en même temps, parce que c'est la moitié qui porte le poids. Classer un constat en sécurité est désormais la décision de bloquer, et une idée de durcissement ou une préférence structurelle va en maintenabilité, qui reste consultative.
Le même changement a corrigé le seul vrai défaut de l'ancienne règle. Quand les deux passes signalaient le même constat, l'enregistrement dédoublonné gardait la passe arrivée en premier, et l'exécution incrémentale suivante recalculait le blocage à partir de cet enregistrement. Un constat de la passe approfondie cessait donc de bloquer une exécution après avoir été trouvé, ce qui ressemble en tout point à une correction. L'enregistrement préfère maintenant la passe bloquante.
Relire en local avant la CI#
La même grille tourne sur ma machine avant qu'une branche soit poussée, et il m'a fallu trois mauvais jours pour apprendre à m'en servir. À la mi-août, une branche portant quatre fonctionnalités est montée à soixante-cinq commits en trois jours de revue, correction, commit et nouvelle revue. En remontant chaque ligne modifiée par un commit de correction jusqu'au commit qui l'avait écrite, douze corrections sur vingt-quatre corrigeaient les corrections de revue précédentes de cette même branche, et aucune des sept dernières ne touchait du code plus ancien que la branche. La boucle ne trouvait plus de bugs.
La cause : la revue de la CI se souvenait de ce qu'elle avait déjà relu, la revue locale non. Chaque passe locale relisait toute la branche, et sur nonante fichiers et quelques, c'est un échantillon et pas un balayage, une poignée différente à chaque fois. La revue locale tient maintenant un registre du dernier commit relu, et revient à une revue complète au moindre doute, comme un rebase ou une base qui a bougé. Sa première passe sur une branche se répartit sur un relecteur par fichier, avec pour mandat de lister chaque défaut plutôt que de s'arrêter au premier. La première exécution de ce type a rendu 154 constats, dont dix erreurs, en une seule passe, là où les passes sur toute la branche en rendaient environ cinq. La règle d'arrêt est écrite elle aussi : les erreurs bloquent, les avertissements jamais, et la revue est terminée quand une passe sur les derniers changements n'ajoute aucune nouvelle erreur.
Un contrôle rapide, et un reçu qui l'atteste#
Avant d'ouvrir une pull request, le contrôle local exécutait tout : les suites complètes de l'API et du frontend, un build de production et quelque trois cents tests de bout en bout. Ça faisait des dizaines de minutes d'attente pour une réponse que la CI calcule de toute façon, puisque chaque suite y tourne sur chaque pull request. Ce que la passe locale est seule à faire, ce sont les vérifications déterministes rapides, les tests que le changement peut réellement atteindre, et la revue qui les précède.
Le contrôle rapide, c'est ça. Le choix des tests est un jugement inscrit dans les instructions plutôt qu'un script, parce que le script qui les déduisait des chemins modifiés avait été supprimé pour avoir été un endroit où un vrai test pouvait être sauté par accident. Le contrôle refuse une exécution qui ne sélectionne rien, sinon la commande la plus facile à taper serait le contrôle le plus faible. Un changement qui n'atteint vraiment aucun test le dit avec une option explicite, et cette affirmation figure sur le reçu, où un lecteur peut la voir.
Le reçu est lié au commit qu'il atteste, et il enregistre quel contrôle a tourné et sur quoi. Un hook refuse d'ouvrir une pull request sans reçu valide pour le commit courant, donc sauter le contrôle ne peut pas passer inaperçu. Deux détails de l'outillage ont compté. La variable qui désigne le contrôle interne est figée pour que ni l'environnement ni la ligne de commande ne puissent la rediriger, faute de quoi une exécution complète aurait pu être tamponnée comme la rapide. Et l'option "fichiers modifiés" du lanceur de tests s'est révélée ne pas être un raccourci : avec la configuration de ce repo, elle sélectionnait les 358 fichiers de spécifications du frontend pour un changement qui ne touchait aucun code frontend.
Ce qui reste humain#
La rédaction des commentaires et des documents passe par son propre contrôle déterministe, pour les caractères, le temps des verbes et la longueur, et la revue juge le reste : ce qui est redondant, périmé ou écrit dans le mauvais registre. Rien de tout ça ne décide si un changement valait la peine d'être fait. Ce jugement reste le mien, et le rôle du contrôle est de s'assurer que tout le reste a été demandé avant que je le rende.