Aller au contenu
Cache et artefacts

Cache et artefacts

200 Pratiquer ⏱ 1 h 10 github-actionsci-cdpython

À la fin, vous saurez

  • Construire une clé de cache qui change exactement quand il le faut
  • Prévoir quelles exécutions peuvent lire et écrire un cache, selon la branche et le déclencheur
  • Mesurer le gain réel d'un cache avant de l'ajouter
  • Conserver des rapports et transmettre des fichiers entre jobs avec les artefacts
  • Expliquer l'empoisonnement de cache et appliquer les protections disponibles en 2026

Prérequis

Testé avec actionlint 1.7.12 actions/cache v6.1.0 actions/download-artifact v8.0.1 actions/setup-python v7.0.0 actions/upload-artifact v7.0.1 python 3.14.7 zizmor 1.30.1 , vérifié le 1 octobre 2026

Pourquoi

Chaque job démarre sur une machine neuve. C'est la garantie d'un résultat qui ne dépend que du commit, et c'est aussi un coût : à chaque run, pip retélécharge les mêmes paquets, et tout ce que produit un job (rapport de tests, couverture, binaire) disparaît avec sa machine.

Deux mécanismes répondent à ces deux problèmes, et le cours CI/CD : les principes a insisté sur leur différence :

ArtefactCache
RôleRésultat du run, que l'on consulte ou livreAccélérateur, qu'on aurait pu retélécharger
Si on le perdIl faut refaire le runLe run est seulement plus lent
Lié àUn run précisUne clé, partagée entre runs et branches
Peut-on lui faire confiance ?Oui, s'il vient d'un run de confianceNon : contenu non signé, partagé

GitHub Actions fournit les deux, avec des règles de portée, de durée et de sécurité que l'on doit connaître pour ne pas obtenir un cache inutile, un pipeline faux, ou une porte d'entrée pour un attaquant. La dernière colonne n'est pas théorique : en décembre 2024 (Ultralytics) puis en mai 2026 (TanStack), des attaquants ont publié des versions compromises de paquets très utilisés en passant par le cache de GitHub Actions.

Les concepts

Clé, chemin, clés de restauration

Une entrée de cache est une archive d'un ou plusieurs répertoires (path), rangée sous une clé (key). Au début du job, l'action cherche une entrée ; à la fin, si le job a réussi, elle en enregistre une.

- uses: actions/cache@v6
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-${{ hashFiles('requirements*.txt') }}
    restore-keys: |
      pip-${{ runner.os }}-

La recherche suit un ordre précis :

  1. une entrée dont la clé est exactement key : c'est un succès (cache-hit vaut true), le répertoire est restauré ;
  2. sinon, une entrée dont la clé commence par key ;
  3. sinon, pour chaque ligne de restore-keys dans l'ordre, une entrée dont la clé commence par cette ligne, en prenant la plus récente si plusieurs correspondent ;
  4. sinon, rien : le job part d'un répertoire vide.

Dans les cas 2 et 3, le répertoire est restauré mais cache-hit vaut false, et une nouvelle entrée sera enregistrée sous key en fin de job. Les clés de restauration servent donc à partir d'un cache proche (les paquets de la semaine dernière) plutôt que de rien.

Trois propriétés complètent le tableau :

  • Une entrée est immuable. Une clé déjà enregistrée n'est jamais réécrite. Si la clé ne change pas, le cache ne se met jamais à jour, même si son contenu devient obsolète.
  • L'enregistrement n'a lieu que si le job réussit. La sauvegarde est faite par l'étape de fin de l'action (Post), avec la condition success() : un job en échec ne pollue pas le cache.
  • Une entrée a aussi une « version », calculée à partir de path et de l'outil de compression. Deux jobs qui utilisent la même clé avec des chemins différents, ou sur des systèmes différents, ne partagent pas le même cache.

La clé : hashFiles

La bonne clé change exactement quand le contenu du cache doit changer. Pour des dépendances, c'est quand les fichiers qui les décrivent changent. La fonction hashFiles calcule une empreinte de ces fichiers. Son implémentation, dans le code de l'agent, est courte : elle parcourt les fichiers qui correspondent aux motifs, calcule le SHA-256 de chacun, et renvoie le SHA-256 de la suite de ces empreintes. Les fichiers hors de l'espace de travail sont ignorés, et si aucun fichier ne correspond, le résultat est une chaîne vide.

