Кэш, matrix-сборки и артефакты
Cache по хэшу lockfile восстанавливает зависимости между запусками; matrix гоняет один job по версиям и ОС параллельно; артефакты передают вывод сборки между job. Протухший key хуже, чем отсутствие кэша.
Каждый CI-запуск на чистом раннере переустанавливает 1200 npm-пакетов из сети — три минуты, на каждый пуш, до того как побежит хоть один тест. Кто-то добавляет actions/cache с key: deps, константой. Первый запуск сохраняет node_modules; каждый следующий восстанавливает его. Сборки падают до сорока секунд. Через две недели прилетает апдейт зависимости, тесты проходят зелёными в CI и ломаются в проде — потому что константный key продолжал восстанавливать старый node_modules, а новый lockfile так и не установился. Кэш был быстрым и неправильным. Кэш, чей key никогда не меняется, — это бомба замедленного действия.
Кэш: хэш lockfile — единственный безопасный key
Когда ты видишь, что CI тратит три минуты только на установку пакетов, это или кэш, который ещё не настроен, или сломанный кэш, который никогда не инвалидируется. Вот как сделать правильно.
Каждый CI-job бежит на чистой машине, поэтому без помощи он заново делает каждый дорогой детерминированный шаг — прежде всего установку зависимостей — с нуля на каждый запуск. actions/cache это чинит: сохраняет директорию после первого запуска и восстанавливает на последующих по выбранной тобой строке-ключу. Кардинальное правило живёт целиком в этом key: он должен меняться ровно тогда, когда должно поменяться кэшируемое содержимое, и не раньше. Для зависимостей вход, определяющий node_modules, — это lockfile, поэтому его и хэшируют.
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-hashFiles('**/package-lock.json') даёт чек-сумму, которая меняется в тот же миг, когда меняется любая зависимость. При попадании cache восстанавливается и установка почти мгновенна; при промахе — новый lockfile — key новый, поэтому старая запись не переиспользуется, ты ставишь начисто, и запуск сохраняет новую запись под новым ключом. Вот почему константный key: deps опасен: он никогда не может промахнуться, поэтому восстанавливает протухшие зависимости вечно, а твой новый lockfile молча игнорируется. Всегда привязывай key к содержимому.
restore-keys — это лестница отката. Когда точный key промахивается, GitHub перебирает каждый префикс по очереди и восстанавливает самое свежее частичное совпадение — так что апдейт lockfile всё равно засевает cache из предыдущей записи runner.os-npm-, а не начинает с пустого, и пакетный менеджер качает только дельту. Это тёплый старт, а не шорткат для корректности: точный key по-прежнему решает, считается ли запись актуальной.
▸Почему это работает
У setup-node кэширование встроено: with: { node-version: 22, cache: 'npm' } хэширует твой lockfile и сам управляет ключом — предпочитай его рукотворному actions/cache для кэша пакетного менеджера. На уровне сеньора важны два факта о scope. Кэши скоупятся по ветке с наследованием вниз: ветка может восстановить из своей базы и из main, но не вбок из несвязанной ветки — поэтому же cache, записанный недоверенным fork-PR, не может отравить main. И есть бюджет вытеснения на репозиторий (10 ГБ); при превышении GitHub вытесняет наименее недавно использованные записи, так что неиспользуемые кэши тихо исчезают.
Matrix: одно определение job, много параллельных запусков
Как доказать, что библиотека работает на Node 18, 20 и 22, не копипастя три почти одинаковых job? Именно эту задачу решает strategy.matrix.
strategy.matrix запускает один и тот же job по разу на каждую комбинацию перечисленных измерений — стандартный способ доказать, что код работает на разных версиях Node и ОС, без копипасты job’ов. GitHub разворачивает декартово произведение и планирует каждую комбинацию параллельно.
test:
strategy:
fail-fast: false
max-parallel: 4
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, macos-latest]
include:
- { node: 22, os: ubuntu-latest, coverage: true }
exclude:
- { node: 18, os: macos-latest }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with: { node-version: ${{ matrix.node }} }Эта сетка node × os — это 3 × 2 = 6 job’ов; exclude убирает один, include добавляет лишнее поле к одной существующей комбинации (а с новым значением мог бы добавить и целый job). fail-fast: true (по умолчанию) отменяет всех соседей в момент первого падения — быстрая обратная связь, но ты теряешь полную картину; ставь false, когда нужно увидеть, какие именно комбинации сломались. max-parallel ограничивает, сколько бежит одновременно, дросселируя параллельное использование раннеров. Помни о взрыве: измерения умножаются, поэтому добавление третьей оси на 3 значения превращает 6 job’ов в 18, и каждый ест минуты раннера — matrix это ручка стоимости, а не бесплатное покрытие. Вместе эти ручки дают полный кросс-версионный сигнал из одного определения job; без fail-fast: false одна сломанная ячейка Node 18 может скрыть три других падающих комбинации под собой.
Артефакты: передай вывод из одного job в следующий
Поскольку каждый job — это свежая машина, сборка, собранная в одном job, не существует в следующем — файловая система пропадает, когда job завершается. Cache здесь неправильный инструмент: это keyed, best-effort, возможно-вытесненное ускорение, а не гарантированная передача. Артефакты — правильный инструмент: явные, надёжные-в-пределах-запуска выводы, которые ты загружаешь и потом скачиваешь и которые можешь публиковать как результаты запуска (собранный бандл, отчёты тестов, покрытие).
build:
steps:
- run: npm run build
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
deploy:
needs: build
steps:
- uses: actions/download-artifact@v4
with: { name: dist }
- run: ./deploy.sh dist/Собрал один раз — деплой ровно те же байты, что ты тестировал, без дрейфа пересборки между job’ами. У артефактов есть период хранения (по умолчанию 90 дней, настраивается) и они учитываются в хранилище, поэтому называй их осознанно, а большие держи недолго.
| Измерение | Cache (actions/cache) | Artifact (upload/download-artifact) |
|---|---|---|
| Назначение | Ускорить воспроизводимый шаг (входы можно вывести заново) | Передать / сохранить вывод, который дёшево не вывести заново |
| Срок жизни | Best-effort; LRU-вытеснение в рамках бюджета 10 ГБ на репо | Хранится фиксированный период (по умолчанию 90 дней) |
| Ключуется по | Строке key, которую ты проектируешь (напр. хэш lockfile) | Простому имени на твой выбор (dist, coverage) |
| Между job? | Да, но как подсказка — промах нормален и безопасен | Да, и гарантированно в пределах запуска через needs |
Ты задал key кэша константой ('deps'). Сборки быстрые, но апдейт зависимости, прошедший CI, ломается в проде. Почему?
build-job компилирует dist/; deploy-job с 'needs: build' находит dist/ отсутствующим. Какой механизм правильно его передаёт?
Библиотека должна проверить работу на Node 18/20/22 на Ubuntu и macOS, дать ясный сигнал о падениях и не сжечь CI-минуты. Выбери стратегию matrix.
- 01Почему key кэша, привязанный к хэшу lockfile, безопасен, а константный key опасен, и что добавляет restore-keys?
- 02Cache и артефакты оба двигают файлы между job. Когда использовать каждый и почему не cache для передачи build→deploy?
Кэширование и matrix — два главных рычага CI-пайплайна, а артефакты — то, как job’ы общаются друг с другом. Каждый job бежит на чистой машине, поэтому actions/cache сохраняет и восстанавливает директорию по строке-ключу, которую ты проектируешь, — и вся игра в этом key: привяжи его к hashFiles(’**/package-lock.json’), чтобы он инвалидировался в момент смены зависимостей, потому что константный key никогда не промахивается и отдаёт протухший node_modules вечно, что быстро и неправильно. restore-keys даёт тёплый старт по префиксу после реальной смены, а встроенный cache: ‘npm’ у setup-node сам управляет ключом; помни, что кэши скоупятся по ветке с наследованием вниз и LRU-вытесняются в рамках бюджета 10 ГБ. strategy.matrix разворачивает одно определение job по версиям и ОС как декартово произведение, запускаемое параллельно, с include/exclude для формовки комбинаций, fail-fast для выбора между быстрой остановкой и полной видимостью падений и max-parallel для дросселирования — но измерения умножаются, поэтому matrix это ручка стоимости, а не бесплатное покрытие. Наконец, поскольку файловая система build пропадает вместе с job’ом, ты передаёшь вывод вперёд через upload-artifact/download-artifact: артефакты — надёжные, именованные, гарантированные-в-пределах-запуска выводы, которые ты сохраняешь или публикуешь, тогда как cache — best-effort вытесняемое ускорение. Используй cache для воспроизводимой работы, которую не жалко потерять, и артефакты для точных байтов, которые нужно передать дальше. Теперь, когда видишь «быструю» сборку, сломавшую prod, — первым делом смотри на cache key: если он константный, нашёл виновника.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.