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 :
- Envoi vers
main: toutes les tâches, 35 min 35 s. - 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.
- 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.
- 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 :
{
"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 :
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 :
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.shLe 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 :
- uses: actions/setup-go@<sha> # v7.0.0
with:
go-version-file: go.mod
cache: falseLes 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 :
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écution | Description | Durée |
|---|---|---|
| 39186521120 | envoi vers main, toutes les tâches | 35 min 35 s |
| 39189753000 | exécution manuelle race et PostgreSQL | 32 min 59 s |
| 39192328603 | branche de version, toutes les tâches de nouveau | 16 min 57 s |
| Avant | 85 min 31 s | |
| 39194529504 | envoi vers main, race et PostgreSQL inclus | 9 min 23 s |

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 :
runs-on: rzv-ubuntu-24.04-x64-2vcpu-8gbRunzivo 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.