Le petit programme suivant fait le même calcul, pour voir la clé réagir :

"""Imite hashFiles() du runner : SHA-256 des empreintes SHA-256 de chaque fichier."""
import glob
import hashlib
import sys

resultat = hashlib.sha256()
fichiers = sorted(f for motif in sys.argv[1:] for f in glob.glob(motif, recursive=True))
for chemin in fichiers:
    with open(chemin, "rb") as fichier:
        resultat.update(hashlib.sha256(fichier.read()).digest())
print(resultat.hexdigest() if fichiers else "", f"({len(fichiers)} fichiers)")
$ python3 hash_fichiers.py "requirements*.txt"
8b6dcb648e981d471d951ce7496ddd22acc364333dea4f8f5fe4e9ddf8f38069 (2 fichiers)
$ sed -i "s/ruff==0.16.9/ruff==0.16.10/" requirements-dev.txt
$ python3 hash_fichiers.py "requirements*.txt"
d3144301b96e54d3b3fd05ea856c6f2d77a18f9742330ae8e672b954d1843a2d (2 fichiers)
$ python3 hash_fichiers.py "requirement*.lock"
 (0 fichiers)

La dernière ligne est le piège classique : un motif qui ne trouve rien (faute de frappe, fichier renommé, checkout pas encore fait) donne une chaîne vide, la clé devient constante (pip-Linux-), le premier run l'enregistre, et plus aucun run ne la met jamais à jour.

Qui peut lire, qui peut écrire

Les caches ne sont pas partagés librement entre toutes les exécutions d'un dépôt. Ils sont rangés par référence (branche ou étiquette), et un run peut restaurer :

  • les caches de sa propre branche ;
  • ceux de la branche par défaut (main) ;
  • pour une demande de fusion, ceux de la branche cible.

Il ne peut pas lire les caches d'une branche sœur ni d'une branche fille, ni ceux d'une autre étiquette. Un cache créé par un run de demande de fusion est rangé sous la référence de fusion (refs/pull/<n>/merge) : seuls les runs suivants de cette demande de fusion (relances et nouveaux commits, qui partagent la même référence) peuvent le réutiliser.

    flowchart TB
  M["main<br/>(alimenté par les push)"]
  F["fonctionnalite-a"]
  P["refs/pull/12/merge<br/>(demande de fusion de fonctionnalite-a)"]
  S["fonctionnalite-b"]
  F -->|lit| M
  P -->|lit| M
  P -->|lit| F
  S -->|lit| M
  S -.-x|ne lit pas| F
  

La conséquence pratique est importante : pour que les demandes de fusion profitent du cache, il faut que main l'alimente, par un workflow déclenché par push. C'est le cas du workflow de Signalements.

Depuis juin 2026, une seconde règle protège la branche par défaut : seuls les déclencheurs de confiance (push, workflow_dispatch, repository_dispatch, schedule, delete, registry_package, page_build) peuvent créer des entrées dans sa portée. Les runs déclenchés par pull_request_target, issue_comment ou workflow_run, qui s'exécutent dans le contexte de la branche par défaut mais peuvent être provoqués par n'importe qui, ont un accès en lecture seule. Une tentative d'enregistrement échoue alors avec un simple avertissement, sans faire échouer le job.

cache-mode

Depuis septembre 2026, une clé cache-mode, au niveau du workflow ou du job, fixe l'accès au cache accordé au jeton du job :

ValeurRestaurerEnregistrer
readouinon
writeouioui
write-onlynonoui
nonenonnon

Sans cache-mode, l'accès vaut write pour un déclencheur de confiance et read pour un déclencheur à faible confiance. Le runner expose la valeur effective dans la variable ACTIONS_CACHE_MODE, que respectent actions/cache et la bibliothèque @actions/cache sur laquelle reposent les actions setup-* ; une opération interdite est simplement sautée, comme un échec de recherche. Déclarer write sur un workflow à faible confiance réintroduit le risque que la règle précédente supprime : la documentation le signale explicitement.

