Aller au contenu

Cache GitHub Actions : accélérer vos workflows

Mise à jour :

Vous venez de corriger une faute de frappe dans votre README. Vous poussez le commit. GitHub Actions démarre… et vous attendez 4 minutes pendant que npm télécharge 847 paquets pour la 50ᵉ fois cette semaine.

Frustrant, non ?

Ce guide vous explique comment diviser ce temps par 10 grâce au cache GitHub Actions. Pas besoin d’être expert : si vous savez écrire un workflow basique, vous saurez utiliser le cache en 10 minutes.

Le problème : pourquoi vos workflows sont lents

Observons ce qui se passe quand vous lancez un workflow sans cache :

Commit #1 (lundi 9h00)
├── Checkout du code .............. 2s
├── npm ci (téléchargement) ....... 47s ← Internet
├── npm run build ................. 35s
└── npm test ...................... 12s
Total: 96 secondes
Commit #2 (lundi 9h15)
├── Checkout du code .............. 2s
├── npm ci (téléchargement) ....... 47s ← Encore Internet, mêmes paquets !
├── npm run build ................. 35s
└── npm test ...................... 12s
Total: 96 secondes

Entre ces deux commits, rien n’a changé dans les dépendances. Pourtant, GitHub télécharge les mêmes 847 paquets, depuis les mêmes serveurs npm, pour la deuxième fois en 15 minutes.

Sur une semaine avec 50 commits, c’est 39 minutes perdues à télécharger les mêmes fichiers.

La solution : garder les fichiers en mémoire

L’analogie du frigo

Imaginez que vous prépariez un gâteau chaque matin. Deux approches possibles :

Chaque matin, vous allez au supermarché acheter :

  • 500g de farine
  • 6 œufs
  • 250g de beurre
  • 200g de sucre

Temps passé : 45 minutes de courses + 30 minutes de préparation = 75 minutes

Le lendemain ? Rebelote : 45 minutes de courses pour les mêmes ingrédients.

Le cache GitHub Actions, c’est votre frigo numérique. Au lieu de télécharger vos dépendances depuis Internet à chaque fois, vous les stockez une première fois, puis vous les récupérez instantanément.

Ce que ça donne en pratique

Commit #1 (premier run, cache vide)
├── Checkout du code .............. 2s
├── Restauration du cache ......... 0s ← Pas de cache encore
├── npm ci (téléchargement) ....... 47s ← Internet
├── Sauvegarde du cache ........... 5s ← On remplit le frigo
├── npm run build ................. 35s
└── npm test ...................... 12s
Total: 101 secondes
Commit #2 (cache disponible)
├── Checkout du code .............. 2s
├── Restauration du cache ......... 3s ← Le frigo est plein !
├── npm ci ........................ 2s ← Rien à télécharger
├── npm run build ................. 35s
└── npm test ...................... 12s
Total: 54 secondes ✨

Résultat : 47 secondes économisées à chaque commit. Sur 50 commits par semaine, c’est 39 minutes récupérées.

Principe du cache GitHub Actions : le premier run sauvegarde, le second
restaure

Activer le cache : la méthode la plus simple

Bonne nouvelle : activer le cache prend une seule ligne.

Avant (sans cache)

name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci # ← 47 secondes à chaque fois
- run: npm run build
- run: npm test

Après (avec cache)

name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # ← C'est tout ! Une ligne.
- run: npm ci # ← 2 secondes si le cache existe
- run: npm run build
- run: npm test

La ligne cache: 'npm' fait tout le travail :

  1. Elle cherche un cache existant
  2. Si trouvé, elle restaure les fichiers
  3. À la fin du job, elle sauvegarde le cache pour la prochaine fois

Vérifier que ça fonctionne

Après avoir ajouté cache: 'npm', lancez deux workflows consécutifs. Dans les logs du second, vous devriez voir :

Run actions/setup-node@v4
Cache hit occurred on the primary key: node-cache-Linux-npm-d5ea0750...

