Solutions

Entreprise

Cal.ai

Tarification

Solutions

Entreprise

Cal.ai

Tarification

Par

Keith Williams

1 janv. 2026

Comment nous avons réduit notre temps de CI de 30 minutes à 5 minutes

Comment nous avons réduit notre temps de CI de 30 minutes à 5 minutes

Comment nous avons réduit notre temps de CI de 30 minutes à 5 minutes

Un CI lent est l'une de ces choses qui fait grincer les rouages de l'équipe d'ingénierie. Les ingénieurs attendent plus longtemps pour des retours, le changement de contexte augmente, et la tentation de regrouper les modifications croît, ce qui ne fait qu'empirer les choses. 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, réalisée sans sacrifier la couverture des tests ni les portes de qualité.

Il n'y avait pas de solution magique. Nous avons examiné où le temps était réellement passé et appliqué trois principes : dépendances de travaux intelligentes, mise en cache agressive, et sharding de tests. Voici ce que nous avons appris.

La Fondation : Migration vers Blacksmith

Nous avions en fait essayé de migrer vers Blacksmith auparavant mais nous avons rencontré des problèmes. Avec une branche main aussi active que la nôtre, nous ne voulions pas faire face à l'instabilité CI. Cette fois-ci, la migration a été simple (#26247) : remplacer les runners buildjet-*vcpu-ubuntu-2204 par des runners blacksmith-*vcpu-ubuntu-2404 dans 28 fichiers de workflow, remplacer les actions cache et setup-node, et ajuster les allocations de vCPU. Le seul vrai problème que nous avons rencontré était le problème de cache corrompu avec les fichiers .next (plus d'informations à ce sujet dans la section sur le caching).

Une fois que nous avons mis à niveau, nous avons remarqué une amélioration immédiate de la vitesse de 2x pour tirer le cache. Avec yarn install à 1,2 Go, cela fait gagner environ 22 secondes sur chaque job qui doit procéder à l'installation de yarn, ce qui est la plupart d'entre eux. Nous exploitons également la mise en cache de conteneurs de Blacksmith pour les services utilisés dans de nombreux jobs (Postgres, Redis, Mailhog). Le job Initialize containers est maintenant réduit de plus de 50 %, 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

jobs:
  build:
    name: Build Web App
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/yarn-install
      - uses

jobs:
  build:
    name: Build Web App
    runs-on: blacksmith-4vcpu-ubuntu-2404
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/yarn-install
      - uses

Ce que nous devions corriger

Les runners de Blacksmith exposent le nombre de CPU de la machine hôte plutôt que les vCPUs alloués (généralement 4). Playwright, étant utile, aurait généré des workers pour correspondre, provoquant une contention des ressources et des échecs de tests intermittents. Nous avions simplement besoin de limiter explicitement les workers à --workers=4 pour correspondre à notre allocation réelle.

Nous avons également découvert qu'un plus grand parallélisme exposait des conditions de course dans notre logique de nettoyage des tests. Les tests d'intégration qui fonctionnaient bien de manière séquentielle se heurtaient lorsqu'ils étaient exécutés simultanément, produisant des erreurs idempotencyKey. La solution a nécessité de repenser la manière dont les tests se nettoient : interroger les réservations par eventTypeId plutôt que de suivre des IDs de réservation individuels, ce qui manquait les réservations créées indirectement par le code sous test (#26269, #26278, #26283).

Même nos tests de capture d'écran nécessitaient des ajustements (#26247). Différents runners ont des différences subtiles dans le rendu des polices, donc nous avons ajouté deviceScaleFactor: 1 pour un rendu cohérent et légèrement augmenté notre seuil de différence de 0.05 à 0.07 pour tenir compte des variations de l'antialiasing sans masquer les régressions réelles.

Avancer plus vite expose souvent des couplages cachés dans votre système. Le couplage était toujours là ; vous ne pouviez tout simplement pas le voir à des vitesses plus faibles. Corriger ces problèmes est ce qui permet des améliorations de vitesse durables.

Principe 1 : Raccourcir le chemin critique avec la conception des dépendances

Pour nous, un grand problème était que le lint bloquait des choses qu'il ne devrait pas bloquer. Lorsque vos tests E2E prennent 7-10 minutes, les bloquer sur 2-3 minutes de vérifications de lint et de type rend simplement l'ensemble du workflow plus long. Le lint s'achèvera longtemps avant que l'E2E ne le fasse, alors pourquoi ne pas les laisser s'exécuter en parallèle ?

Découpler les suites de tests non liées

Nos tests d'intégration et la suite principale E2E n'ont pas besoin de la construction de l'API v2, donc nous avons supprimé cette dépendance (#26170). À l'inverse, les tests E2E de l'API v2 n'ont pas besoin de la construction principale du web. Chaque suite de tests dépend maintenant uniquement de ce qu'elle nécessite réellement, permettant un maximum de parallélisme.