Comme pour la file de concurrence de la leçon 2, l'outillage n'a pas encore suivi :

$ actionlint .github/workflows/cm.yml
.github/workflows/cm.yml:3:1: unexpected key "cache-mode" for "workflow" section. expected one of "concurrency", "defaults", "env", "jobs", "name", "on", "permissions", "run-name" [syntax-check]
  |
3 | cache-mode: read
  | ^~~~~~~~~~~
.github/workflows/cm.yml:7:5: unexpected key "cache-mode" for "job" section. expected one of "concurrency", "container", "continue-on-error", "defaults", "env", "environment", "if", "name", "needs", "outputs", "permissions", "runs-on", "secrets", "services", "snapshot", "steps", "strategy", "timeout-minutes", "uses", "with" [syntax-check]
  |
7 |     cache-mode: write
  |     ^~~~~~~~~~~

Les limites

Une entrée qui n'a pas été lue depuis 7 jours est supprimée. L'ensemble des caches d'un dépôt est limité à 10 Go ; au-delà, les entrées les moins récemment utilisées sont évincées. Depuis novembre 2025, un dépôt d'un compte payant peut relever cette limite, l'excédent étant facturé. Un dépôt peut créer au plus 200 entrées par minute, et en télécharger au plus 1 500 par minute.

Les artefacts

Un artefact est un ensemble de fichiers téléversé par un job et rattaché au run. On le télécharge depuis la page du run, depuis l'API, ou depuis un autre job du même run. Contrairement au cache, il ne se partage pas entre runs (sauf à le demander explicitement), et il est conservé selon une durée de rétention : 90 jours par défaut, réglable de 1 à 90 jours pour un dépôt public, de 1 à 400 jours pour un dépôt privé, et ajustable artefact par artefact avec retention-days. Le stockage des artefacts est décompté sur le quota du compte (500 Mo pour le plan gratuit, partagés avec GitHub Packages).

Depuis la version 4 de upload-artifact, un artefact est immuable : son nom est unique dans le run, et on ne peut plus y ajouter de fichiers après coup. La version 7 ajoute le téléversement direct d'un fichier seul, sans archive zip (archive: false) ; la version 8 de download-artifact vérifie l'empreinte de chaque artefact téléchargé et échoue par défaut en cas de différence (digest-mismatch: error).

En pratique

Mesurer avant de mettre en cache

Un cache a un coût : il faut télécharger et décompresser l'archive au début, la compresser et la téléverser à la fin quand la clé change. Avant d'en ajouter un, mesurez ce qu'il fait gagner. Pour Signalements, on peut le mesurer sur un poste en installant les dépendances avec un répertoire de cache pip vide, puis rempli, trois fois chacun :

$ for i in 1 2 3; do
>   for essai in vide rempli; do
>     [ $essai = vide ] && { rm -rf ../pip-cache-v; mkdir ../pip-cache-v; c=../pip-cache-v; } || c=../pip-cache
>     docker run --rm -v "$PWD":/src:ro -v "$(realpath $c)":/cache -w /src --user "$(id -u):$(id -g)" \
>       -e HOME=/tmp -e PIP_CACHE_DIR=/cache -e PIP_DISABLE_PIP_VERSION_CHECK=1 python:3.14-slim \
>       bash -c 'debut=$(date +%s%N); pip install --user --quiet --no-warn-script-location -r requirements-dev.txt;
>                echo "'$essai' : $(( ($(date +%s%N) - debut) / 1000000 )) ms"'
>   done
> done
vide : 6791 ms
rempli : 4305 ms
vide : 7447 ms
rempli : 4473 ms
vide : 7294 ms
rempli : 3853 ms
$ du -sh ../pip-cache
22M	../pip-cache

(Chaque mesure est un pip install -r requirements-dev.txt dans un conteneur python:3.14-slim neuf, avec PIP_CACHE_DIR pointant vers un répertoire vidé juste avant, ou vers ../pip-cache, rempli par une première installation.)

Le cache fait gagner environ trois secondes, sur une archive de 22 Mo qu'il faut elle-même télécharger et décompresser. Le bénéfice est réel mais modeste : Signalements a peu de dépendances, et ce sont des roues précompilées. Sur un projet qui compile ses dépendances, ou qui en a des centaines (un projet Node.js typique), le rapport est tout autre, et le cache divise parfois la durée par dix. La bonne décision dépend de la mesure, pas du réflexe.

