Comment nous avons réduit notre temps d’attente CI de 30 minutes à 5 minutes
La CI lente est l’une de ces choses qui agacent les équipes d’ingénierie. Les ingénieurs attendent plus longtemps les retours, le changement de contexte augmente, et la tentation de regrouper les modifications grandit, ce qui ne fait qu’aggraver la situation. Nous avons passé quelques semaines à optimiser nos workflows GitHub Actions et avons réduit nos vérifications de PR de 30 minutes à 5 minutes. C’est une réduction de 83 % du temps d’attente, obtenue sans sacrifier la couverture des tests ni les garde-fous de qualité. Il n’y a pas eu de solution miracle unique. Nous avons examiné où le temps partait réellement et appliqué trois principes : des dépendances de jobs intelligentes, un caching agressif et le sharding des tests. Voici ce que nous avons appris.

La base : migrer vers Blacksmith
Nous avions déjà essayé de migrer vers Blacksmith auparavant, mais nous avions rencontré des problèmes. Avec une branche main aussi active que la nôtre, nous ne voulions pas gérer une CI instable. Cette fois, la migration s’est faite sans difficulté (#26247) : remplacer les runners buildjet-*vcpu-ubuntu-2204 par blacksmith-*vcpu-ubuntu-2404 dans 28 fichiers de workflow, échanger les actions cache et setup-node, et ajuster les allocations de vCPU. Le seul vrai problème rencontré a été le cache corrompu avec les fichiers .next (j’y reviens dans la section sur le caching).
Une fois la mise à niveau effectuée, nous avons constaté immédiatement une amélioration de vitesse x2 pour la récupération du cache. Comme yarn install pèse 1,2 Go, cela permet d’économiser environ 22 secondes sur chaque job qui doit exécuter yarn install, et c’est le cas de la plupart. Nous utilisons aussi le caching des conteneurs de Blacksmith pour les services utilisés dans de nombreux jobs (Postgres, Redis, Mailhog). Le job Initialize containers a maintenant plus de 50 % de temps gagné, passant de 20 secondes à 9 secondes.
jobs:
build:
name: Build Web App
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-build
Ce que nous avons dû corriger
Les runners Blacksmith exposent le nombre de CPU de la machine hôte plutôt que les vCPU alloués (généralement 4). Playwright, bien intentionné, lançait des workers pour correspondre à ce nombre, ce qui provoquait une contention des ressources et des échecs de tests intermittents. Il nous a simplement fallu limiter explicitement les workers avec --workers=4 pour correspondre à notre allocation réelle.
Nous avons aussi découvert qu’un parallélisme plus élevé révélait des conditions de concurrence dans notre logique de nettoyage des tests. Des tests d’intégration qui fonctionnaient parfaitement en séquentiel entraient en collision lorsqu’ils s’exécutaient en parallèle, produisant des erreurs idempotencyKey. La solution a consisté à repenser la façon dont les tests se nettoient après eux-mêmes : interroger les réservations par eventTypeId plutôt que de suivre les IDs de réservation individuels, ce qui laissait passer les réservations créées indirectement par le code testé (#26269, #26278, #26283).
Même nos tests de captures d’écran ont nécessité des ajustements (#26247). Les différents runners présentent de légères différences de rendu des polices, nous avons donc ajouté deviceScaleFactor: 1 pour un rendu cohérent et légèrement augmenté notre seuil de diff de 0.05 à 0.07 afin de tenir compte des variations de lissage sans masquer de vraies régressions.
Aller plus vite révèle souvent des couplages cachés dans votre système. Le couplage était toujours là ; vous ne pouviez simplement pas le voir à des vitesses plus faibles. Corriger ces problèmes, c’est ce qui permet des améliorations de vitesse durables.
Principe 1 : raccourcir le chemin critique grâce à la conception des dépendances
Pour nous, un gros problème était que lint bloquait des choses qu’il ne devait pas bloquer. Quand vos tests E2E prennent 7 à 10 minutes, les bloquer à cause de 2 à 3 minutes de lint et de vérifications de types ne fait qu’allonger tout le workflow. Le lint se termine bien avant les E2E, alors pourquoi ne pas les laisser tourner en parallèle ?
Désolidariser les suites de tests sans rapport
Nos tests d’intégration et notre suite E2E principale n’ont pas besoin du build API v2, nous avons donc supprimé cette dépendance (#26170). À l’inverse, les tests E2E de l’API v2 n’ont pas besoin du build web principal. Chaque suite de tests dépend désormais uniquement de ce dont elle a réellement besoin, ce qui permet un parallélisme maximal.
Regrouper les jobs de pré-vérification
Nous avions des jobs séparés pour la détection des changements de fichiers (changes) et la vérification du label E2E (check-label). Chaque job a un coût de démarrage : lancer un runner, récupérer le code, restaurer le contexte. En fusionnant ces éléments dans un seul job prepare, nous avons éliminé des surcoûts redondants (#26101). Nous sommes allés plus loin et avons déplacé nos étapes d’installation des dépendances dans le job prepare également, ce qui a permis d’économiser encore 20 secondes par workflow en supprimant une autre frontière de job (#26320).
Rendre le reporting non bloquant
Les rapports de tests E2E sont utiles, mais ils ne devraient pas se trouver sur le chemin critique. Nous avons déplacé les jobs merge-reports, publish-report et cleanup-report dans un workflow séparé déclenché par workflow_run (#26157). Désormais, les ingénieurs peuvent relancer immédiatement les jobs en échec sans attendre que la génération des rapports se termine.
name: E2E Report
on:
workflow_run:
workflows: ["PR Update"]
types:
- completed
La règle est simple : soyez souples et laissez les jobs E2E démarrer plus tôt plutôt que plus tard. Ne bloquez pas des jobs longs sur des prérequis courts sauf s’il existe une vraie dépendance. Le temps réel de votre CI est déterminé par le chemin critique, et chaque dépendance inutile allonge ce chemin.
Principe 2 : le caching, c’est roi (mais seulement s’il fonctionne)
Un caching efficace nécessite des clés qui s’invalident quand il faut, restent stables quand il faut, et un stockage de cache qui reste sain dans le temps.
Utiliser lookup-only
Notre amélioration de caching la plus spectaculaire est venue d’une simple observation : nous téléchargions environ 1,2 Go de données mises en cache alors même que nous n’avions rien à installer. Le cache existait, le hit était réussi, mais nous payions quand même le coût du téléchargement avant de découvrir que nous pouvions ignorer l’étape d’installation.
La correction a consisté à utiliser des vérifications de cache lookup-only (#26314). Nous avons des étapes dédiées qui s’exécutent avant tous les jobs pour garantir que les dépendances sont installées, donc au moment où les jobs individuels s’exécutent, nous savons que le cache existe. Avant de tenter de restaurer le cache complet, nous vérifions d’abord s’il existe sans le télécharger. Si tous les caches sont atteints, nous sautons tout le flux de restauration et d’installation. Pas de téléchargements, pas d’installations, on passe directement au travail réel. Cela seul a permis de gagner beaucoup de temps sur chaque scénario de cache hit, ce qui, avec une conception correcte des clés de cache, devrait représenter la majorité des exécutions.
- name: Check yarn cache (lookup-only)
if: ${{ inputs.skip-install-if-cache-hit == 'true' }}
uses: actions/cache/restore@v4
id: yarn-download-cache-check
with:
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
lookup-only: true
Nous avons appliqué la même logique à l’installation du navigateur Playwright (#26060). Comme nous avons une étape dédiée qui garantit que les navigateurs Playwright sont mis en cache avant l’exécution des jobs de test, nous pouvons utiliser la même approche lookup-only. Si le cache Playwright est atteint, l’installation est entièrement ignorée. Désormais, nous vérifions d’abord, puis nous décidons s’il faut seulement télécharger.
Nous avons étendu ce modèle à notre cache de base de données également (#26344). En utilisant des vérifications lookup-only, nous pouvons éviter de reconstruire le cache DB lorsqu’il existe déjà. Nous avons aussi simplifié la clé de cache en supprimant le numéro de PR et le SHA, ce qui la rend beaucoup plus réutilisable d’une exécution à l’autre.
Correction du cache
La correction du cache compte autant que sa vitesse. Nous avons découvert que notre motif glob **/node_modules/ capturait apps/web/.next/node_modules/, qui correspond à la sortie de build de Next.js contenant des liens symboliques et des fichiers générés qui changent pendant les builds. Cela conduisait à des archives de cache corrompues avec des erreurs d’extraction tar (#26268). La correction était un simple motif d’exclusion, mais il a fallu comprendre exactement ce qui était mis en cache et pourquoi pour le trouver.
Nous avons aussi mis en place un nettoyage automatique du cache (#26312). Lorsqu’une PR est fusionnée ou fermée, nous supprimons désormais ses caches de build associés. Cela garde notre stockage de cache léger et évite l’accumulation d’entrées obsolètes qui ne seraient plus jamais utilisées. Nous avons simplifié au passage notre format de clé de cache en supprimant des segments inutiles comme runner.os et node_version, qui ajoutaient de la complexité sans améliorer les taux de hit.
delete-cache-build-on-pr-close:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: blacksmith-2vcpu-ubuntu-2404
env:
CACHE_NAME: prod-build
steps:
- name: Delete cache-build cache
uses: useblacksmith/cache-delete@v1
with:
key: ${{ env.CACHE_NAME }}-${{ github.event.pull_request.head.ref }}
prefix: "true"
Turbo Remote Caching
Nous sommes passés à Next.js 16 (#26093), ce qui a réduit nos builds de plus de 6 minutes à environ 1 minute (merci à l’équipe Next.js pour ces énormes améliorations). Avec Turbo Remote Caching activé, les builds suivants mettent seulement 7 secondes à être récupérés depuis le cache, contre environ 1 minute pour un build complet.
Nous avons aussi activé Turbo Remote Caching pour nos tests E2E de l’API v2 (#26331). Auparavant, chaque shard E2E recompilait les packages de la plateforme depuis zéro. Désormais, chaque shard bénéficie du cache distant de Turbo, et nous avons optimisé la compilation TypeScript de Jest en activant isolatedModules et en désactivant les diagnostics dans la CI, ce qui accélère considérablement le temps de démarrage des tests.
Principe 3 : faire passer les tests à l’échelle avec le sharding
Quand vous avez plus de 1 100 cas de test répartis sur 82 fichiers de tests E2E, les exécuter séquentiellement revient à laisser de la performance sur la table. Le sharding des tests, qui répartit votre suite de tests sur plusieurs runners parallèles, est la manière la plus directe de réduire le temps réel pour les grandes suites de tests. Nous avions déjà le sharding en place pour notre suite E2E principale, mais nous l’avons augmenté de 4 à 8 shards (#26342), ce qui a réduit d’une minute supplémentaire le temps total de la suite E2E. Nos tests E2E de l’API v2 s’exécutaient encore en un seul job.
Nous avons découpé nos tests E2E de l’API v2 en 4 jobs parallèles à l’aide de l’option --shard intégrée à Jest (#26183). Chaque shard s’exécute indépendamment avec ses propres services Postgres et Redis, et les artefacts sont nommés de façon unique par shard. Ce qui prenait auparavant plus de 10 minutes en un seul job s’achève désormais bien plus vite grâce à une répartition du travail sur quatre runners.
- name: Run Tests
working-directory: apps/api/v2
run: |
yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }}
EXIT_CODE=$?
echo "yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }} command exit code: $EXIT_CODE"
exit $EXIT_CODE
Le sharding à grande échelle fait aussi apparaître des problèmes d’infrastructure que vous pourriez ne pas remarquer avec une exécution séquentielle. Quand plusieurs jobs essaient de remplir simultanément le même cache de base de données, vous obtenez des conditions de concurrence. Nous avons résolu cela en créant un job setup-db dédié qui s’exécute avant tous les jobs de tests E2E et d’intégration (#26171). Ce job unique remplit le cache de base de données une seule fois, et tous les jobs en aval le restaurent à partir de celui-ci. Pas de concurrence, pas de travail en double.
Nous avons aussi créé un workflow dédié pour les tests unitaires de l’API v2, en les séparant de la suite de tests principale (#26189). Cela leur permet de s’exécuter en parallèle avec les autres vérifications au lieu de se battre pour les ressources dans un job de test monolithique.
L’effet cumulatif
Aucun de ces changements pris isolément n’aurait réduit notre temps de CI de 83 %. Les gains viennent de leur combinaison.
Des dépendances intelligentes permettent aux jobs de démarrer plus tôt. Un caching efficace signifie que ces jobs passent moins de temps en configuration. Le sharding fait que l’exécution réelle des tests se déroule en parallèle. Ensemble, ils compressent le chemin critique de tous les côtés.
L’investissement se cumule avec le temps. Chaque PR reçoit désormais un retour plus rapide. Les ingénieurs restent plus longtemps dans leur flux. La tentation de regrouper les changements diminue, ce qui signifie des diffs plus petits et plus faciles à relire. La qualité du code s’améliore parce que la boucle de retour est plus serrée.
Nous continuerons d’investir dans les performances de la CI parce que les retours sont réels et mesurables. L’objectif n’est pas la vitesse pour elle-même ; c’est de permettre aux ingénieurs de livrer plus vite des logiciels de qualité. Quand la CI est rapide, aller vite et maintenir un haut niveau d’exigence avancent ensemble au lieu de se contrarier.
Pull requests référencées
Base : migration de Buildjet vers Blacksmith
- #26247 - Migrer les workflows GitHub de Buildjet vers Blacksmith
Stabilité des tests
-
#26269 - Corriger les tests e2e intermittents avec des sessions utilisateur isolées
-
#26278 - Stabiliser les tests e2e avec des sélecteurs ciblés et des plannings déterministes
-
#26283 - Améliorer l’isolation des tests pour les tests e2e de managed scheduling link
Turbo Remote Caching
-
#26093 - Passer à Next 16 (builds réduits de plus de 6 minutes à environ 1 minute)
-
#26331 - Activer Turbo Remote Caching (les builds suivants prennent 7 s)
Dépendances des jobs et optimisation des workflows
-
#26101 - Regrouper les jobs changes et check-label dans prepare
-
#26170 - Désolidariser les tests non liés à l’API v2 du build API v2
-
#26157 - Rendre les jobs de rapport E2E non bloquants en les déplaçant dans un workflow séparé
-
#26320 - Déplacer le job deps vers une étape du job prepare pour économiser environ 20 s par workflow
Utiliser lookup-only
-
#26314 - Utiliser une vérification de cache
lookup-onlypour éviter les téléchargements du job deps -
#26060 - Ignorer l’installation de Playwright en cas de cache hit
-
#26344 - Utiliser
lookup-onlypour le cache DB et simplifier la clé de cache
Correction du cache
-
#26268 - Exclure .next/node_modules du cache yarn pour éviter la corruption
-
#26312 - Supprimer les entrées de cache-build à la fermeture d’une PR
Sharding des tests et infrastructure
-
#26342 - Augmenter les shards E2E principaux de 4 à 8 (économie de 1 minute)
-
#26183 - Découper les tests E2E de l’API v2 en 4 jobs parallèles
-
#26171 - Ajouter un job setup-db dédié pour éliminer la condition de concurrence du cache
-
#26189 - Créer un nouveau workflow pour les tests unitaires de l’API v2
