Un workflow qui exécute npm ci à chaque push télécharge depuis le registre les mêmes centaines de paquets à chaque fois, même quand le lockfile n’a pas changé depuis une semaine. Sur un projet avec un arbre de dépendances réel, ce sont une ou deux minutes perdues avant même que le premier test démarre, et vous les payez de nouveau à chaque push et à chaque pull request. actions/cache conserve ces téléchargements d’une exécution à l’autre et vous les restitue quand rien de pertinent n’a changé.

Comment l’action décide entre hit et miss

actions/cache stocke chaque entrée sous une chaîne que vous choisissez, la key, limitée à la branche qui l’a créée. Une exécution peut lire les caches de sa propre branche et de la branche par défaut. Lors de la restauration, l’action compare votre key à ce qui est stocké :

  • Une correspondance exacte avec key est un hit. Les fichiers sont téléchargés et le job continue.
  • En l’absence de correspondance exacte, on passe à restore-keys, vérifiées dans l’ordre, chacune une correspondance par préfixe avec les clés existantes. La correspondance la plus récente l’emporte.
  • Sans aucune correspondance, l’étape ne restaure rien. Le job démarre à froid et, s’il réussit, enregistre une nouvelle entrée sous key.

C’est pourquoi la pratique standard intègre le hash du lockfile dans la clé :

1
2
3
4
5
6
- uses: actions/cache@v6
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

Changez une ligne dans package-lock.json et le hash change, donc la clé exacte échoue volontairement. Vous voulez un cache qui corresponde au nouveau lockfile, pas à celui de la semaine dernière. Le préfixe de restore-keys continue lui à correspondre, donc le job démarre depuis le cache précédent plutôt que depuis un répertoire vide, ne télécharge que ce qui a changé, et enregistre une nouvelle entrée à correspondance exacte en cas de succès.

Deux limites s’appliquent. Un dépôt dispose de 10 Go de stockage de cache, partagés entre tous ses workflows, et toute entrée inutilisée depuis 7 jours est évincée. Vous n’avez rien à nettoyer à la main : quand le dépôt dépasse la limite, les entrées les plus anciennes sont supprimées, et la prochaine exécution qui en avait besoin paie une seule fois le coût de l’installation à froid.

Mettre npm en cache avec l’option cache de setup-node

Pour les gestionnaires de paquets courants, vous n’avez même pas besoin d’écrire une étape actions/cache. actions/setup-node, actions/setup-python et actions/setup-java acceptent chacun un paramètre cache qui gère la clé, la restauration et l’enregistrement à votre place :

1
2
3
4
5
6
7
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: 'npm'
- run: npm ci
- run: npm test

cache: 'npm' trouve package-lock.json (ou npm-shrinkwrap.json) à la racine du dépôt, en calcule le hash, et met en cache le cache de téléchargement de npm. cache: 'yarn' fait la même chose avec yarn.lock. Dans un monorepo où le lockfile se trouve dans packages/api/package-lock.json, pointez vers lui explicitement :

1
2
3
4
5
- uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: 'npm'
    cache-dependency-path: packages/api/package-lock.json

setup-python fonctionne de la même façon : cache: 'pip' (ou 'pipenv', 'poetry'), avec le hash calculé par défaut sur requirements.txt. setup-java accepte cache: 'maven' ou cache: 'gradle'. Commencez par là. Ne passez à une étape actions/cache manuelle que si vous avez besoin d’une clé ou d’un chemin que ces actions ne fournissent pas, ou si vous mettez en cache quelque chose qui n’a rien à voir avec un runtime de langage.

Mettre Maven et pip en cache avec actions/cache directement

Une étape manuelle comporte toujours les trois mêmes parties : le répertoire où l’outil écrit son cache, une clé construite à partir du fichier qui fixe les versions des dépendances, et un repli par préfixe.

1
2
3
4
5
6
- uses: actions/cache@v6
  with:
    path: ~/.m2/repository
    key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
    restore-keys: |
      ${{ runner.os }}-m2-
