Workflow, który przy każdym push uruchamia npm ci, za każdym razem pobiera z registry te same kilkaset pakietów, nawet gdy lockfile nie zmienił się od tygodnia. W projekcie z prawdziwym drzewem zależności to minuta czy dwie stracone, zanim ruszy pierwszy test, i płacisz to ponownie przy każdym push i każdym pull request. actions/cache przechowuje te pobrania między uruchomieniami i przywraca je, gdy nic istotnego się nie zmieniło.

Jak action decyduje o hit czy miss

actions/cache zapisuje każdy wpis pod wartością key, którą sam wybierasz, ograniczoną do brancha, który go utworzył. Uruchomienie może odczytać cache z własnego brancha i z brancha domyślnego. Przy przywracaniu action porównuje twój key z tym, co jest zapisane:

  • Dokładne dopasowanie do key to hit. Pliki są pobierane i job idzie dalej.
  • Brak dokładnego dopasowania przechodzi do restore-keys, sprawdzanych po kolei, z których każdy to prefix match względem istniejących kluczy. Wygrywa najnowsze dopasowanie.
  • Brak jakiegokolwiek dopasowania: krok nic nie przywraca. Job startuje na zimno, a jeśli się powiedzie, zapisuje nowy wpis pod key.

Dlatego standardowy wzorzec haszuje lockfile do klucza:

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-

Zmień jedną linię w package-lock.json, a hash się zmieni, więc dokładny klucz celowo nie trafia. Chcesz cache pasujący do nowego lockfile, nie do tego sprzed tygodnia. Prefix z restore-keys nadal jednak pasuje, więc job startuje od poprzedniego cache zamiast od pustego katalogu, pobiera tylko to, co nowe, i po sukcesie zapisuje świeży wpis z dokładnym dopasowaniem.

Obowiązują dwa limity. Repozytorium ma 10 GB miejsca na cache, współdzielone przez wszystkie jego workflow, a każdy wpis nieużywany przez 7 dni jest usuwany. Nie musisz niczego czyścić ręcznie: gdy repozytorium przekroczy limit, usuwane są najstarsze wpisy, a kolejne uruchomienie, które ich potrzebowało, płaci jednorazowo koszt instalacji na zimno.

Cache npm przez wbudowaną opcję w setup-node

Dla popularnych menedżerów pakietów w ogóle nie piszesz kroku actions/cache. actions/setup-node, actions/setup-python i actions/setup-java przyjmują input cache, który sam zajmuje się kluczem, przywracaniem i zapisem:

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' znajduje package-lock.json (lub npm-shrinkwrap.json) w katalogu głównym repozytorium, liczy jego hash i cache’uje pobrania npm. cache: 'yarn' robi to samo z yarn.lock. W monorepo, gdzie lockfile leży w packages/api/package-lock.json, wskaż go jawnie:

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 działa tak samo: cache: 'pip' (albo 'pipenv', 'poetry'), z domyślnym hashem liczonym z requirements.txt. setup-java przyjmuje cache: 'maven' lub cache: 'gradle'. Zacznij od tego. Do ręcznego kroku actions/cache przechodź tylko wtedy, gdy potrzebujesz klucza albo ścieżki, których setup action ci nie da, albo cache’ujesz coś, co nie ma nic wspólnego z runtime języka.

Cache Maven i pip bezpośrednio przez actions/cache

Ręczny krok zawsze ma te same trzy części: katalog, do którego narzędzie zapisuje swój cache, klucz zbudowany z pliku, który ustala wersje zależności, i fallback po prefiksie.

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 to cache samego narzędzia, nie twojego projektu: cache pobierania pip, nie twój virtualenv; lokalne repozytorium Maven, nie target/. Możesz też cache’ować wynik builda, jak target/ czy dist/, ale opłaca się to tylko przy buildzie inkrementalnym. W przeciwnym razie kompresujesz i wysyłasz pliki, które kolejne uruchomienie i tak wyrzuci.

Jeśli już cache’ujesz warstwy Dockera przez cache-to: type=gha, jak w Budować i Publikować Obraz Docker z GitHub Actions, te warstwy trafiają do tej samej usługi cache Actions. Liczą się do tych samych 10 GB i tego samego 7-dniowego usuwania co cache zależności. Repozytorium, które robi obie rzeczy, osiąga limit szybciej, niż się spodziewasz, a duże cache warstw mogą wypchnąć cache npm.

Klucze cache w buildach matrix

Matrix po wersjach Node albo systemach operacyjnych potrzebuje wartości matrix w kluczu, inaczej wszystkie trzy legi rywalizują o jeden wpis:

 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 }}-

Wpisów cache nie da się nadpisać. Usuń matrix.node-version z klucza, a wszystkie trzy legi spróbują zapisać Linux-npm-<hash>: wygrywa ten, który skończy pierwszy, pozostałe logują warning, że zapis się nie udał. Dla ~/.npm to w większości nieszkodliwe, bo pobrane tarballe nie zależą od wersji Node. Przestaje być nieszkodliwe, gdy ścieżka w cache zawiera skompilowany output, jak node_modules z natywnym addonem. Wtedy leg Node 24 przywraca binaria zbudowane dla Node 20, a ty dostajesz błędy, które wyglądają na wszystko oprócz problemu z cache. runner.os w kluczu robi to samo między legami różnych systemów.

Czego nie cache’ować w GitHub Actions

Traktuj wpis cache jak warstwę obrazu: każdy, kto może uruchomić workflow, który go przywraca, może go odczytać. Trzymaj tokeny i pliki .env z dala od jakiegokolwiek katalogu w cache. Obowiązuje ta sama zasada co w Zmienne Środowiskowe i Secrets w Kontenerach Docker: secrets pochodzą z secrets: w czasie działania, nigdy z czegoś zapisanego na dysku i użytego ponownie.

Dwa przypadki, w których cache’owanie kosztuje więcej, niż oszczędza:

  • Wszystko, co instaluje się w kilka sekund. Garść czystych pakietów Python bez skompilowanych rozszerzeń instaluje się mniej więcej tak szybko, jak się przywraca. Krok cache ma własny narzut na kompresję i przesłanie archiwum, który może pochłonąć tak małą oszczędność.
  • Klucz zbyt szeroki, by kiedykolwiek trafić. Gdy haszujesz cały katalog src/ zamiast lockfile, każda zmiana kodu unieważnia cache. Oprzyj klucz na pliku, który ustala wersje zależności, i na niczym więcej.

Jak sprawdzić, czy cache trafia

Krok actions/cache loguje Cache restored from key: ... przy hicie i Cache not found for input keys: ... przy missie. Przeczytaj tę linię, zanim zaufasz konfiguracji.

Żeby to zmierzyć, dodaj cache: 'npm' albo ręczny krok do jednego workflow, zrób push dwa razy i porównaj, ile trwa krok npm ci przy zimnym i ciepłym uruchomieniu. Ta różnica to realny zysk, jaki daje cache. Jeśli jeden job ma budować cache, a inne joby mają go tylko czytać, zamień actions/cache na actions/cache/restore@v6 i actions/cache/save@v6, które dzielą te dwie połowy, żebyś sam decydował, gdzie która działa.

Powiązane artykuły