Ici, on garde le cache : il ne coûte qu'une ligne, et il réduit aussi la dépendance au dépôt de paquets public (moins de téléchargements, moins d'échecs quand PyPI est lent).

Le cache intégré de setup-python

Pour les cas courants, les actions setup-* (Python, Node, Go, Java) intègrent le cache : elles connaissent le répertoire du gestionnaire de paquets et construisent la clé elles-mêmes.

- name: Installer Python
  uses: actions/setup-python@v7
  with:
    python-version: ${{ env.PYTHON_VERSION }}
    cache: pip
    cache-dependency-path: requirements*.txt

cache: pip met en cache le répertoire de téléchargement de pip (~/.cache/pip), pas les paquets installés : pip install s'exécute toujours, mais sans retélécharger. cache-dependency-path désigne les fichiers dont l'empreinte entre dans la clé ; sans lui, l'action cherche un requirements.txt ou un pyproject.toml dans le dépôt, et ne tiendrait pas compte ici de requirements-dev.txt : une montée de version de Ruff ne changerait pas la clé. Ajoutez les mêmes deux lignes au job d'intégration : chaque combinaison de la matrice a sa version de Python, donc sa propre entrée, puisque la version fait partie de la clé construite par l'action.

Conserver les rapports

Les rapports de tests et de couverture sont des artefacts : ils appartiennent à un run, on veut les consulter quand ce run échoue. Le job verifier produit maintenant un rapport de couverture HTML, et téléverse les deux rapports :

      - name: Tests unitaires
        id: tests
        run: pytest -q --cov=app --cov-report=html:couverture --junitxml=rapport-unitaires.xml test_app.py

      - name: Résumé des tests
        if: ${{ !cancelled() && steps.tests.outcome != 'skipped' }}
        run: python ci/resume_tests.py rapport-unitaires.xml

      - name: Conserver les rapports
        if: ${{ !cancelled() && steps.tests.outcome != 'skipped' }}
        uses: actions/upload-artifact@v7
        with:
          name: rapports-unitaires
          path: |
            rapport-unitaires.xml
            couverture/
          retention-days: 14
          if-no-files-found: error
  • La même condition que le résumé : les rapports sont surtout utiles quand les tests échouent.
  • retention-days: 14 : un rapport de tests sert pendant la relecture d'une demande de fusion, pas pendant trois mois. Une rétention courte économise le quota de stockage.
  • if-no-files-found: error : par défaut, l'action se contente d'un avertissement si les chemins ne correspondent à rien. Ici, l'absence de rapport après des tests signale un problème, autant qu'il soit visible.

Passer des fichiers entre jobs : la matrice

Chaque job de la matrice d'intégration produit son propre rapport. Comme un nom d'artefact est unique dans le run, le nom doit inclure la combinaison :

      - run: pytest -q --junitxml=rapport-integration.xml test_integration.py

      - if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v7
        with:
          name: rapport-integration-py${{ matrix.python }}-pg${{ matrix.postgres }}
          path: rapport-integration.xml
          retention-days: 14

Un job suivant rassemble les rapports et publie un résumé par combinaison :

  bilan-integration:
    name: Bilan de l'intégration
    needs: integration
    if: ${{ !cancelled() && needs.integration.result != 'skipped' }}
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
          sparse-checkout: ci

      - uses: actions/download-artifact@v8
        with:
          pattern: rapport-integration-*
          path: rapports

      - name: Résumé par combinaison
        run: |
          for rapport in rapports/*/rapport-integration.xml; do
            combinaison=$(basename "$(dirname "$rapport")")
            python3 ci/resume_tests.py "$rapport" "${combinaison#rapport-}"
          done
  • sparse-checkout: ci : le job n'a besoin que du répertoire ci/, inutile de récupérer le reste.
  • pattern télécharge tous les artefacts dont le nom correspond, chacun dans un sous-répertoire à son nom (rapports/rapport-integration-py3.14-pg18.6/). Avec merge-multiple: true, ils seraient fusionnés dans un seul répertoire, et des fichiers de même nom s'écraseraient.
  • Le script resume_tests.py accepte maintenant un titre en second argument. Ajoutez bilan-integration à la liste needs de l'agrégateur ci-terminee.

La boucle se vérifie localement, sur l'arborescence que produit download-artifact (ici avec deux rapports réels, produits contre PostgreSQL 17.11 et 18.6) :

$ ls -R rapports
rapports:
rapport-integration-py3.13-pg17.11
rapport-integration-py3.14-pg18.6

rapports/rapport-integration-py3.13-pg17.11:
rapport-integration.xml

rapports/rapport-integration-py3.14-pg18.6:
rapport-integration.xml
$ for rapport in rapports/*/rapport-integration.xml; do
>   combinaison=$(basename "$(dirname "$rapport")")
>   python3 ci/resume_tests.py "$rapport" "${combinaison#rapport-}"
> done
### integration-py3.13-pg17.11

