Конвейер Node.js-проекта должен отвечать на четыре вопроса: установлены ли ровно те зависимости, что в lock-файле, проходит ли код проверку типов, работает ли он с настоящими зависимостями и какой именно образ проверен перед выкатом. Зеленый npm test сам по себе отвечает только на часть третьего вопроса.
Минимальный конвейер
name: ci
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
- run: npm test -- --coverage
- run: npm run test:integration
- run: npm run build
- run: npm audit --omit=dev --audit-level=high
- uses: actions/upload-artifact@v4
if: failure()
with:
name: test-reports
path: coverage/
concurrency отменяет проверку устаревшего коммита после нового push. permissions не дает проверкам лишних прав. Публикация образа - отдельное задание, которое ждет успешные проверки и работает только на main.
Версия Node и npm ci
Версия Node хранится в репозитории: .nvmrc, mise.toml или другое единое описание, которое читает и разработчик, и CI. Поле engines в package.json документирует требование, но не устанавливает нужный runtime на раннер.
npm ci обязателен для воспроизводимой сборки:
- удаляет старый
node_modulesвместо скрытого переиспользования; - устанавливает дерево из
package-lock.json; - падает, если manifest и lock разошлись;
- не переписывает lock-файл под давлением свежих версий из реестра.
npm install остается командой разработчика для намеренного изменения зависимостей. Обновление lock-файла приходит отдельным diff, который можно рецензировать.
Версию npm тоже фиксируют через packageManager в package.json и Corepack, если проект зависит от ее поведения. Для pnpm и Yarn принцип тот же: frozen lockfile, версия пакетного менеджера из репозитория, никакого молчаливого обновления зависимостей в CI.
Что кешировать
actions/setup-node с cache: npm сохраняет каталог загрузок ~/.npm по хешу lock-файла. Это уменьшает сетевой трафик, но не отменяет чистую установку.
Не кешируйте node_modules как готовый результат между разными ОС, архитектурами и версиями Node. Нативные зависимости и установочные скрипты делают такой кеш хрупким. Для монорепозитория дополнительно используют кеш Nx или Turborepo: он хранит результаты задач по входам и не путается с кешем пакетов.
Ключ с SHA коммита бесполезен: каждый прогон получает новый кеш. Ключ по lock-файлу меняется только при изменении зависимостей.
TypeScript - отдельный quality gate
Jest через ts-jest, SWC или транспиляцию NestJS не всегда выполняет полную проверку типов. Транспилятор может выдать JavaScript даже там, где tsc находит несовместимость. Поэтому npx tsc --noEmit идет отдельной командой до тестов или параллельным заданием.
npm run build не заменяет эту проверку автоматически: bundler способен пропустить типы ради скорости. Важно читать фактическую конфигурацию проекта, а не считать любой успешный build typecheck.
Линтер, форматирование и typecheck не обязаны выполняться последовательно, если не используют результаты друг друга. Их можно вынести в параллельные задания. Интеграционные тесты запускают после быстрых проверок, чтобы не поднимать PostgreSQL для кода, который не компилируется.
Jest и Testcontainers
Unit-тесты не должны требовать Docker. Интеграционные - должны запускаться в CI всегда, даже если локально разработчик иногда использует отдельный профиль.
Для Testcontainers раннеру нужен Docker. Тесты получают случайный адрес и порт контейнера от библиотеки, а не читают localhost:5432 из production-конфигурации. PostgreSQL и миграции поднимаются один раз на набор, данные изолируются между сценариями.
{
"scripts": {
"test": "jest --config jest.config.js",
"test:integration": "jest --config jest.integration.config.js --runInBand"
}
}
--runInBand здесь не универсальное правило, а осознанный выбор для небольшого набора с одной базой. Для ускорения большого набора лучше выделить каждому Jest worker свою схему или контейнер и уникальные данные. Параллельные тесты с общим TRUNCATE нестабильны по устройству.
Отчеты coverage и JUnit сохраняют при падении. Порог покрытия полезен как защита от удаления тестов, но не как доказательство правильности assertions.
Quality gates без ложной уверенности
npm run lintиnpm run format:checkфиксируют соглашения.npx tsc --noEmitпроверяет типы.- unit, integration и smoke проверяют разные границы.
npm audit --omit=devищет известные уязвимости production-дерева.- Gitleaks ищет секреты в коде и истории.
- Trivy проверяет уже собранный образ.
npm audit не знает, вызывается ли уязвимая функция вашим кодом. Не отключайте его целиком из-за шума: разбирайте исключения с причиной и сроком пересмотра. Для production-образа важнее фактическое дерево --omit=dev, а не только полный lock со всеми инструментами разработки.
npm ci --ignore-scripts уменьшает риск установочных скриптов, но ломает пакеты, которым нужна сборка нативного кода. Применяйте после проверки зависимостей, а не как универсальный флаг.
Один образ после проверок
Задание образа зависит от всех обязательных проверок и публикует артефакт по SHA коммита:
image:
needs: checks
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
load: true
tags: app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- run: docker run --rm app:${{ github.sha }} node dist/main.js --smoke-check
- if: github.event_name == 'push'
run: |
docker tag app:${{ github.sha }} ghcr.io/${{ github.repository }}:${{ github.sha }}
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
В реальном проекте smoke-команда должна быть реализована приложением и завершаться сама. Не заменяйте ее запуском HTTP-сервера без таймаута и проверки ответа. До публикации образ сканируют, staging и production получают один и тот же SHA или digest без повторной сборки.
Миграции и секреты
TypeORM-миграции выполняются отдельным шагом или Kubernetes Job, а не каждой репликой при старте. Изменение схемы совместимо со старой и новой версией. Откат образа не означает автоматический откат данных.
PR из форка не получает production-секреты и право публикации. Токен задания ограничен по правам. Для облачного доступа предпочтителен OIDC с короткоживущими учетными данными вместо постоянного ключа в переменных CI.
Коротко
- Версия Node и пакетного менеджера живет в репозитории, зависимости ставятся через
npm ci. - Кешируют загрузки npm и результаты задач монорепозитория, а не произвольный
node_modules. tsc --noEmit- отдельный gate, Jest и bundler не гарантируют typecheck.- Testcontainers подключает настоящие зависимости, миграции применяются как в production.
- Образ собирается после проверок, получает SHA и идет через окружения без пересборки.
- Публикация, секреты и миграции имеют отдельные права и отдельные шаги.
Что почитать дальше
- Принципы CI/CD - общий порядок конвейера и build once, deploy many.
- Интеграционные тесты на Node.js - Testcontainers, миграции и изоляция данных.
- Dockerfile для NestJS - как собрать runtime-образ без инструментов разработки.