95 % d'accélération du CI avec 15 lignes de bash

Erik Treviño avatar
Erik Treviño
Cover for 95 % d'accélération du CI avec 15 lignes de bash

J’ai réduit notre temps de validation CI de 95 %.

Pas grâce au cache. Pas grâce au sharding. Pas grâce à un outil tiers sophistiqué qui ajoute une dépendance supplémentaire à votre pipeline.

Avec 15 lignes de bash. Zéro dépendance. Zéro risque sur la chaîne d’approvisionnement.

La PR qui a livré ce changement était plus petite que la plupart des messages de commit. Et elle fait gagner à l’équipe 7 à 8 heures par mois.

Le problème

Chaque PR exécutait l’intégralité du pipeline CI. Linting backend, tests unitaires backend, tests unitaires frontend, tests E2E — le parcours complet. Un développeur modifie un seul fichier de test E2E, pousse sa branche, et attend 8 minutes et 18 secondes que le linting backend lui confirme que son code Python est correct.

Le code Python qu’il n’a pas touché.

C’est le comportement par défaut de la plupart des configurations CI. Un seul fichier de workflow, un seul déclencheur, tout exécuter à chaque push. C’est simple. C’est sûr. Mais c’est aussi du temps de développeur gaspillé sur des validations qui ne peuvent tout simplement pas échouer pour les modifications en cours.

La solution : 15 lignes de bash

L’idée est simple : utiliser git diff --name-only pour déterminer quels fichiers ont changé, puis ignorer conditionnellement les jobs qui ne sont pas pertinents pour ces modifications.

#!/bin/bash
# detect-changes.sh — Determine which CI jobs to run based on changed files
# Called by GitHub Actions workflow to set output variables

CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)

RUN_BACKEND=false
RUN_FRONTEND=false
RUN_E2E=false