| Total | Réussis | Échoués | Ignorés | Durée |
|---:|---:|---:|---:|---:|
| 2 | 2 | 0 | 0 | 0.50 s |
### integration-py3.14-pg18.6

| Total | Réussis | Échoués | Ignorés | Durée |
|---:|---:|---:|---:|---:|
| 2 | 2 | 0 | 0 | 0.42 s |

Récupérer un artefact

Depuis le terminal, gh télécharge les artefacts d'un run :

$ gh run download <identifiant-du-run> --name rapports-unitaires --dir rapports

Un workflow peut aussi télécharger un artefact d'un autre run avec download-artifact, en lui donnant run-id et un github-token qui a la permission actions: read. C'est le mécanisme qu'utilisent les workflows workflow_run pour exploiter le résultat d'un workflow non privilégié, et c'est un vecteur d'attaque (leçon 11) : un artefact produit par une demande de fusion venue d'une bifurcation est une donnée non fiable.

Sous le capot

Le cache et les artefacts ne sont pas stockés sur le runner, mais dans un service de stockage de GitHub. Au démarrage du job, le runner reçoit, avec le message de job, l'adresse de ce service et un jeton d'exécution distinct du GITHUB_TOKEN. Les actions cache, upload-artifact et download-artifact s'en servent pour obtenir des adresses de téléversement et de téléchargement signées, puis transfèrent les archives directement. Ce jeton n'est pas gouverné par la clé permissions: du workflow : c'est ce que soulignait le compte rendu de l'incident TanStack, et c'est la limite que cache-mode vient combler, en restreignant enfin ses droits sur le cache.

L'enregistrement du cache se fait dans l'étape Post de l'action, en fin de job, avec la condition success(). Les étapes actions/cache/restore et actions/cache/save, utilisables séparément, permettent de restaurer sans jamais enregistrer (le bon choix pour un job qui traite des données non fiables), ou d'enregistrer à un moment précis.

Un artefact est une archive zip (sauf avec archive: false), téléversée en une fois. Depuis la version 4, il est disponible dès la fin du job qui l'a produit, sans attendre la fin du run, et l'action renvoie son identifiant, son adresse et son empreinte (artifact-digest). C'est cette empreinte que la version 8 de download-artifact vérifie.

Pièges courants

Le cache n'est jamais utilisé. La clé contient une valeur qui change à chaque run (github.sha, github.run_id, une date), ou le chemin diffère entre l'enregistrement et la restauration (et donc la version), ou le run est une demande de fusion qui ne peut pas lire le cache d'une autre demande. Le journal de l'étape indique la clé cherchée et le résultat.

Le cache n'est jamais mis à jour. La clé ne change jamais : hashFiles ne trouve aucun fichier et renvoie une chaîne vide, ou la clé ne dépend pas des fichiers de dépendances. Une entrée existante n'est jamais réécrite.

Le cache n'est jamais enregistré. Le job échoue (enregistrement seulement en cas de succès), ou le déclencheur n'a qu'un accès en lecture (un avertissement le signale dans le journal), ou la clé existe déjà.

Les caches disparaissent. Le dépôt dépasse 10 Go, et les entrées les moins récemment utilisées sont évincées : de nombreuses branches et une matrice large suffisent. La page Actions > Caches du dépôt liste les entrées, leur taille et leur dernière utilisation.

