GitHub Actions: правильное кэширование зависимостей pnpm
Скорость CI на простых проектах решает: разработчик ждёт PR-checks, чтобы влить ветку. Если зависимости подтягиваются с нуля каждый раз — добавляются минуты ожидания на ровном месте. Расскажу, как я настраиваю кэш pnpm в GitHub Actions, чтобы он реально помогал, а не делал вид.
Зачем именно pnpm
pnpm в 2026-м стал моим default-выбором: он компактнее, точнее по lockfile, поддерживает workspaces и не страдает от npm ERR! ERESOLVE. Кэш у него тоже устроен иначе: один глобальный store с hard-link на файлы, а не node_modules в каждом проекте. Это влияет на то, что именно нужно кэшировать в CI.
Базовая настройка
Минимальный workflow, который у меня лежит как стартер:
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm test --reporter=dot
- run: pnpm buildЧто здесь важно:
- concurrency. Если в один PR кто-то сделал три коммита, отменим лишние прогоны.
- pnpm/action-setup@v4 до setup-node. Иначе setup-node не найдёт pnpm для кэширования.
- cache: 'pnpm' в setup-node. Это и есть включение кэша pnpm-store, ничего больше делать не надо.
pnpm install --frozen-lockfile. На CI обязательно: лочит версии под lockfile, не позволяет ничего обновить.
На свежем кэше первый прогон идёт около минуты на средний проект. На последующих — секунды.
Что кэшируется и что нет
setup-node кэширует только pnpm-store (или npm cache, или yarn cache, в зависимости от менеджера). node_modules и .pnpm-store в репозитории не кэшируются.
В рабочем каталоге pnpm создаёт node_modules и в нём симлинки/hard-links на файлы из глобального store. На сервере GitHub Actions глобальный store по умолчанию в ~/.local/share/pnpm/store. Именно туда action кладёт восстановленный кэш.
Из-за этого pnpm install при попадании в кэш ускоряется значительно: модули не качаются, симлинки строятся.
Custom-кэш для node_modules
Иногда хочется кэшировать ещё и node_modules, чтобы не ждать построение симлинков. Это делается через actions/cache с ключом по lockfile:
- uses: actions/cache@v4
with:
path: |
node_modules
**/node_modules
key: pnpm-modules-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-modules-${{ runner.os }}-На моём опыте — выигрывает 5–15 секунд, потому что pnpm install и без того быстрый при попадании в store-кэш. Возьму это, если есть workspaces с десятками пакетов и стройка симлинков заметно тормозит.
Работа с workspaces
На монорепе с pnpm workspaces всё то же самое работает по тем же правилам. Один lockfile в корне — один кэш-ключ. Если кто-то добавил/удалил зависимость в любом пакете — lockfile поменялся — кэш пересобирается.
- run: pnpm install --frozen-lockfile
- run: pnpm -r --filter './apps/api...' buildФильтры pnpm удобно использовать, чтобы билдить только нужный кусок. ./apps/api... с многоточием означает «api и его внутренние зависимости».
Только нужные jobs запускаются
Если у тебя несколько jobs (frontend, backend, тесты) — каждый из них тратит свои минуты. Я часто использую paths и paths-ignore, чтобы не запускать ненужное:
on:
push:
paths:
- 'apps/api/**'
- 'packages/**'
- 'pnpm-lock.yaml'
- '.github/workflows/api-ci.yml'На больших монорепо это снимает половину нагрузки на CI. Внимание: на github paths-ignore смотрит на коммит целиком, не на конкретный файл. Если в одном PR изменился код api и код client — оба workflow стартуют.
Подводные камни
Кэш не подхватывается
Самое частое — кэш отдельный для каждого runner.os и каждой версии pnpm. Если у тебя в matrix несколько Node-версий, на каждую будет свой кэш. Это нормально. Если переключился с pnpm 8 на 9 — первый прогон будет долгим, ничего не сделать.
«Lockfile not up to date»
--frozen-lockfile валидно роняет CI, если кто-то добавил пакет, не обновив lockfile. Это не баг, это фича. Лечится pnpm install локально и коммитом lockfile.
setup-node перед action-setup
Если поставить setup-node раньше pnpm/action-setup, опция cache: 'pnpm' упадёт с «pnpm not found». Порядок строгий.
Огромный store
Глобальный store растёт с каждым новым пакетом. На многих ветках с разными версиями он легко уходит за гигабайт. У GitHub Actions есть лимит на размер кэша на репозиторий (10 ГБ суммарно), и старые кэши вытесняются. На моих проектах не упирался — но если у тебя десятки веток в активной разработке, добавь pnpm store prune в шаг билда, чтобы убрать неиспользуемое.
Кэш сбивается из-за хеша
Если используешь ${{ hashFiles('pnpm-lock.yaml') }}, и lockfile меняется на каждом коммите (например, кто-то регенерирует автоматически) — кэш не помогает. Нужна гигиена с lockfile.
Полный пример с тестами и сборкой
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
retention-days: 7
build:
runs-on: ubuntu-24.04
needs: lint-and-test
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/На чистом дереве этот workflow проходит ~2 минуты, на повторных запусках с кэшем — около минуты. Тесты и билд — две отдельные jobs: можно запускать параллельно, если убрать needs, на больших проектах ускоряет ещё раз в полтора.
Self-hosted runners
Если есть self-hosted runner, кэш всё ещё работает: action-setup и setup-node кэшируют в стандартные места внутри runner-а. Преимущество self-hosted — быстрее CPU и сеть, плюс возможность держать дополнительные кэши прямо на диске. Если у тебя self-hosted на VPS, имеет смысл также примонтировать ~/.local/share/pnpm/store как persistent volume — кэш будет переживать пересборки runner-а.
Что у меня в чек-листе
- concurrency с cancel-in-progress.
- pnpm/action-setup, потом setup-node с cache: 'pnpm'.
--frozen-lockfileв install.- На монорепе — фильтры по pnpm.
pathsв on, чтобы не запускать ненужные jobs.- Артефакты coverage и dist через upload-artifact с retention.
Этот шаблон у меня переиспользуется на десятке проектов с минимальными изменениями. Никаких самописных кэш-ключей, никаких хитростей. Простая настройка, которая стабильно работает.