for file in $CHANGED_FILES; do
  case "$file" in
    backend/*|api/*|*.py)        RUN_BACKEND=true ;;
    frontend/*|src/*|*.ts|*.tsx) RUN_FRONTEND=true ;;
    tests/e2e/*|playwright/*)    RUN_E2E=true ;;
    .github/*|docker-compose*|Makefile)
      # Infrastructure changes: run everything as a safety net
      RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;;
  esac
done

echo "run-backend=$RUN_BACKEND" >> "$GITHUB_OUTPUT"
echo "run-frontend=$RUN_FRONTEND" >> "$GITHUB_OUTPUT"
echo "run-e2e=$RUN_E2E" >> "$GITHUB_OUTPUT"

C’est tout. Le script lit le diff, classe chaque fichier modifié par répertoire et définit des indicateurs de sortie que les jobs en aval vérifient avant de s’exécuter.

Le workflow GitHub Actions : avant et après

Avant : tout exécuter systématiquement

# .github/workflows/ci.yml — BEFORE
name: CI
on: [push, pull_request]

jobs:
  backend-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff
      - run: ruff check backend/

  backend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest backend/tests/

  frontend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  e2e-test:
    runs-on: ubuntu-latest
    needs: [backend-test, frontend-test]
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Chaque job s’exécute sur chaque PR. Une correction de faute de frappe dans un fichier de test E2E déclenche le linting backend, les tests backend, les tests frontend et les tests E2E. Temps total : 8 minutes 18 secondes.

Après : n’exécuter que ce qui est pertinent

# .github/workflows/ci.yml — AFTER
name: CI
on: [push, pull_request]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      run-backend: ${{ steps.changes.outputs.run-backend }}
      run-frontend: ${{ steps.changes.outputs.run-frontend }}
      run-e2e: ${{ steps.changes.outputs.run-e2e }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for accurate git diff
      - id: changes
        run: bash .github/scripts/detect-changes.sh

  backend-lint:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install ruff
      - run: ruff check backend/

  backend-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: pytest backend/tests/

  frontend-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  e2e-test:
    needs: detect-changes
    if: needs.detect-changes.outputs.run-e2e == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Le seul ajout est le job detect-changes (exécuté en environ 5 secondes) et les conditions if: sur chaque job en aval. Tout le reste est identique.

Les résultats

Type de PRAvantAprèsGain
Modifications E2E uniquement8 min 18 sec39 sec95 %
Modifications frontend uniquement6 min 13 sec1 min 32 sec75 %
Modifications backend uniquement5 min 45 sec2 min 10 sec62 %
Modifications full-stack8 min 18 sec8 min 18 sec0 % (tout s’exécute)
Modifications CI/infra8 min 18 sec8 min 18 sec0 % (filet de sécurité)

Sur une semaine type — environ 40 % de PR E2E uniquement, 30 % frontend uniquement, 20 % backend uniquement, 10 % full-stack — cela représente 7 à 8 heures par mois de temps d’attente développeur économisé.

Le filet de sécurité est important : toute modification des workflows .github/, des configurations Docker ou du Makefile déclenche le pipeline complet. On ne saute jamais la validation de l’infrastructure qui exécute vos validations.

Pourquoi ne pas utiliser une GitHub Action pour ça ?

C’est là que l’histoire devient intéressante. J’ai audité trois GitHub Actions populaires pour le filtrage CI basé sur les chemins avant d’écrire le script bash. Ce que j’ai trouvé m’a convaincu que zéro dépendance était le bon choix.

Action n°1 : communique avec une API commerciale

Une action populaire de filtrage par chemin effectue un appel API vers un service commercial à chaque exécution CI. Relisez bien : votre pipeline CI communique avec un tiers à chaque exécution. L’action fonctionne correctement quand le service est en ligne et que votre abonnement est actif. Quand ce n’est pas le cas — et j’ai trouvé des Issues GitHub d’utilisateurs le signalant — l’action peut faire échouer votre build. Votre pipeline CI a désormais une dépendance de disponibilité envers un produit SaaS que vous ne contrôlez pas.

Action n°2 : cible d’attaque sur la chaîne d’approvisionnement (CVE-2025-30066)

En mars 2025, la GitHub Action largement utilisée tj-actions/changed-files — utilisée par plus de 23 000 dépôts — a été compromise dans une attaque sur la chaîne d’approvisionnement. L’attaquant a modifié des tags de version existants pour pointer vers du code malveillant qui vidait la mémoire du runner CI, exposant les secrets du workflow, notamment les clés API, les tokens et les identifiants. La CISA a émis un avis formel (CVE-2025-30066). L’attaque a ensuite été retracée jusqu’à un token d’accès personnel compromis appartenant à un compte bot avec accès en écriture au dépôt.

Ce n’est pas un risque théorique. C’est arrivé. À une action qui fait exactement ce que mon script bash de 15 lignes fait.

Action n°3 : fonctionne bien, mais inutile

La troisième action que j’ai évaluée est bien maintenue, open-source et ne présente aucun problème de sécurité connu. Elle fonctionne correctement. Elle ajoute aussi une dépendance qui doit être verrouillée en version, surveillée pour les mises à jour et auditée périodiquement. Pour 15 lignes de bash qui font la même chose, la dépendance n’est pas justifiée.

L’argument sécurité en faveur de zéro dépendance

Chaque GitHub Action que vous ajoutez à votre workflow est du code que vous exécutez avec accès aux secrets de votre dépôt, à votre code source et à votre environnement CI. Chaque action est une décision de confiance. L’incident tj-actions/changed-files a démontré que même des actions populaires et largement utilisées peuvent être compromises.

L’approche par script bash a un profil de sécurité différent :

  • Aucun appel réseau. Le script lit la sortie de git diff. Il ne télécharge rien, ne communique avec rien et ne dépend de rien en dehors du dépôt.
  • Aucune exécution de code tiers. Le script est versionné dans votre dépôt et revu dans le même processus de PR que votre code applicatif.
  • Pas de jeu de verrouillage de version. Pas de tag @v4 dont il faut se soucier. Aucun vecteur d’attaque sur la chaîne d’approvisionnement. Le code est le vôtre.
  • Auditabilité totale. N’importe qui dans l’équipe peut lire 15 lignes de bash et comprendre exactement ce que ça fait.

Parfois, la meilleure dépendance est l’absence de dépendance.

Cas limites et filets de sécurité

Quelques éléments que je gère et qu’une implémentation naïve manquerait :

Nouveaux fichiers sans catégorie claire

# If a file doesn't match any known pattern, run everything.
# Better to over-run than under-run.
*)
  RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;;

Le cas par défaut déclenche une exécution complète. Un type de fichier non reconnu ne devrait jamais ignorer silencieusement la validation.

Fichiers supprimés

git diff --name-only inclut les fichiers supprimés. Un fichier backend supprimé nécessite quand même l’exécution de la suite de tests backend — la suppression pourrait casser un import.

Commits de merge

Le fetch-depth: 0 dans l’étape de checkout est essentiel. Sans l’historique git complet, le git diff par rapport à la branche de base produit des résultats inexacts. J’ai vu des configurations CI avec fetch-depth: 1 (la valeur par défaut) qui manquaient des modifications parce que le clone superficiel ne contenait pas la base de merge.

Ce que vous pouvez faire dès lundi matin

  1. Mesurez votre référence actuelle. Quelle est la durée moyenne du CI pour votre type de PR le plus courant ? Si vous ne le savez pas, vérifiez les 20 dernières exécutions de workflow.

  2. Classez vos jobs CI par répertoire. Quels jobs correspondent à quelles parties de la base de code ? La plupart des pipelines ont une correspondance naturelle.

  3. Écrivez le script bash. Partez de la version ci-dessus et adaptez les patterns de répertoires à la structure de votre dépôt.

  4. Ajoutez le job detect-changes. Intégrez-le à votre workflow avec les conditions if:.

  5. Ajoutez le filet de sécurité. Toute modification de la configuration CI, des fichiers Docker ou des scripts d’infrastructure doit déclencher une exécution complète. Toujours.

  6. Mesurez le résultat. Comparez les mêmes types de PR avant et après. Les chiffres plaideront mieux que n’importe quel argument.

La leçon plus large

Le pipeline CI est l’infrastructure la plus négligée dans la plupart des organisations d’ingénierie. Les équipes vont passer des semaines à optimiser des requêtes de base de données qui font gagner 200 ms par requête, puis regarder chaque développeur attendre 8 minutes de validation CI non pertinente 5 fois par jour sans se poser de questions.

La solution ici n’est pas astucieuse. Elle n’est pas nouvelle. C’est 15 lignes de bash qui posent une question simple : les modifications de cette PR affectent-elles réellement le code que ce job valide ?

Si la réponse est non, on le saute. Ce n’est pas prendre des raccourcis. C’est respecter le temps de votre équipe.

15 lignes. Zéro dépendance. 95 % plus rapide. Parfois, la solution la plus simple est celle que personne n’essaie parce qu’elle semble trop simple pour fonctionner.