Le téléversement échoue parce que le nom existe déjà. Deux jobs d'une matrice téléversent sous le même nom, et un nom d'artefact est unique dans le run. Incluez les valeurs de la matrice dans le nom (ou utilisez overwrite: true si l'on veut réellement remplacer).

Le fichier .coverage manque à l'artefact. Depuis la version 4.4 de upload-artifact, les fichiers et répertoires cachés (dont le nom commence par un point) sont exclus par défaut, ce qui surprend avec les fichiers de données de couverture :

$ pytest -q --cov=app --cov-report=html:couverture --junitxml=rapport-unitaires.xml test_app.py | tail -6
....                                                                     [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.7-final-0 ________________

Coverage HTML written to dir couverture
4 passed in 0.31s
$ ls -a | grep -E "coverage|couverture|rapport"
couverture
.coverage
rapport-unitaires.xml

Pour l'inclure, il faut include-hidden-files: true, en sachant que cela inclut aussi tout autre fichier caché du chemin.

Un artefact téléchargé échoue avec une erreur d'empreinte. Avec download-artifact@v8, une différence entre l'empreinte annoncée et le contenu reçu fait échouer l'étape. C'est le comportement voulu ; ne le désactivez pas sans comprendre la cause.

Sécurité

L'empoisonnement de cache

Le contenu d'un cache n'est ni signé ni vérifié. Tout run qui peut écrire une entrée sous une clé que lira un run plus privilégié peut y placer ce qu'il veut : un paquet modifié dans le cache de pip, un exécutable dans le cache du gestionnaire de paquets. Le run privilégié restaure l'archive, exécute le code, et fait ce que l'attaquant voulait avec ses droits à lui.

Les deux incidents publics les plus connus suivent ce schéma :

  • Ultralytics, décembre 2024. Un workflow pull_request_target interpolait le nom de la branche proposée dans un script (l'injection de la leçon 3). L'attaquant y a exécuté du code, a placé du contenu malveillant dans le cache de la branche par défaut, et le workflow de publication, qui restaurait ce cache, a publié sur PyPI des versions 8.3.41 et 8.3.42 contenant un mineur de cryptomonnaie.
  • TanStack, mai 2026. Selon le compte rendu publié par le projet, une demande de fusion a déclenché un workflow pull_request_target, qui a empoisonné le stock de paquets pnpm mis en cache ; le workflow de publication l'a restauré, et l'attaquant a pu publier 84 versions malveillantes de 42 paquets npm.

Les protections de 2026 ferment la voie principale : les déclencheurs à faible confiance n'écrivent plus dans la portée de la branche par défaut, et cache-mode permet d'interdire toute écriture. Elles ne dispensent pas des règles de fond :

  • Un workflow qui publie ne restaure pas de cache. Ses dépendances sont téléchargées et vérifiées (empreintes dans un fichier de dépendances verrouillé), pas extraites d'une archive que d'autres runs ont pu écrire. Mettez cache-mode: none sur le job de publication et n'activez pas le cache des actions setup-*.
  • Restaurez sans enregistrer (actions/cache/restore, ou cache-mode: read) dans tout job qui exécute du code non fiable.
  • Ne mettez jamais de secret dans un cache : quiconque peut ouvrir une demande de fusion peut lire les caches de la branche cible.

zizmor repère le motif le plus dangereux, un workflow de publication qui active un cache :

$ uvx zizmor --offline .github/workflows/publier.yml
error[cache-poisoning]: runtime artifacts potentially vulnerable to a cache poisoning attack
  --> .github/workflows/publier.yml:17:9
   |
 2 | / on:
 3 | |   push:
 4 | |     tags: ["v*"]
   | |________________- generally used when publishing artifacts generated at runtime
...
17 |         - uses: actions/setup-python@v7
   |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this step
...
20 |             cache: pip
   |             ---------- enables caching explicitly here
   |
   = note: audit confidence → Low
   = help: audit documentation → https://docs.zizmor.sh/audits/#cache-poisoning

(Les autres constats de la même analyse, sur l'épinglage des actions, sont omis.) La confiance est notée « basse » : zizmor ne peut pas savoir si l'étape publie réellement. C'est un signal à examiner, pas un verdict.

Les artefacts qui fuient

Téléverser tout l'espace de travail (path: .) est une mauvaise idée pour une raison simple : on ne sait pas ce qu'il contient. Fichiers .env de test, clés générées pour un test, et, avec les anciennes versions de checkout, le jeton d'accès lui-même, écrit dans .git/config. zizmor le signale sous le nom artipacked :

$ uvx zizmor --offline .github/workflows/artefact.yml
warning[artipacked]: credential persistence through GitHub Actions artifacts
  --> .github/workflows/artefact.yml:9:9
   |
 9 |         - uses: actions/checkout@v7
   |           ^^^^^^^^^^^^^^^^^^^^^^^^^ does not set persist-credentials: false
10 |         - run: pytest --cov=app --cov-report=html:couverture
11 |         - uses: actions/upload-artifact@v7
   |  _________-
12 | |         with:
13 | |           name: espace-de-travail
14 | |           path: .
15 | |           include-hidden-files: true
   | |_____________________________________- may leak the credentials persisted above
   |
   = note: audit confidence → High
   = note: this finding has an auto-fix
   = help: audit documentation → https://docs.zizmor.sh/audits/#artipacked

Depuis sa version 6, checkout range le jeton dans un fichier séparé, sous RUNNER_TEMP, hors de l'espace de travail, et .git/config ne contient plus qu'une directive includeIf qui pointe vers lui : avec checkout@v7, ce téléversement ne contiendrait donc plus le jeton. Le constat reste juste dans son principe, et décisif pour les dépôts qui utilisent encore une version plus ancienne : persist-credentials: false, et des chemins d'artefacts précis, jamais l'espace de travail entier.

Un artefact est aussi lisible par toute personne qui peut voir le dépôt : sur un dépôt public, par tout le monde. Un rapport de tests qui contient des données de production, ou des journaux avec des adresses internes, n'a rien à faire dans un artefact public.

En production

Le quota de stockage. Sur le plan gratuit, 500 Mo pour les artefacts et les paquets réunis. Un rapport de couverture HTML fait quelques centaines de kilo-octets ; une image de conteneur exportée en archive, plusieurs centaines de méga-octets. Les images vont dans un registre (leçon 6), les rapports dans des artefacts à rétention courte.

Les gros dépôts. Avec beaucoup de branches et une matrice large, 10 Go de cache sont vite atteints, et les évictions annulent le bénéfice (le cache est enregistré, évincé, réenregistré). Restreignez l'écriture du cache aux runs de main (cache-mode: read sur les demandes de fusion, qui liront le cache de main) : moins d'entrées, mieux réutilisées.

Le cache de construction d'images suit les mêmes règles de portée et de quota quand on utilise le stockage type=gha de BuildKit, comme le fait le workflow de ce site. La leçon suivante y revient.

Les dépendances verrouillées. Le cache accélère, mais seul un fichier de dépendances avec empreintes (pip install --require-hashes, package-lock.json, uv.lock) garantit que ce qui est installé est ce qui a été relu. Les deux se combinent : un cache rempli de paquets dont l'empreinte est vérifiée à l'installation ne peut pas faire installer un paquet modifié.

Exercices

1. Pour chacune de ces clés, dites ce qui ne va pas : (a) pip-${{ github.sha }} ; (b) pip-${{ hashFiles('requirement.txt') }} dans un dépôt qui a requirements.txt ; (c) pip-${{ hashFiles('requirements*.txt') }} dans un job qui contient aussi Windows dans sa matrice.

Solution

(a) La clé change à chaque commit : jamais de succès exact. Avec restore-keys: pip-, on restaurerait le cache le plus récent, mais on enregistrerait une nouvelle entrée à chaque run, ce qui remplit vite les 10 Go. (b) Le motif ne correspond à aucun fichier : hashFiles renvoie une chaîne vide, la clé est constante, le cache n'est jamais mis à jour. (c) Les jobs Linux et Windows ont la même clé, mais pas la même version de cache (outil de compression et chemins différents) : ils ne se gênent pas, mais il vaut mieux inclure runner.os dans la clé pour que les entrées soient lisibles et que restore-keys reste précis.

2. Une équipe constate que ses demandes de fusion ne profitent jamais du cache, alors que main en a. Son workflow se déclenche uniquement sur pull_request. Expliquez et corrigez.

Solution

Les runs de demande de fusion écrivent leurs entrées sous refs/pull/<n>/merge, que seuls les runs suivants de la même demande peuvent lire (le changement de juin 2026 sur le cache en lecture seule ne concerne pas pull_request, dont la portée n'est pas celle de la branche par défaut). Ils peuvent lire les caches de main, mais si aucun workflow ne s'exécute sur main, il n'y en a pas (ou seulement d'anciens, évincés au bout de 7 jours sans lecture). Correction : déclencher aussi le workflow sur push vers main, pour qu'il alimente le cache que liront toutes les demandes de fusion.

3. Écrivez les étapes d'un job qui restaure le cache de pip sans jamais l'enregistrer, et dites dans quel cas l'utiliser.

Solution
- uses: actions/cache/restore@v6
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-${{ hashFiles('requirements*.txt') }}
    restore-keys: pip-${{ runner.os }}-

Ou, au niveau du job, cache-mode: read. À utiliser dans tout job qui exécute du code non fiable (demande de fusion d'une bifurcation, workflow pull_request_target ou workflow_run), pour qu'il ne puisse rien écrire qu'un job plus privilégié restaurerait.

4. La matrice d'intégration de la leçon 4 est étendue à trois versions de Python et trois de PostgreSQL. Combien d'artefacts de rapports sont produits par run, et combien de stockage cela représente-t-il par mois pour 25 runs par jour ouvré, avec des rapports de 5 Ko et une rétention de 14 jours ? Et avec la rétention par défaut ?

Solution

Neuf combinaisons, plus l'entrée expérimentale si on la garde : 10 artefacts de 5 Ko par run, soit 50 Ko. À 25 runs par jour ouvré, environ 10 jours ouvrés tiennent dans 14 jours : 250 runs, soit environ 12,5 Mo stockés en permanence. Avec 90 jours, environ 63 jours ouvrés : 1 575 runs, environ 79 Mo, soit 16 % du quota de 500 Mo du plan gratuit, pour des rapports que personne ne consulte au-delà de quelques jours. Le rapport de couverture HTML, bien plus gros, rend le calcul encore plus net.

5. Le workflow de publication d'un collègue installe ses dépendances avec setup-python et cache: pip, se déclenche sur les étiquettes v*, et publie sur PyPI. Rédigez la revue de cette configuration en trois points, et proposez la correction.

Solution
  1. Le job de publication restaure un cache que d'autres runs ont écrit : un attaquant qui a pu écrire une entrée sous une clé compatible fait exécuter son code avec les droits de publication (le scénario Ultralytics).
  2. Les protections de 2026 (lecture seule pour les déclencheurs à faible confiance) réduisent ce risque mais ne le suppriment pas : un run de push compromis autrement (dépendance malveillante dans un test) peut toujours écrire dans la portée de main.
  3. Le gain de temps est négligeable pour un job exécuté à chaque version.

Correction : retirer cache: pip, ajouter cache-mode: none au job, installer les outils de construction depuis un fichier de dépendances verrouillé avec empreintes, et vérifier avec zizmor que l'audit cache-poisoning ne signale plus rien.

Récapitulatif

  • Cache : accélérateur partagé, non fiable, immuable par clé, enregistré seulement si le job réussit, évincé après 7 jours sans lecture ou au-delà de 10 Go.
  • La clé suit les fichiers de dépendances avec hashFiles ; un motif qui ne trouve rien donne une clé constante.
  • Un run lit les caches de sa branche, de la branche cible, et de main : c'est main qui doit alimenter le cache.
  • Depuis 2026 : lecture seule pour les déclencheurs à faible confiance, et cache-mode pour régler l'accès job par job.
  • Mesurez le gain avant d'ajouter un cache.
  • Artefact : résultat d'un run, nom unique, immuable, rétention réglable ; les fichiers cachés sont exclus par défaut.
  • Un workflow qui publie ne restaure pas de cache ; un artefact venu d'une demande de fusion est une donnée non fiable ; on ne téléverse jamais l'espace de travail entier.

Pour aller plus loin

Voir ma constellation →

Sources