Le message Cache hit confirme que le cache a été utilisé. Si vous voyez Cache miss, c’est normal pour le premier run : le cache n’existe pas encore.

Comment le cache sait quoi garder ?

Vous vous demandez peut-être : « Comment GitHub sait que mes dépendances n’ont pas changé ? »

La réponse tient en un mot : le hash.

Le hash, c’est quoi ?

Un hash est une empreinte digitale d’un fichier. Si le fichier change, même d’une virgule, le hash change complètement.

package-lock.json (version 1) → Hash: abc123
package-lock.json (version 2) → Hash: xyz789 (complètement différent !)

Quand vous utilisez cache: 'npm', GitHub calcule le hash de votre fichier package-lock.json. Ce hash devient l’étiquette du cache.

Pourquoi c’est malin

  • Lundi : vous poussez un commit, hash = abc123, cache créé
  • Mardi : nouveau commit, hash = abc123 (identique !)
  • GitHub dit : « J’ai déjà un cache pour abc123, je le réutilise »
  • Résultat : cache hit, restauration en 3 secondes

Le système est automatique : si vos dépendances changent, le cache se renouvelle. Si elles ne changent pas, le cache est réutilisé.

Pourquoi package-lock.json et pas package.json ?

C’est une question fréquente. La réponse est simple :

FichierContenuExemple
package.jsonPlages de versions"lodash": "^4.17.0" (peut être 4.17.0, 4.17.1, 4.18.0…)
package-lock.jsonVersions exactes"lodash": "4.17.21" (toujours cette version précise)

Si vous utilisez package.json, le hash peut rester identique alors que les versions réelles ont changé. Vous risquez de restaurer un cache incompatible avec vos dépendances actuelles.

Le fichier lock garantit que le cache correspond exactement à ce qui sera installé.

La clé de cache : l’étiquette de votre frigo

Quand vous utilisez cache: 'npm', GitHub crée automatiquement une clé de cache. Cette clé, c’est comme l’étiquette sur une boîte au frigo : elle dit ce qu’il y a dedans.

Anatomie d’une clé

Structure d'une clé de cache : préfixe, OS et hash des
dépendances

Une clé typique ressemble à ça :

npm-Linux-d5ea0750abc123def456
│ │ └── Hash du package-lock.json
│ └── Système d'exploitation
└── Type de cache (npm, pip, maven...)

Pourquoi ces trois parties ?

PartieRôleExemple
PréfixeÉvite les collisions entre types de cachenpm- vs pip-
OSLes dépendances compilées diffèrent selon l’OSLinux vs Windows
HashIdentifie la version exacte des dépendancesChange si le lockfile change

Que se passe-t-il quand la clé ne correspond pas ?

Imaginons que vous ajoutiez une nouvelle dépendance. Le hash change. GitHub cherche un cache avec la nouvelle clé… et ne trouve rien.

C’est là qu’interviennent les restore-keys (clés de secours).

key: npm-Linux-xyz789 # Clé exacte (nouvelle)
restore-keys: |
npm-Linux- # Fallback : n'importe quel cache npm sur Linux
npm- # Fallback ultime : n'importe quel cache npm
  1. Recherche exacte

    GitHub cherche npm-Linux-xyz789. Pas trouvé ? On passe à l’étape suivante.

  2. Recherche par préfixe

    GitHub cherche un cache commençant par npm-Linux-. Il trouve npm-Linux-abc123 (l’ancien cache). Il le restaure.

  3. Installation partielle

    npm ci s’exécute. 95% des paquets sont déjà là (depuis l’ancien cache). Seule la nouvelle dépendance est téléchargée.

  4. Nouveau cache

    À la fin, GitHub sauvegarde le nouveau cache avec la clé npm-Linux-xyz789.

Résultat : au lieu de tout télécharger (47 secondes), vous ne téléchargez que la différence (5 secondes).

Configuration avancée : prendre le contrôle

La méthode cache: 'npm' est magique, mais parfois vous avez besoin de plus de contrôle. C’est là qu’intervient l’action actions/cache.