1
2
3
4
5
6
- uses: actions/cache@v6
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

path est le cache de l’outil, pas celui de votre projet : le cache de téléchargement de pip, pas votre virtualenv ; le dépôt local de Maven, pas target/. Vous pouvez aussi mettre en cache la sortie du build comme target/ ou dist/, mais cela ne paie que si le build est incrémental. Sinon, vous compressez et transférez des fichiers que l’exécution suivante jette.

Si vous mettez déjà en cache des couches Docker avec cache-to: type=gha, comme dans Construire et Publier une Image Docker avec GitHub Actions, ces couches vont dans le même service de cache d’Actions. Elles comptent sur les mêmes 10 Go et la même éviction à 7 jours que vos caches de dépendances. Un dépôt qui fait les deux atteint la limite plus tôt que prévu, et de grands caches de couches peuvent évincer votre cache npm.

Les clés de cache dans une matrice de builds

Une matrice sur des versions de Node ou des systèmes d’exploitation a besoin de la valeur de la matrice dans la clé, sinon les trois jobs se disputent une seule entrée :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
strategy:
  matrix:
    node-version: [20, 22, 24]
steps:
  - uses: actions/cache@v6
    with:
      path: ~/.npm
      key: ${{ runner.os }}-npm-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
      restore-keys: |
        ${{ runner.os }}-npm-${{ matrix.node-version }}-

Les entrées de cache ne peuvent pas être écrasées. Retirez matrix.node-version de la clé et les trois jobs tenteront d’enregistrer Linux-npm-<hash> : le premier terminé l’emporte, les autres enregistrent un avertissement indiquant que l’enregistrement a échoué. Pour ~/.npm, c’est presque sans conséquence, puisque les tarballs téléchargés ne dépendent pas de la version de Node. Cela cesse d’être sans conséquence quand le chemin mis en cache contient de la sortie compilée, comme node_modules avec un addon natif. Le job Node 24 restaure alors des binaires compilés pour Node 20, et vous obtenez des échecs qui ressemblent à tout sauf à un problème de cache. runner.os dans la clé fait le même travail entre les jobs de systèmes d’exploitation différents.

Ce qu’il ne faut pas mettre en cache dans GitHub Actions

Traitez une entrée de cache comme une couche d’image : quiconque peut exécuter un workflow qui la restaure peut la lire. Gardez tokens et fichiers .env hors de tout répertoire mis en cache. La règle est celle de Variables d’Environnement et Secrets dans Docker : les secrets viennent de secrets: à l’exécution, jamais de quelque chose écrit sur disque puis réutilisé.

Deux cas où mettre en cache coûte plus que cela ne fait gagner :

  • Tout ce qui s’installe en quelques secondes. Une poignée de paquets Python purs, sans extension compilée, s’installe presque aussi vite qu’elle se restaure. L’étape de cache a son propre surcoût de compression et de transfert de l’archive, qui peut manger un gain aussi faible.
  • Une clé trop large pour jamais faire hit. Hashez tout le répertoire src/ plutôt que le lockfile et chaque changement de code invalide le cache. Basez la clé sur le fichier qui fixe les versions des dépendances et rien d’autre.

Comment savoir si le cache a vraiment servi

L’étape actions/cache enregistre Cache restored from key: ... en cas de hit et Cache not found for input keys: ... en cas de miss. Lisez cette ligne avant de faire confiance à la configuration.

Pour le mesurer, ajoutez cache: 'npm' ou l’étape manuelle à un workflow, poussez deux fois, et comparez la durée de l’étape npm ci entre l’exécution à froid et celle à chaud. Cette différence, c’est ce que vaut le changement. Si un job doit construire le cache et que d’autres jobs doivent seulement le lire, remplacez actions/cache par actions/cache/restore@v6 et actions/cache/save@v6, qui séparent les deux moitiés pour que vous décidiez où chacune s’exécute.

Articles associés