Consolider 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 des étiquettes E2E (check-label). Chaque job a des frais de démarrage : démarrer un runner, vérifier le code, restaurer le contexte. En regroupant ces étapes dans un seul job prepare, nous avons éliminé les frais redondants (#26101). Nous sommes allés plus loin et avons également déplacé nos étapes d'installation de dépendances dans le job prepare, économisant encore 20 secondes par workflow en éliminant une autre frontière de job (#26320).

Rendre les rapports non bloquants

Les rapports de tests E2E sont précieux, mais ils ne devraient pas rester 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 échoués sans attendre que la génération de rapports soit terminée.

name: E2E Report

on:
  workflow_run:
    workflows: ["PR Update"]
    types

name: E2E Report

on:
  workflow_run:
    workflows: ["PR Update"]
    types

name: E2E Report

on:
  workflow_run:
    workflows: ["PR Update"]
    types

La règle est simple : être flexible à propos de laisser les jobs E2E commencer à s'exécuter plus tôt plutôt que tard. Ne bloquez pas les jobs longs sur des prérequis de courte durée à moins qu'il n'y ait une véritable dépendance. Le temps wall-clock de votre CI est déterminé par le chemin critique, et chaque dépendance inutile étend ce chemin.

Principe 2 : Le caching est roi (mais seulement s'il fonctionne)

Un caching efficace nécessite des clés qui invalident quand elles le doivent, restent stables quand elles le doivent, et un magasin de cache qui reste sain au fil du temps.

Utiliser lookup-only

Notre amélioration de caching la plus marquante est venue d'une simple observation : nous téléchargions environ 1,2 Go de données mises en cache même lorsque nous n'avions pas besoin d'installer quoi que ce soit. Le cache existait, le hit était réussi, mais nous payions toujours le coût de téléchargement avant de découvrir que nous pouvions sauter l'étape d'installation.

La solution était d'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 s'assurer 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 télécharger. Si tous les caches sont valides, nous sautons tout le flux de restauration et d'installation. Pas de téléchargements, pas d'installations, il suffit de passer au travail réel. Cela a économisé un temps significatif sur chaque scénario de hit de cache, ce qui, avec une bonne conception de la clé de cache, devrait être 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
- 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
- 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 tests, nous pouvons utiliser la même approche lookup-only. Si le cache Playwright est valide, sautez complètement l'installation. Maintenant, nous vérifions d'abord, puis décidons s'il faut 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 sauter la reconstruction du cache de la base de données lorsqu'il existe déjà. Nous avons également simplifié la clé de cache en supprimant le numéro PR et le SHA, la rendant beaucoup plus réutilisable à travers les exécutions.

Correction de cache

La correction du cache est tout aussi importante que la vitesse du cache. Nous avons découvert que notre motif glob **/node_modules/ capturait apps/web/.next/node_modules/, qui est la sortie de construction de Next.js contenant des symlinks et des fichiers générés qui changent pendant les constructions. Cela a conduit à des archives de cache corrompues avec des erreurs d'extraction tar (#26268). La solution était un simple motif d'exclusion, mais le trouver nécessitait de comprendre exactement ce qui était mis en cache et pourquoi.

Nous avons également mis en œuvre un nettoyage automatique du cache (#26312). Lorsqu'une PR est fusionnée ou fermée, nous supprimons maintenant les caches de construction associés. Cela garde notre magasin de cache léger et empêche l'accumulation d'entrées obsolètes qui ne seraient plus jamais utilisées. Nous avons simplifié notre format de clé de cache dans le processus, 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"
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"
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"

Mise en cache Turbo à distance

Nous avons mis à niveau vers Next.js 16 (#26093), ce qui a réduit nos constructions de 6+ minutes à ~1 minute (merci à l'équipe Next.js pour ces énormes améliorations). Avec le cache à distance Turbo activé, les constructions suivantes prennent seulement 7 secondes pour être récupérées du cache comparé aux ~1 minute pour une construction complète.

Nous avons également activé le cache à distance Turbo pour nos tests E2E de l'API v2 (#26331). Auparavant, chaque shard E2E reconstruisait les packages de la plateforme depuis zéro. Maintenant, chaque shard bénéficie du cache à distance de Turbo, et nous avons optimisé la compilation TypeScript de Jest en activant isolatedModules et en désactivant les diagnostics dans CI, ce qui accélère considérablement le temps de démarrage des tests.

Principe 3 : Échelle des tests avec le sharding

Lorsque vous avez plus de 1 100 cas de test répartis sur 82 fichiers de test E2E, les exécuter séquentiellement laisse des performances inexploitées. Le sharding des tests, qui divise votre suite de tests sur plusieurs runners parallèles, est le moyen le plus direct de réduire le temps wall-clock pour les grandes suites de tests. Nous avions déjà mis en place un sharding pour notre suite principale E2E, mais nous l'avons augmenté de 4 à 8 shards (#26342), ce qui a réduit le temps total de la suite E2E d'une minute supplémentaire. Nos tests E2E de l'API v2 s'exécutaient encore comme un seul job.

Nous avons shardé nos tests E2E de l'API v2 en 4 jobs parallèles en utilisant l'option intégrée --shard de Jest (#26183). Chaque shard s'exécute indépendamment avec ses propres services Postgres et Redis, et les artefacts sont nommés de manière unique par shard. Ce qui prenait auparavant plus de 10 minutes en tant que job unique se complète maintenant beaucoup plus rapidement avec le travail réparti 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
- 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
- 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 à l'échelle met également en évidence des problèmes d'infrastructure que vous pourriez ne pas remarquer avec une exécution séquentielle. Lorsque plusieurs jobs essaient de peupler le même cache de base de données simultanément, vous obtenez des conditions de course. Nous avons résolu ce problème 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 la base de données une fois, et tous les jobs en aval le restaurent. Pas de courses, pas de travail en double.

Nous avons également créé un workflow dédié pour les tests unitaires de l'API v2, les séparant de la suite de tests principale (#26189). Cela leur permet de s'exécuter en parallèle avec d'autres vérifications plutôt que de se battre pour les ressources dans un job de test monolithique.

L'effet composé

Aucun de ces changements pris isolément n'aurait réduit notre temps CI de 83 %. Les gains proviennent de leur combinaison.

Des dépendances intelligentes permettent aux jobs de commencer plus tôt. Un caching efficace signifie que ces jobs passent moins de temps à se mettre en place. Le sharding signifie que l'exécution des tests se fait réellement en parallèle. Ensemble, ils compressent le chemin critique dans toutes les directions.

L'investissement se cumule dans le temps. Chaque PR reçoit maintenant un retour plus rapide. Les ingénieurs restent en état de flux plus longtemps. La tentation de grouper les changements diminue, ce qui signifie des diffusions plus petites et plus faciles à examiner. La qualité du code s'améliore parce que la boucle de rétroaction est plus rapprochée.

Nous continuerons à investir dans les performances CI parce que les retours sont réels et mesurables. Le but n'est pas la vitesse pour elle-même ; c'est de permettre aux ingénieurs de livrer des logiciels de qualité plus rapidement. Lorsque le CI est rapide, avancer rapidement et maintenir des normes élevées fonctionnent ensemble plutôt que de s'opposer.

Pull Requests référencées

Fondation : Migration de Buildjet vers Blacksmith

  • #26247 - Migrer les workflows GitHub de Buildjet vers Blacksmith

Stabilité des tests

  • #26269 - Corriger les tests e2e instables avec des sessions utilisateurs isolées

  • #26278 - Stabiliser les tests e2e avec des localisateurs à portée et des horaires déterministes

  • #26283 - Améliorer l'isolation des tests pour les tests e2e de type d'événement géré

Mise en cache Turbo à distance

  • #26093 - Mise à niveau vers Next 16 (les constructions ont été réduites de plus de 6 minutes à ~1 minute)

  • #26331 - Activer la mise en cache Turbo à distance (les constructions suivantes prennent 7s)

Dépendances de jobs et optimisation de workflow

  • #26101 - Consolider les jobs de détection des changements et de vérification des étiquettes dans prepare

  • #26170 - Découpler les tests non-API v2 de la construction de l'API v2

  • #26157 - Rendre les jobs de rapport E2E non-bloquants en les déplaçant vers un workflow séparé

  • #26320 - Déplacer les jobs de dépendances vers l'étape de job prepare pour économiser ~20s par workflow

Utiliser lookup-only

  • #26314 - Utiliser la vérification de cache lookup-only pour sauter les téléchargements de jobs de dépendances

  • #26060 - Sauter l'installation de Playwright lors d'un hit de cache

  • #26344 - Utiliser lookup-only pour le cache de DB et simplifier la clé de cache

Correction de cache

  • #26268 - Exclure .next/node_modules du cache yarn pour éviter la corruption

  • #26312 - Supprimer les entrées de cache-build lors de la fermeture de la PR

Sharding de tests et infrastructure

  • #26342 - Augmenter les shards principaux E2E de 4 à 8 (économise 1 minute)

  • #26183 - Sharder les tests E2E de l'API v2 en 4 jobs parallèles

  • #26171 - Ajouter un job setup-db dédié pour éliminer les conditions de course de cache

  • #26189 - Créer un nouveau workflow pour les tests unitaires de l'API v2

Commencez avec Cal.com gratuitement dès aujourd'hui !

Découvrez une planification et une productivité sans faille sans frais cachés. Inscrivez-vous en quelques secondes et commencez à simplifier votre planification dès aujourd'hui, sans carte de crédit requise !