Quand utiliser actions/cache ?

SituationSolution
Cache basique des dépendancescache: 'npm' (simple)
Cache du build (Next.js, Webpack…)actions/cache (avancé)
Plusieurs chemins à cacheractions/cache (avancé)
Clé personnaliséeactions/cache (avancé)

Exemple : cacher le build Next.js

Next.js stocke son cache de build dans .next/cache. Sans ce cache, le build peut prendre 2 minutes. Avec, il prend 30 secondes.

name: CI avec cache de build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Cache des dépendances (méthode simple)
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Cache du build Next.js (méthode avancée)
- name: Cache du build Next.js
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
nextjs-${{ runner.os }}-
- run: npm ci
- run: npm run build # ← 30s au lieu de 2min !
- run: npm test

Décortiquons cette clé :

nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**') }}
│ │ │ │
│ │ │ └── Change si le code source change
│ │ └── Change si les dépendances changent
│ └── Linux, macOS ou Windows
└── Préfixe pour identifier ce cache

Le cache est invalidé si :

  • Vous changez de système d’exploitation
  • Vous modifiez les dépendances
  • Vous modifiez le code source

C’est logique : le build dépend de ces trois éléments.

Exemple : cacher plusieurs chemins

Vous pouvez cacher plusieurs dossiers dans une seule entrée :

- uses: actions/cache@v4
with:
path: |
~/.npm
.next/cache
node_modules/.cache
key: all-caches-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

Ce qu’il faut cacher (et ne pas cacher)

À cacher : les dépendances

LangageQuoi cacherPourquoi
Node.js~/.npmCache global npm, fonctionne partout
Python~/.cache/pipPaquets téléchargés par pip
Java~/.m2/repositoryArtefacts Maven
Go~/go/pkg/modModules Go
Rust~/.cargoCrates Cargo

À cacher : les résultats de build

OutilQuoi cacherGain typique
Next.js.next/cache50-80%
Webpacknode_modules/.cache40-60%
Gradle~/.gradle/caches50-70%
Rusttarget/60-80%

À NE PAS cacher

Ne pas cacherPourquoi
node_modules directementFragile, dépend de l’OS exact
Secrets, tokensLe cache est lisible par les PRs
Fichiers volumineux non réutilisésGaspille l’espace (limite 10 GB)
Code exécutableRisque de sécurité (cache poisoning)

Sécurité : qui peut accéder à mon cache ?

Le cache n’est pas partagé n’importe comment. GitHub applique des règles d’isolation strictes.

Les règles d’accès

Isolation des caches par branche : accès autorisés et
refusés

DepuisPeut accéder au cache deAutorisé ?
Branche featuremain (branche par défaut)✅ Oui
Branche feature-afeature-b (branche sœur)❌ Non
Fork externeRepo parent❌ Non
PRBranche cible✅ Oui

Pourquoi ces restrictions ?

Imaginez qu’un attaquant crée un fork de votre projet. S’il pouvait écrire dans votre cache, il pourrait y injecter du code malveillant. Lors du prochain build sur main, ce code s’exécuterait avec accès à vos secrets.

Les restrictions empêchent ce scénario : les forks ne peuvent pas toucher au cache du repo parent.

Pour renforcer la sécurité de vos workflows, pensez aussi à épingler vos actions par SHA.

Le risque de cache poisoning

Limites et quotas

Ce que vous devez savoir

LimiteValeurCe que ça signifie
Taille par entrée10 GBUn seul cache ne peut pas dépasser 10 GB
Taille totale10 GBTous les caches du repo combinés
Rétention7 joursCache supprimé s’il n’est pas utilisé pendant 7 jours
Longueur de clé512 caractèresAttention aux clés trop complexes

Quand le cache est plein

Si vous dépassez 10 GB, GitHub supprime les caches les plus anciens (ceux non utilisés depuis longtemps) jusqu’à repasser sous la limite.

