En octobre, j’ai fusionné un petit correctif du portail client. Il touchait deux fichiers du portail et aucun code Go. Avant de pouvoir être livré, la CI s’est exécutée trois fois :

  1. Envoi vers main : toutes les tâches, 35 min 35 s.
  2. Un déclenchement manuel pour les tests d’intégration avec détecteur de concurrence (race) et PostgreSQL : 32 min 59 s, dont un délai d’expiration de 20 minutes et une réexécution.
  3. Envoi vers la branche de version : toutes les tâches de nouveau, 16 min 57 s.

Cela fait 85,5 min de CI pour un changement que les tâches Go n’avaient rien à vérifier.

La CI pour un correctif du portailAvant et après
Durée totale de la CI, un changement touchant seulement le portail
En baisse de 89 % : de 85 min 31 s à 9 min 23 s
Set up Go, par tâche
En baisse, de 8 à 11 min à 1 à 13 s
Tâches Go, race et PostgreSQL
Ignorées pour un changement touchant seulement le portail
Temps d’attente des tâches avant leur démarrage
Environ 1,7 min sur les 9 min de l’exécution

L’étape la plus lente de la première exécution était Set up Go, qui a pris de 8 à 11 min dans les tâches Go, Node et client Linux.

Voici les cinq correctifs. Chacun fonctionne sur les exécuteurs hébergés par GitHub et sur les exécuteurs autohébergés. Cochez-les au fur et à mesure.

Les cinq correctifs

1. N’exécuter que ce qui a changé

Je garde une seule table de correspondance entre chemins et composants. La CI et le script de version la lisent tous les deux. Version simplifiée :

json
{
  "components": {
    "portal": { "paths": ["apps/portal/**", "packages/**", "pnpm-lock.yaml"] },
    "core": { "paths": ["internal/**", "go.mod", "migrations/**"] },
    "ci": { "forceAll": true, "paths": [".github/**", "scripts/ci/**"] }
  },
  "ignored": ["docs/**", "**/*.md"]
}

Une première tâche changes compare l’envoi au commit précédent et écrit une sortie par composant. Un fichier qui ne correspond à aucun composant déclenche tout, tout comme un changement de la CI elle-même. Chaque tâche vérifie ensuite sa sortie :

yaml
go-fast:
  needs: changes
  if: needs.changes.outputs.core == 'true'

Pourquoi pas le filtre paths intégré ? Si un flux de travail est ignoré par le filtrage de chemins, ses vérifications restent « Pending », et une demande de tirage (pull request) qui les exige ne peut pas être fusionnée (documentation de GitHub). Un if: au niveau de la tâche évite ce problème, mais une tâche ignorée est signalée comme « Success » (documentation de GitHub). Ainsi, si la tâche changes elle-même échoue, toutes les tâches suivantes sont ignorées et semblent réussies.

Le correctif est une tâche-barrière qui s’exécute toujours, dépend de toutes les autres tâches et échoue pour tout résultat autre que réussite ou ignorée. Faites-en la seule vérification obligatoire. Version simplifiée :

yaml
ci-gate:
  name: CI gate
  if: always()
  needs: [changes, format, go-fast, node-fast]
  steps:
    - env:
        NEEDS_JSON: ${{ toJSON(needs) }}
      run: ./scripts/ci/02-ci-gate.sh

Le script parcourt NEEDS_JSON avec jq et se termine avec un code non nul sur failure ou cancelled. Une logique comme celle-ci a sa place dans un script que vous pouvez exécuter localement, pas dans du YAML (règle 1 du guide CI).

2. Tester une seule fois

Les tests race et PostgreSQL attendaient un déclenchement manuel ; chaque changement exigeait donc une seconde exécution. Ensuite, la branche de version réexécutait tout sur le même commit.

Maintenant, les suites coûteuses s’exécutent automatiquement à chaque envoi vers main qui modifie un composant Go. La prochaine étape consiste à promouvoir le commit qui a déjà réussi sur main plutôt que de le tester de nouveau sur une branche de version.

3. Garder les caches sur l’exécuteur

Le disque d’un exécuteur autohébergé survit entre les tâches. C’est un cache que vous avez déjà.

