← назад к разделу

Конвейер 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 и идет через окружения без пересборки.
  • Публикация, секреты и миграции имеют отдельные права и отдельные шаги.

Что почитать дальше