CI с нуля на Nx affected: от «пайплайна нет» до 9 минут на все staging-приложения
На этой странице
nx affected. Сложность не в команде, а в вопросе «затронуто относительно чего?». Ниже — путь от ручного деплоя скриптами до пайплайна, который проверяет MR и собирает только нужное, и четыре грабли, на которые я наступил по дороге.Точка ноль: деплой руками
Сначала пайплайна не было. Был shell-скрипт: собрать приложение локально и скопировать файлы на сервер. Проблемы очевидные:
- собирается то, что лежит у тебя на диске, а не то, что в репозитории;
- никаких проверок до деплоя;
- деплой может сделать только тот, у кого есть доступ и скрипт.
Первый пайплайн повторял скрипт: установить зависимости → собрать все приложения → скопировать. Уже лучше, но собирались все приложения на каждый пуш.
Идея affected, если по-простому
Nx знает граф проекта: какое приложение от каких библиотек зависит. Если ты поменял файл в библиотеке users, Nx идёт по графу «вверх» и находит все приложения, которые эту библиотеку используют. Остальные не затронуты, их можно не собирать и не тестировать.
npx nx affected -t build --base=<коммит> --head=HEADNx берёт изменённые файлы между base и head (по сути, git diff), находит их проекты, добавляет всё, что от них зависит, и запускает build только для этого набора.
Это обычный обход графа. Если библиотеки — вершины, а импорты — рёбра, то «затронутые» — все вершины, из которых по рёбрам можно дойти до изменённой.
Главный вопрос: base — это что?
Команда простая, а ошибиться с base — легко. Для разных задач ответ разный.
Для проверки MR: точка ответвления
Для merge request база — коммит, от которого ветка ответвилась от целевой (merge-base). В GitLab это переменная CI_MERGE_REQUEST_DIFF_BASE_SHA.
Важная деталь: по умолчанию GitLab клонирует репозиторий с ограниченной глубиной истории. Без полной истории git не найдёт merge-base, и affected посчитает неправильно. Поэтому:
variables:
GIT_DEPTH: "0" # полная историяДля сборки staging: последний задеплоенный коммит
Первая мысль — сравнивать с предыдущим коммитом ветки (CI_COMMIT_BEFORE_SHA). Это ошибка, и вот почему.
Пайплайны на staging у нас прерываемые (interruptible: true): если пришёл новый пуш, старая сборка отменяется — её всё равно перекроет новая. Представь:
- Коммит A меняет приложение X. Пайплайн A стартует.
- Через минуту коммит B меняет приложение Y. Пайплайн A отменён, стартует B.
- B сравнивает себя с A и собирает только Y.
Изменения X не попали ни в одну сборку. На сервере старая версия X, и никто не знает почему.
Правильная база — последний коммит, который реально дошёл до сервера. Его запоминает отдельный job после успешного деплоя:
# учебный пример
mark-deployed:
stage: mark_deployed
needs: [deploy]
cache:
key: deployed-sha
paths: [.ci-cache/]
policy: push
script:
- mkdir -p .ci-cache
- echo "$CI_COMMIT_SHA" > .ci-cache/deployed-shaСборка читает этот файл и считает affected от него. Пишется он только после успешного деплоя, поэтому изменения упавших и отменённых пайплайнов автоматически попадают в следующую сборку.
И обязательно — запасной выход: собрать всё, если:
- базы нет (первый запуск, кэш очищен);
- такого коммита нет в истории (например, после force-push);
- изменился сам CI или список приложений;
- кто-то запустил пайплайн с
FULL_BUILD=true.
Лучше иногда собрать лишнее, чем однажды не собрать нужное.
Проверка MR: только то, что изменилось
Сборка — это половина. Вторая половина — не пускать ошибки в staging. Стадия verify на каждый MR, всё по affected:
| Job | Что делает |
|---|---|
| lint | ESLint только по изменённым файлам + проверка стилей |
| typecheck | компилятор Angular без вывода (ngc --noEmit): типы и шаблоны |
| test | unit-тесты затронутых проектов, отчёт JUnit и покрытие в виджет MR |
Три job-а идут параллельно. Старые нарушения линтера записаны в файл-базлайн и не мешают; падает только новое.
И то же самое одной командой локально (pnpm verify). Если проверка есть только в CI, человек узнаёт об ошибке через десять минут. Если она есть и локально — до пуша.
Грабли 1. Две сборки параллельно — OOM
Хотелось ускорить: собирать по два приложения одновременно (--parallel=2). Сборки начали падать со странной ошибкой esbuild: all goroutines are asleep - deadlock!.
Ошибка вводит в заблуждение. Настоящая причина — память. Две параллельные сборки Angular занимали около 13 ГБ на хосте с 15,5 ГБ. Ядро Linux (OOM killer) убивало процесс сборки, а дочерний процесс esbuild, оставшись без родителя, падал с этим сообщением.
Урок: при странной ошибке в CI сначала смотри ресурсы — память, диск, время. Решение — --parallel=1. Медленнее на одну сборку, но affected и так уменьшил их число.
Грабли 2. Кэш, который прятал тесты
Nx умеет кэшировать результаты: если входы задачи не менялись, он не запускает её, а восстанавливает результат из кэша (в том числе удалённого, Nx Cloud).
В отчёте MR вдруг стало 237 тестов из 944. Тесты никто не удалял. Причина: из кэша восстанавливаются только объявленные выходы задачи (outputs). Файл junit.xml для отчёта в них не входил. Проект из кэша — «зелёный», но без отчёта, и GitLab его просто не показывает.
Решение для тестов в MR — --skip-nx-cache. Для отчёта нужен честный прогон. Альтернатива — добавить junit.xml в outputs задачи.
Урок: кэш ускоряет, но всё, что ты потом читаешь из файлов, должно быть в outputs. Иначе оно тихо исчезает.
Грабли 3. Собирать то, что никто не использует
Из 17 приложений в staging реально тестировали 7. Остальные собирались просто потому, что были в списке. Отключили лишние в карте деплоя — минус больше половины работы без единой строки умного кода.
Самая быстрая сборка — та, которой нет.
Грабли 4. Правки CI через веб-редактор
Первую версию пайплайна я правил прямо в веб-редакторе GitLab: в истории за неделю осталось около двадцати пяти коммитов «Update .gitlab-ci.yml file». Каждый — запустить, подождать, посмотреть, поправить.
Что делать иначе:
- писать CI в ветке и проверять в MR;
- проверять YAML встроенным линтером GitLab (CI Lint) до пуша;
- выносить логику из YAML в скрипты (
node tools/scripts/...), которые можно запустить и отладить локально.
Мелочи, которые тоже экономят минуты
- Кэш стора pnpm по ключу от
pnpm-lock.yaml: зависимости не скачиваются каждый раз заново. - Один job пишет кэш, остальные только читают (
policy: pull): иначе три параллельных job-а по очереди архивируют и выгружают одно и то же. - Source maps загружаются в систему мониторинга ошибок в том же job, что и сборка, и удаляются перед деплоем: на сервер они не нужны, артефакт меньше.
Итог
| Было | Стало |
|---|---|
| ручной деплой скриптом | пайплайн на каждый пуш |
| нет проверок | lint + typecheck + тесты на каждый MR |
| сборка всех приложений | только затронутые с последнего деплоя |
| ? минут | ~9 минут на все staging-приложения |
Главное, что я вынес: nx affected — это не про команду, а про правильный ответ на вопрос «относительно чего?». И про запасной выход «собери всё», когда ответа нет.
Связанные посты: nx-monorepo-from-copies, tailwind-v3-to-v4-monorepo, custom-eslint-rules.