DarkRiDDeR14 мин

CI/CD в 2020: минимальный pipeline, который не публикует чужую сборку

CI/CDGitLabПрактика

Симптом обычно выглядит безобидно: тесты в 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 только читает этот результат и не вызывает новую сборку.

Вертикальная схема минимального pipeline: commit и lockfile входят в verify, затем build создаёт каталог dist с REVISION и SHA256SUMS; ручной staging deploy получает только этот артефакт и может быть остановлен до побочного эффекта
Проверка исходников и доставка разделены. Между ними лежит один артефакт, а не cache и не новый checkout с повторной сборкой.

Матрица входов: что именно обязано совпасть

Контракт минимального выпуска
ЧастьВладелецНаблюдаемое доказательствоЧто блокирует
Исходный revisionVCS и runner$CI_COMMIT_SHA записан в dist/REVISIONфайл отсутствует или revision не совпадает с job deploy
Зависимостиpackage-lock.jsonnpm ci завершился на lockfilemanifest и lockfile расходятся, install не проходит
Проверкиjob verifylint и 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 и причину остановки, но не содержимое секретных переменных.

Маршрут первой поставки

  1. Зафиксировать текущую команду build, путь результата и revision, из которого команда обычно выпускает приложение. Не начинать с cache и ускорения.
  2. Положить lockfile в контролируемый вход и проверить, что npm ci завершается на чистом runner. Ошибку расхождения manifest и lockfile исправить до настройки deploy.
  3. Добавить job verify с существующими lint/test-командами. Сбой должен завершать job ненулевым кодом; успешный лог не выдавать за тест пользовательского сценария.
  4. Собрать dist/ один раз, положить рядом REVISION и checksum, прикрепить каталог как artifacts с понятным сроком хранения.
  5. В deploy-job разрешить зависимости только от build, проверить artifact и поставить ручной gate. Переменные доступа к стенду хранить в настройках CI, а не в YAML и не в статье.
  6. Провести один учебный запуск на 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 из примера