Симптом обычно выглядит безобидно: тесты в pipeline зелёные, а после нажатия deploy на стенде оказывается другой набор файлов. Иногда job deploy ещё раз вызывает npm run build; иногда берёт каталог из cache; иногда просто не может показать, какой commit лежит внутри. Проблема не в количестве job. У выпуска нет одного названного результата, поэтому при сбое нельзя доказать, что проверяли и что отправили. Цена — ручной разбор после merge, риск выложить не тот bundle и невозможность быстро повторить путь отката.
В марте 2020 года для первой схемы мне достаточно GitLab CI/CD и трёх стадий: проверить вход, собрать результат, вручную разрешить доставку на staging. Это учебная конфигурация, а не готовый файл для чужого production. В ней нет credentials, фактических длительностей и реальной выгрузки. Цель уже полезнее: один commit, один lockfile, один build-артефакт и явный момент, когда pipeline обязан остановиться.
Сначала называем контракт выпуска
До YAML я записываю четыре вещи. Вход — revision исходного кода и lockfile зависимостей. Проверка — команды, которые могут остановить переход к сборке. Выход — каталог dist/ с отметкой revision и контрольной суммой. Получатель — ручной job deploy, который не пересобирает исходники. Если любой пункт не назван, зелёный значок job не означает, что выпуск повторяем.
Важно не смешивать cache и артефакт. Cache может ускорить повторную установку пакетов, но не должен быть носителем релизного результата: его содержимое зависит от runner и правила ключа. Артефакт создаёт конкретный job build после успешной команды. Его срок хранения ограничен, поэтому «неизменяемый» здесь означает не вечное хранилище, а дисциплину конкретного pipeline: downstream-job только читает этот результат и не вызывает новую сборку.
Матрица входов: что именно обязано совпасть
| Часть | Владелец | Наблюдаемое доказательство | Что блокирует |
|---|---|---|---|
| Исходный revision | VCS и runner | $CI_COMMIT_SHA записан в dist/REVISION | файл отсутствует или revision не совпадает с job deploy |
| Зависимости | package-lock.json | npm ci завершился на lockfile | manifest и lockfile расходятся, install не проходит |
| Проверки | job verify | lint и test имеют нулевой exit code | сбой не пускает job build |
| Результат сборки | job build | архив dist/ и SHA256SUMS | нет каталога либо checksum не проходит |
| Побочный эффект | ручной deploy | оператор запускает job только после просмотра артефакта | manual gate не нажат или проверка артефакта не прошла |
Эта таблица не заменяет правила доступа к серверу. Она отделяет технический вопрос от организационного: CI доказывает происхождение файлов, а право запускать deploy остаётся у процесса команды. Если staging вообще не нужен, последний job можно не создавать. Хуже оставить автоматическую команду, которая выглядит как deploy, но не показывает, какой каталог она отправила.
Консервативная GitLab CI-конфигурация
Ниже использован набор ключей, привычный для GitLab CI/CD начала 2020 года: stages, artifacts, dependencies, only и when: manual. Стадии дают линейный маршрут, а dependencies у deploy ограничивает входящие файлы артефактом build. Перед применением нужно сверить синтаксис с версией GitLab и GitLab Runner на своей установке: это часть входного контракта, а не мелочь шаблона.
image: node:12-alpine
stages:
- verify
- build
- release
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .npm/
verify:
stage: verify
script:
- npm ci --cache .npm --prefer-offline
- npm run lint
- npm test
build:
stage: build
dependencies: []
script:
- npm ci --cache .npm --prefer-offline
- npm run build
- printf "%s\n" "$CI_COMMIT_SHA" > dist/REVISION
- cd dist && sha256sum * > SHA256SUMS
artifacts:
name: "web-$CI_COMMIT_SHA"
paths:
- dist/
expire_in: 7 days
deploy_staging:
stage: release
dependencies:
- build
script:
- test -f dist/REVISION
- test "$(cat dist/REVISION)" = "$CI_COMMIT_SHA"
- ./scripts/deploy-staging dist/
environment:
name: staging
when: manual
allow_failure: false
only:
- master
Job verify запускает install из lockfile и две проверки. Никакой вывод о длительности здесь не сделан: cache .npm/ может помочь, а может не попасть на тот же runner. Job build снова устанавливает зависимости, потому что job изолированы. Это дороже короткой команды, но понятнее: build не наследует случайный рабочий каталог предыдущего job. Сначала полезно добиться этого свойства, потом измерять, нужна ли оптимизация.
В build добавлены REVISION и SHA256SUMS. Первый файл отвечает на вопрос «из какого commit получен каталог». Второй помогает заметить потерю или замену файла после сборки. Команда sha256sum в примере рассчитана на простой плоский dist/; если build создаёт вложенные каталоги или runner работает не в GNU/Linux, команду надо адаптировать и проверить отдельно. Нельзя копировать её и считать, что весь архив уже проверен.
Почему deploy не собирает заново
Повторная сборка в deploy-job ломает границу. Даже при одинаковом commit она может прочитать другой lockfile из ветки, другой образ job, другое окружение или cache. Когда это происходит, тестировал один job, а отправлял другой. Правильный минимум проще: deploy получает dist/ через dependencies: [build], проверяет отметку revision и только после этого вызывает команду доставки.
Ручной when: manual здесь не является «защитой от всего». Это всего лишь точка остановки до побочного эффекта. Явное allow_failure: false делает намерение видимым в файле, но поведение manual-job и права запуска всё равно нужно проверить в пилотном проекте на своей версии GitLab. Если команда хочет автоматический staging, она должна заменить ручной gate наблюдаемым условием и отдельно описать, кто отвечает за откат.
Проверяем артефакт до побочного эффекта
Перед вызовом deploy-staging job не должен доверять имени архива. Он открывает ожидаемые файлы, сравнивает revision с переменной pipeline и сверяет суммы. Это короткая проверка происхождения, а не криптографическая защита всей цепочки. Она не подтверждает, что HTML полезен пользователю, секреты настроены верно или сервер примет upload. Но она останавливает именно тот класс ошибок, ради которого появился артефакт: deploy не продолжает путь с неизвестным каталогом.
# scripts/check-release-artifact.sh
set -eu
test -f dist/REVISION
test -f dist/SHA256SUMS
test "$(cat dist/REVISION)" = "$CI_COMMIT_SHA"
(cd dist && sha256sum -c SHA256SUMS)
test -f dist/index.html
echo "Артефакт относится к текущему commit и содержит ожидаемый вход"
В отдельном проекте вместо sha256sum может использоваться manifest bundler или архив с уже заданной суммой. Важен не инструмент, а порядок: build создаёт доказательство, deploy проверяет то же доказательство. Если verification не проходит, job завершается до сетевого вызова. Лог должен показывать имя job, revision и причину остановки, но не содержимое секретных переменных.
Маршрут первой поставки
- Зафиксировать текущую команду build, путь результата и revision, из которого команда обычно выпускает приложение. Не начинать с cache и ускорения.
- Положить lockfile в контролируемый вход и проверить, что
npm ciзавершается на чистом runner. Ошибку расхождения manifest и lockfile исправить до настройки deploy. - Добавить job
verifyс существующими lint/test-командами. Сбой должен завершать job ненулевым кодом; успешный лог не выдавать за тест пользовательского сценария. - Собрать
dist/один раз, положить рядомREVISIONи checksum, прикрепить каталог как artifacts с понятным сроком хранения. - В deploy-job разрешить зависимости только от
build, проверить artifact и поставить ручной gate. Переменные доступа к стенду хранить в настройках CI, а не в YAML и не в статье. - Провести один учебный запуск на staging: проверить, что при провале test, отсутствии artifact и несовпадении revision deploy не делает сетевого шага. Не называть этот прогон production-деплоем.
Границы этого минимального решения
Схема не покрывает стратегию production-раскатки, rollback, миграции базы и мониторинг после доставки. Она также не утверждает, что конкретный GitLab instance хранит artifact бесконечно или что один checksum защищает от всех классов компрометации. Для первой задачи это нормально: сначала появляется цепочка «commit → проверки → один каталог → ручная остановка». Следующей задачей можно сделать реальный smoke на staging и документированный способ вернуть предыдущий известный артефакт.
Если после внедрения появляется соблазн добавить retry, второй cache или ещё один build, сначала нужно назвать симптом. Если job просто медленный — измерить время на одинаковом runner. Если artifact не находится — проверить dependencies и срок хранения. Если нужен другой релиз — сделать новый pipeline. Так маленькая конфигурация остаётся местом, где причину видно до действия, а не коллекцией флагов, накопленных после ночных сбоев.
Проверяемые источники
- GitLab Docs: CI/CD YAML syntax reference — справочник ключей stages, dependencies, artifacts и when; точную поддержку нужно сверять с версией GitLab на своей установке
- GitLab Docs: Job artifacts — описывает передачу файлов между job и ограничение входящих артефактов через dependencies
- npm CLI v6: npm ci — фиксирует установку из существующего lockfile и ошибку при расхождении manifest и lockfile
- GitLab 12.9.0 CI YAML reference, March 2020 — официальный справочник версии, выпущенной в марте 2020 года; в нём есть базовые artifacts, dependencies и ручной when из примера