J’avais actions/setup-go avec la mise en cache activée. Chaque tâche téléchargeait une archive de 4,6 Go et la décompressait dans un répertoire personnel que partageaient de nombreux processus d’exécuteur sur la même machine. Dans chacune de ces tâches, tar a affiché « Cannot open: File exists » 22 585 fois. Lors de l’exécution de confiance, la tâche PostgreSQL a atteint son délai d’expiration de 20 minutes pendant Set up Go.

J’ai désactivé les caches des actions, de sorte que les tâches utilisent les caches Go et pnpm déjà présents sur le disque de données de l’exécuteur :

yaml
- uses: actions/setup-go@<sha> # v7.0.0
  with:
    go-version-file: go.mod
    cache: false

Les tâches Node et client ont aussi retiré cache: pnpm de actions/setup-node. Set up Go prend maintenant de 1 à 13 s. J’ai depuis donné à chaque exécuteur ses propres répertoires de cache, afin que des tâches parallèles ne décompressent jamais au même endroit.

Sur les exécuteurs hébergés par GitHub, chaque tâche démarre à neuf; actions/cache ou l’entrée cache des actions setup-* est donc le bon outil. Basez la clé sur le fichier de verrouillage. GitHub supprime les entrées non utilisées depuis 7 jours (référence sur la mise en cache des dépendances).

4. Laisser les caches d’outils faire leur travail

Go met en cache les résultats de tests réussis. Un résultat est réutilisé lorsque le binaire de test, les options, ainsi que les fichiers et les variables d’environnement que le test lit sont inchangés. La sortie indique alors (cached) (documentation de go test).

Ma première exécution après le changement ne touchait aucun code Go. Les 35 paquets de tests race comportant des tests provenaient tous du cache, et la tâche a pris 8 s.

-count=1 désactive ce comportement. Utilisez-le seulement lorsqu’un test dépend de quelque chose que Go ne voit pas, comme les tests PostgreSQL.

5. Mesurer avant et après

Cette commande liste les étapes les plus lentes d’une exécution :

bash
gh run view <run-id> --json jobs --jq '[.jobs[] | .name as $job | .steps[]
  | {job: $job, step: .name, s: ((.completedAt | fromdate) - (.startedAt | fromdate))}]
  | sort_by(-.s) | .[:3][] | [.s, .job, .step] | @tsv'

Sur ma première exécution, elle a affiché Set up Go à 642, 537 et 485 secondes. Cela désignait le cache, pas les tests.

Mes chiffres

ExécutionDescriptionDurée
39186521120envoi vers main, toutes les tâches35 min 35 s
39189753000exécution manuelle race et PostgreSQL32 min 59 s
39192328603branche de version, toutes les tâches de nouveau16 min 57 s
Avant85 min 31 s
39194529504envoi vers main, race et PostgreSQL inclus9 min 23 s
Chronologie de l’exécution GitHub Actions 39194529504 pour le commit 7b7ce40, 9 min 23 s au total. Une bande à la même échelle la compare aux trois exécutions précédentes, 85 min 31 s. Tâches : composants modifiés 8 s, vérification du format 36 s, vérifications shell 8 s, vérifications Go 34 s, vérifications Go race 8 s, intégration PostgreSQL 7 min 16 s, vérifications Node 3 min 55 s, publication des clients Darwin et Linux environ 1 min 40 s chacune, champs du manifeste de version 4 s, barrière CI 8 s. Set up Go prend de 1 à 13 s dans chaque tâche qui l’utilise.

L’exécution après le changement modifiait des fichiers de CI ; elle a donc lancé toutes les tâches. Un changement touchant seulement le portail ignore maintenant les tâches Go, race et PostgreSQL. Environ 1,7 min des 9 correspondait à des tâches en attente de démarrage.

Il reste une pièce : les versions réexécutent encore la CI sur la branche de version. Je retirerai cette exécution et ne construirai que les images modifiées.

Si vous préférez ne pas gérer d’exécuteurs du tout

Runzivo exploite des exécuteurs Linux x64 gérés pour GitHub Actions. Chaque tâche obtient une microVM Firecracker neuve, détruite à la fin de la tâche. Pour l’essayer, changez une seule étiquette :

yaml
runs-on: rzv-ubuntu-24.04-x64-2vcpu-8gb

Runzivo est en accès anticipé, avec une allocation mensuelle gratuite par espace de travail (commencer). Comme chaque tâche démarre à neuf, appliquez-y le conseil du correctif 3 pour les exécuteurs hébergés. Les chiffres ci-dessus viennent de mes changements de CI, pas d’un changement d’exécuteurs.