Ни на одно из этих мест не указал ни один инструмент. До каждого исправления пайплайн был зеленым и после оставался таким же. Изменилось другое: то, что на самом деле означал этот зеленый статус.
График покрытия принимал сбои инфраструктуры за регрессии в тестах, сообщение об успешном завершении утверждало, что образы опубликованы, хотя ничего не было отправлено в репозиторий, а тег при определенных условиях мог отправить в деплой четырехсимвольную строку null, если бы один из этапов хоть раз оказался пропущен.
Исправления пронумерованы прямо в Java Jenkinsfile как FIX #1–FIX #13. Номера #11 нет: во время ревью это исправление объединили с другим изменением.
Еще шесть исправлений с номерами R4#n находятся в workflow GitHub Actions.
Я оставил нумерацию прямо в файлах, а не вынес ее в changelog, потому что смысл каждого исправления понятен только рядом со строкой, которую оно защищает.
Все эти проблемы можно разделить на четыре вида лжи.
Ложь № 1: интерполяция, которая подставляет null
Groovy интерполирует ${VAR} внутри блока """...""" на этапе разбора. А shell внутри этого блока подставляет $VAR уже во время выполнения.
Выглядят эти конструкции почти одинаково, но срабатывают в совершенно разные моменты.
Этап передачи в CD записывает тег образа в репозиторий деплоя. В начале стоит защитная проверка:
if [ -z "\${IMAGE_TAG}" ]; then echo '❌ IMAGE_TAG is empty — aborting CD repo update' exit 1 fi
Обратный слеш здесь важнее всего остального в строке.
\\${IMAGE_TAG} проходит через Groovy без изменений и доходит до shell как ${IMAGE_TAG}, а shell вычисляет эту переменную уже при выполнении этапа.
Если убрать экранирование и написать:
${IMAGE_TAG}
Groovy подставит значение еще при разборе пайплайна, задолго до выполнения этапа.
Если этап Versioning к этому моменту не выполнялся, env.IMAGE_TAG содержит null, а Groovy при преобразовании в строку превращает его в четыре буквальных символа null.
Тогда проверка становится такой:
if [ -z "null" ]; then
и условие оказывается ложным, потому что "null" — вполне себе непустая строка.
Проверка проходит, в манифест записывается IMAGE_TAG=null, изменение коммитится, а инструмент деплоя получает команду скачать образ с тегом null.
То же правило экранирования действует для:
\${GIT_USER} \${GIT_TOKEN}
которые withCredentials передает в окружение shell.
В области видимости Groovy этих переменных вообще нет, поэтому без экранирования вместо них подставится пустая строка, и клонирование пойдет без учетных данных.
Общая закономерность
Когда один шаблонизатор вложен в другой, у вас появляются два момента вычисления при одном и том же синтаксисе. Groovy внутри shell, Helm внутри YAML, Terraform внутри JSON: проблема почти никогда не выглядит как синтаксическая ошибка.
Просто значение вычисляется не в тот момент и при этом выглядит вполне правдоподобно.
Такие ситуации хорошо показывают, насколько важны базовые принципы работы с DevOps-инструментами: от CI/CD и контейнеров до автоматизации инфраструктуры. Проверить, какие темы уже освоены, и понять, где есть пробелы, можно с помощью вступительного теста.
Ложь № 2: обработка ошибок, которая проглатывает не те ошибки
Этап CD не должен создавать коммит, если тег не изменился: повторный запуск для того же коммита даст идентичный манифест, а пустой коммит — это просто лишний шум.
Самый очевидный способ записать это выглядит так:
git commit -m "ci: update image tag" || echo "Nothing to commit"
Но это неправильно, причем проблема проявится только в тот день, когда сломается что-то еще.
|| echo перехватывает любой ненулевой код возврата от git commit, а не только случай, когда в индексе нет изменений.
Detached HEAD, конфликт слияния, поврежденный .git/config, упавший pre-commit-хук — во всех этих случаях команда выведет:
Nothing to commit
и позволит этапу перейти к push.
А тот уже завершится ошибкой non-fast-forward, которая никак не указывает на настоящую причину сбоя.
Вместо этого нужно задать точный вопрос:
git diff --cached --quiet \ && echo "ℹ️ Nothing to commit — image tag unchanged" \ || git commit -m "ci: update java-monolith image tag to ${IMAGE_TAG} [skip ci]"
git diff --cached --quiet возвращает 0, если в индексе нет изменений, и 1, если они есть.
То есть проверяет «есть ли что коммитить», а не «завершился ли commit с ошибкой».
При этом настоящие ошибки commit по-прежнему могут явно уронить этап.
|| true и || echo — два самых распространенных способа превратить явный сбой в тихий.
Оба варианта вполне допустимы, если подавляемое условие — единственная возможная причина ошибки.
Но ни один из них нельзя использовать просто для того, чтобы сделать красный этап зеленым.
Ложь № 3: отчеты, которые измеряют не то
Следующие три исправления связаны со средствами публикации результатов, которые выдают проблемы инфраструктуры за проблемы в коде.
Покрытие, которое незаметно исчезает
Этап SonarQube получает путь к отчету JaCoCo.
В исходной версии он собирался через APP_DIR:
${WORKSPACE}/${APP_DIR}/target/site/jacoco/jacoco.xml
При APP_DIR = '.' получается:
.../workspace/./target/site/jacoco/jacoco.xml
Для любого POSIX-инструмента /./ ничего не меняет, поэтому на первый взгляд здесь нет проблемы.
Но SonarQube Scanner 4.x обрабатывает этот путь через Java new File(path), который не нормализует сегмент ./.
В результате сканер пытается найти путь именно в таком виде, не находит отчета и продолжает работу без данных о покрытии.
Никакой ошибки не возникает.
Анализ завершается, проверка Quality Gate выполняется, а покрытие показывает ноль просто потому, что данные JaCoCo вообще не были импортированы.
Исправление простое: не собирать путь из переменной, которая обычно равна .:
JACOCO_PATH="${WORKSPACE}/target/site/jacoco/jacoco.xml"
Динамика покрытия, искаженная сбоями до запуска тестов
recordCoverage в блоке post { always } запускается для каждой сборки, в том числе если она упала на сканировании Trivy за три этапа до запуска каких-либо тестов. Отчета в таком случае нет, поэтому recordCoverage записывает точку с нулевым покрытием.
На графике покрытия появляется резкий обвал. Кто-то смотрит на него и решает, что это регрессия в тестах, хотя на самом деле просто истек таймаут сканера.
Правильная проверка должна отличать ситуацию «тесты запускались, но ничего не покрыли» от ситуации «тесты вообще не запускались»:
if (fileExists("${APP_DIR}/target/surefire-reports")) { junit testResults: "${APP_DIR}/target/surefire-reports/*.xml", allowEmptyResults: true recordCoverage( tools: [[ parser: 'JACOCO', pattern: "${APP_DIR}/target/site/jacoco/jacoco.xml" ]], sourceCodeRetention: 'EVERY_BUILD' ) } else { echo '⏭️ Skipping junit + recordCoverage — no surefire-reports directory found.' }
Проверка отличает две разные ситуации:
тесты были выполнены, но покрытие отсутствует;
тесты вообще не запускались.
На графике динамики покрытия должна отражаться только первая.
Сообщение об успехе, которое обещает лишнее
Этапы публикации запускаются только для main, а сообщение об успехе от этого условия не зависело.
Поэтому зеленая сборка feature-ветки выводила баннер с тремя реестрами и тегом образа так, будто все уже было опубликовано:
def published = (env.GIT_BRANCH != null && env.GIT_BRANCH ==~ /^(origin\/)?main$/) ? 'PUBLISHED to all registries ✅' : 'NOT PUBLISHED — non-main branch (build + scan only)'
Сводку по сборке читают гораздо чаще, чем список этапов над ней. Если сообщение можно понять как утверждение о том, что действительно было опубликовано, оно должно зависеть от того, что действительно было опубликовано.
Ложь № 4: мины замедленного действия
Все четыре решения были правильными в день, когда их написали, но со временем становятся неправильными сами по себе, даже если никто не трогает код.
Сокращенный SHA, который со временем удлиняется
В тег образа входит короткий SHA коммита:
SHORT_SHA=$(git rev-parse --short HEAD)
Если у --short не указать длину, Git сам выбирает самый короткий однозначный префикс для текущего репозитория. Сегодня это 7 символов, а когда количество объектов превысит определенный порог, станет 8. Формат тега незаметно изменится прямо посреди жизни проекта, и все, что разбирает теги по позициям, сломается.
Поэтому длину нужно зафиксировать:
SHORT_SHA=$(git rev-parse --short=7 HEAD)
Базовый образ, который никогда не обновляется
docker build без --pull использует базовый образ из локального кеша Docker daemon. На долгоживущем self-hosted-агенте этот образ может не обновляться месяцами. Если одна из основных задач пайплайна — сканирование Trivy, сборка на закешированном базовом образе означает, что вы сканируете образ, который никто уже не станет деплоить, и успешно проходите проверку. --pull принудительно проверяет digest и скачивает образ только в том случае, если он изменился.
Пример:
docker build --pull -t ${IMAGE_NAME}:${IMAGE_TAG} .
Имя каталога, которое однажды может совпасть
Этап CD клонирует репозиторий в рабочий каталог, предварительно очищая его через rm -rf. Если назвать этот каталог cd-repo, достаточно однажды добавить в проект настоящую директорию с таким именем, чтобы команда начала удалять исходники приложения. Переименование в cdrepo_tmp делает такое совпадение крайне маловероятным. Стоимость исправления нулевая, а устраняемый сценарий сбоя — потеря незакоммиченных изменений в workspace.
Пример:
rm -rf _cd_repo_tmp git clone <repository> _cd_repo_tmp
Имя ветки, которое однажды переименуют
Команда git push origin mainначнет падать с ошибкой: src refspec main does not match anyв тот день, когда в репозитории деплоя переименуют ветку по умолчанию.
git push origin HEAD отправляет текущую checkout-ветку и не зависит от того, как она называется.
Исправление:
git push origin HEAD
Два исправления только про масштаб последствий (blast radius)
docker logout нужен аргумент
Этапы публикации последовательно авторизуются в трех реестрах. На некоторых версиях Docker Engine вызов: docker logoutбез аргумента не просто завершает сессию Docker Hub, а полностью очищает: ~/.docker/config.jsonзаодно удаляя учетные данные GHCR и Nexus. Поэтому в этих пайплайнах каждый logout явно указывает реестр:
docker logout registry-1.docker.io
Метки агентов вместо agent any
agent any отправит задачу на Windows-узел, как только тот появится у контроллера.
После этого каждый шаг sh, каждый вызов trivy и каждый docker build начнут падать так, будто сломан сам пайплайн, хотя на самом деле задачу просто запустили не на том агенте.
Вместо:
agent any
нужно явно указать подходящий узел:
agent { label 'built-in || linux' }
Зачем на самом деле нужна эта нумерация
Комментарии получились длинными. Ревьюер вполне мог бы назвать их избыточными, и я бы не стал их писать, если бы эти исправления касались багов, которые сами дают о себе знать. Но у всех исправлений выше есть одно общее свойство:
когда я менял этот код, он работал.
Нет упавшего теста, в котором можно зафиксировать логику решения, нет тикета об инциденте, на который можно сослаться, и нет diff, который сам все объяснит. Удалите комментарий, и следующий человек, увидев git rev-parse --short=7 HEAD, заметит подозрительно конкретную цифру и не найдет ни одной причины не «упростить» ее.
Удалите комментарий рядом с git diff --cached --quiet
и || echo покажется более аккуратным решением: оно короче и читается проще.
Комментарий, который объясняет, что делает строка, — это шум: строка и так это показывает.
А комментарий, который объясняет, что случится, если ее убрать, — единственное надежное место, где можно сохранить решение, которое невозможно зафиксировать тестом. В этом и весь смысл оставлять нумерацию прямо в файле, а не в сообщении коммита, которое никто не станет искать через git blame.
Источники
Java Jenkinsfile, FIX #1–FIX #13
Java GitHub Actions workflow, FIX R4#2–R4#11
Python Jenkinsfile и Node Jenkinsfile, куда те же исправления перенесены по ссылке

Разобраться с инструментами на практике проще, когда можно увидеть рабочие примеры и задать вопросы тем, кто использует их в реальных проектах. На бесплатных открытых уроках Otus можно познакомиться с экспертами, посмотреть, как проходит обучение, проверить свои знания и закрыть пробелы в темах, которые важны в работе.
В сентябре разберём два практических кейса: настройку GitLab Runner для ускорения CI/CD и диагностику production-систем с помощью eBPF. Присоединяйтесь:
10 сентября в 20:00. «Настройка GitLab Runners». Записаться
23 сентября в 20:00. «eBPF: рентгеновское зрение для production». Записаться
Полный список бесплатных уроков сентября по инфраструктуре смотрите в дайджесте.