Problème potentiel : si vos caches sont tous volumineux et utilisés régulièrement, vous pouvez entrer dans un cycle de création/suppression appelé cache thrashing. Chaque run crée un cache qui chasse l’ancien.

Solutions :

  • Réduisez ce que vous cachez (seulement le nécessaire)
  • Demandez une augmentation de quota (entreprises)
  • Surveillez avec gh cache list

Débugger quand ça ne marche pas

Le cache n’est jamais utilisé

Symptôme : vous voyez toujours « Cache miss » dans les logs.

Causes possibles :

  1. La clé change à chaque run

    # ❌ Mauvais : github.sha change à chaque commit
    key: cache-${{ github.sha }}
    # ✅ Bon : change seulement si les dépendances changent
    key: cache-${{ hashFiles('**/package-lock.json') }}
  2. Le fichier hashé n’existe pas

    # ❌ Le fichier n'existe pas → hash vide → clé toujours différente
    key: cache-${{ hashFiles('package-lock.json') }}
    # ✅ Glob pattern qui trouve le fichier
    key: cache-${{ hashFiles('**/package-lock.json') }}
  3. Pas de restore-keys

    Sans restore-keys, le moindre changement = cache miss complet.

Comment voir ce qui se passe

Ajoutez une étape de debug :

- name: Debug cache
run: |
echo "Clé calculée: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}"
echo "Le fichier existe ?"
ls -la package-lock.json || echo "Fichier non trouvé !"

Lister et supprimer les caches

La CLI GitHub permet de gérer vos caches :

Terminal window
# Voir tous les caches du repo
gh cache list
# Supprimer un cache spécifique
gh cache delete "npm-Linux-abc123"
# Tout supprimer (utile pour repartir de zéro)
gh cache delete --all

Workflow complet commenté

Voici un workflow production-ready avec toutes les bonnes pratiques :

name: CI Production
on:
push:
branches: [main]
pull_request:
branches: [main]
# Permissions minimales (sécurité)
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
# 1. Récupérer le code
- name: Checkout
uses: actions/checkout@v4
# 2. Configurer Node.js avec cache des dépendances
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Équivalent à :
# - Chercher un cache avec clé npm-Linux-<hash de package-lock.json>
# - Restaurer ~/.npm si trouvé
# - Sauvegarder ~/.npm à la fin
# 3. Cache du build Next.js (optionnel mais recommandé)
- name: Cache build Next.js
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**', 'app/**') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
nextjs-${{ runner.os }}-
# 4. Installer les dépendances
# - Avec cache hit : ~2 secondes
# - Sans cache : ~45 secondes
- name: Install dependencies
run: npm ci
# 5. Build
# - Avec cache build : ~30 secondes
# - Sans cache : ~2 minutes
- name: Build
run: npm run build
# 6. Tests
- name: Test
run: npm test

À retenir

  1. Une ligne suffit pour commencer

    Ajoutez cache: 'npm' (ou pip, maven…) dans votre action setup-*. C’est tout. Vous venez de gagner 30-60 secondes par run.

  2. Le hash du lockfile est la clé

    Le cache est identifié par le hash de votre fichier lock (package-lock.json, poetry.lock…). Si le fichier ne change pas, le cache est réutilisé.

  3. Les restore-keys sont votre filet de sécurité

    Même si la clé exacte n’existe pas, les restore-keys permettent de récupérer un cache partiel. Toujours les configurer.

  4. Cachez le global, pas le local

    Préférez ~/.npm à node_modules. C’est plus robuste et plus sécurisé.

  5. Le cache est isolé par branche

    Les branches feature peuvent lire le cache de main, mais les forks ne peuvent pas accéder au cache du repo parent.

  6. 10 GB max, 7 jours de rétention

    Surveillez votre utilisation avec gh cache list. Ne cachez que le nécessaire.

  7. Vérifiez dans les logs

    Cherchez « Cache hit » ou « Cache miss » pour confirmer que le cache fonctionne.

Liens utiles