惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
人人都是产品经理
人人都是产品经理
大猫的无限游戏
大猫的无限游戏
博客园 - 三生石上(FineUI控件)
量子位
腾讯CDC
The Cloudflare Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
云风的 BLOG
云风的 BLOG
Vercel News
Vercel News
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
L
LangChain Blog
aimingoo的专栏
aimingoo的专栏
The Hacker News
The Hacker News
T
The Exploit Database - CXSecurity.com
B
Blog
S
SegmentFault 最新的问题
P
Privacy & Cybersecurity Law Blog
T
Threatpost
博客园 - 聂微东
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
C
Check Point Blog
N
Netflix TechBlog - Medium
D
DataBreaches.Net
爱范儿
爱范儿
IT之家
IT之家
S
Secure Thoughts
M
MIT News - Artificial intelligence
NISL@THU
NISL@THU
C
Cisco Blogs
TaoSecurity Blog
TaoSecurity Blog
有赞技术团队
有赞技术团队
A
Arctic Wolf
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Proofpoint News Feed
Spread Privacy
Spread Privacy
Schneier on Security
Schneier on Security
Simon Willison's Weblog
Simon Willison's Weblog
G
GRAHAM CLULEY
雷峰网
雷峰网
Project Zero
Project Zero
博客园 - Franky
H
Heimdal Security Blog
A
About on SuperTechFans
Security Latest
Security Latest
Webroot Blog
Webroot Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Hugging Face - Blog
Hugging Face - Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
OneClickRelease, или как мы ставим релизы одной кнопкой
Юрий Власенко · 2026-05-27 · via Все публикации подряд на Хабре

Средний

14 мин

6.3K

Привет, Хабр! На связи Юрий Власенко, Senior DevOps инженер в РТЛабс. Сегодня я расскажу о том, как наша команда автоматизировала сложный, бюрократизированный и многоуровневый релизный процесс компании и превратила его в тестируемый объектно-ориентированный код на Groovy (Jenkins).

Как мы к этому пришли

Изначально у нас не было цели построить какую-то универсальную релизную платформу на все случаи жизни. Мы решали вполне конкретную задачу: сделать пайплайн, который по одной кнопке соберёт и выведет релиз для одного конкретного блока разработки. Ну то есть ту самую заветную кнопку, нажав которую в 3 часа ночи, ты с гарантией в 99,9% будешь уверен в том, что релиз успешно добрался до прода.

И как это часто бывает в инженерных задачах, всё начиналось с попытки упорядочить существующий хаос. До внедрения релизного фреймворка поставка кода представляла собой разрозненную смесь инструментов, с помощью которых код выводился на тестовые и production-стенды. Где-то автоматизация отличалась, где-то шаги были ближе к ручным, а то и вовсе полностью ручные, где-то логика между контурами расходилась сильнее, чем хотелось бы.

В результате процесс был:

  • неоднородным;

  • плохо предсказуемым;

  • сложным в сопровождении;

  • зависимым от того, кто именно сейчас его ведёт.

При этом мы в достаточной степени обладали представлением о том, какие именно рутинные действия больше всего отнимают сил и времени в процессе подготовки релиза у наших тест-лидов, которые в подавляющем большинстве команд являются релиз-менеджерами. Мы хотели автоматизировать и закрыть базовый минимум потребностей:

  • определить, какие задачи входят в релиз;

  • найти связанные с ними изменения в репозиториях команды;

  • создать и слить все нужные ветки;

  • собрать нужные приложения;

  • зафиксировать состав поставки;

  • провести релиз по средам с определением порядка установки приложений;

  • вернуть пользователю прозрачный статус по всей цепочке.

Мы провели брейншторм и решили решили создать OneClick – фреймворк доставки кода, позволяющий выкатить релиз одной кнопкой (ну почти). Тогда мы ещё не знали, что в один прекрасный день наш код станет стандартом доставки для десятков слабосвязанных или вообще не связанных друг с другом информационных систем компании.

Из чего состоит OneClick

Не Jenkinsfile единым

Уже на этапе раннего планирования стало понятно, что реализовать наш замысел можно только на основе полноценной системы со своей логикой, правилами и точками отказа. Значит, относиться к ней как к очередному длинному pipeline-скрипту нельзя. При развитии проекта сложность будет только накапливаться, ив какой-то момент это выльется или в невозможность доработки без глобального рефакторинга, или в невозможность доработки как таковой.

И проблема была не в Jenkins или Pipeline as Code. Сложная релизная логика очень плохо живёт в форме длинного процедурного сценария. Особенно если этот сценарий одновременно:

  • работает с большим количеством API (ArgoCD, Jira, Gitlab, Vault, Harbor, SonarQube, etc.) и обеспечивает взаимодействие объектов, полученных из этих API;

  • работает с несколькими источниками конфигурации;

  • динамически определяет состояние релиза;

  • динамически формирует способ сборки приложений;

  • обеспечивает единый процесс доставки по средам;

  • выполняет логирование в нужном формате и с нужным уровнем на всех стадиях пайплайна.

Поэтому, основная логика всего процесса живёт в библиотеке как объектная модель, а Jenkins просто становится тонкой точкой входа и средой исполнения.

Для написания Jenkinsfiles мы выбрали scripted парадигму: для нас это было не стилистическим предпочтением, а практическим выбором, дающим больше свободы в действиях и раскрывающим весь потенциал Groovy.

Важно! Весь представленный в этой статье код хоть и является псевдокодом, но максимально приближен к тем классам, методам и пайплайнам, которые используются у нас.

@Library('oneclick-lib@main') _

import com.company.cicd.ReleaseContext
import com.company.cicd.WorkflowFirst
import com.company.cicd.WorkflowSecond

WorkflowFirst workflowFirst
WorkflowSecond workflowSecond

node {
  timestamps {
    stage('First') {
      ReleaseContext context = ReleaseContext.from(env, params)
      workflowFirst = new WorkflowFirst(this, context)
      midResult = workflowFirst.run()
    stage('Second') {
      Context context = ReleaseContext.from(env, params)
      workflowSecond = new WorkflowSecond(midResult)
      result = workflowSecond.run()
    }
  }
}

Сам pipeline почти ничего не «знает» о релизной логике. Он лишь передает управление библиотеке и определяет последовательность запускаемых действий.

Если смотреть на процесс в целом, то OneClick у нас распадается на три шага, довольно прозаичных и знакомых каждому, кто так или иначе имеет отношение к процессам доставки кода:

  1. подготовка: определить релиз как сущность. В нашем случае за это отвечает так называемый JiraOps, когда состав и состояние релиза автоматизация получает из Jira;

  2. вuild: понять, какие приложения нужно собирать и делать это единообразно, с зависимостями, простановкой версий и получением артефактов;

  3. deploy: довести релиз по средам от dev до prod;

Именно эта цепочка являлась основополагающим принципом и нашей рабочей моделью релизного процесса. Со временем она обросла большим количеством проверок и дополнений (сканирование SAST, сканирование SonarQube, проверка покрытия тестами, запуск кастомных автотестов и пр.). Но основу по-прежнему составляют эти три крупных блока, которые в то же время являются тремя разными пайплайнами, способными запускаться независимо друг от друга.

Ниже представлена примерная блоксхема взаимосвязи различных пайплайнов всего процесса:

Каждый из этих блоков достоин отдельной статьи, но в контексте общего рассмотрения процесса остановлюсь на этом кратко.

Часть первая: JiraOps

Первая задача и первый пайплайн – OneClickRelease:

  • отвечает за формирование релиза;

  • является единой точкой входа для установки релиза;

  • триггерит все зависимые джобы.

Что под капотом:

  • Jenkins сам, по информации из Jira, определяет задачи, входящие в релиз;

  • по этим задачам ищет связанные MR в проектах команды;

  • создает релизную ветку во всех проектах команды;

  • мержит в неё нужные изменения;

  • на основании собранных приложений и значений, полученных из служебных полей Jira, формирует файл состава релиза;

  • создает специальную релизную задачу в Jira. В ней описан состав поставки, план установки, версии, согласования, даты и прочая информация, в полной мере описывающая релиз. Эта задача затем динамически изменяется в зависимости от состояния релиза.

Ниже представлен псевдокод workflow этого процесса:

class ReleaseScopeWorkflow {

  ReleaseScope build(String releaseKey) {
    def issues = jira.findIssuesByRelease(releaseKey)
    def mergeRequests = issues.collectMany { issue ->
        git.findMergeRequestsByIssue(issue.key)
    }
    
    def releaseBranch = git.createBranch("release/${releaseKey}")
    
    mergeRequests.each { mr ->
      git.mergeMrToBranch(mr, releaseBranch)
    }
      
    def releaseFile = releaseFileBuilder.build(
      branch: releaseBranch,
      projects: [],
      specs: []
    )
      
    jira.createReleaseIssue(
      key: releaseKey,
      branch: releaseBranch,
      scopeFile: releaseFile.path
    )
  
    new ReleaseScope(
      branch: releaseBranch,
      projects:[“proj1”, “proj2”],
      specs: [“spec1”, “spec2”],
      releaseFile: releaseFile
    )
  }
}

На этом этапе релиз перестает быть «примерным списком изменений» и становится формально собранной сущностью:

  • релиз-менеджер не собирает и не заполняет состав релиза руками, это происходит автоматически;

  • разработчик будет уверен, что каждый MR будет автоматически найден и включен в релиз;

  • тестировщик, опираясь на вывод логов, может сразу объективно оценить, какие задачи готовы и поставлены на стенд.

  • соблюдается консистентность поставки, т. е. все компоненты релиза, готовые к выводу, гарантировано будут установлены в процессе выполнения пайплайна.

Такой подход минимизирует риски того, что какой-то компонент не попадёт в релиз или что-то проскочит туда без должных проверок.

Часть вторая: Build

Следующим важным этапом является сборка приложений.

Мы выбрали конфигурируемую сборку, при которой процесс сборки каждого приложения описывается в yaml-файле несколькими полями проекта. В них определяются окружения (Java, Python, Go, Node) и инструменты сборки (Gradle, Maven, Docker), их версии, зависимости проекта, команды сборки и описание возвращаемых артефактов. Сам pipeline не знает заранее, как и с помощью чего собирается проект. Он получает описание проекта и выполняет нужный сценарий динамически, в процессе своего выполнения.

Условно, файл конфигурации, на основании которого выполняется сборка и деплой, можно представить так:

projects:
 project_a:
  framework: java
  frameworkVersion: 21.0.1
  builder: maven
  builderVersion: 3.9.9
  buildCommand: mvn clean install
  apps:
    app1:
     exclude_envs: [stand1, stand2]
     wave: 1
    app2:
     wave: 2
 project_b:
  framework: python
  frameworkVersion: 3.10
  builder: docker
  dockerFile: src/docker/Dockerfile
  apps:
    app3:
     deploy: false
    app4:
     wave: 3

За счёт этого один и тот же пайп может единообразно собирать и деплоить практически любые приложения, не превращаясь в набор специальных веток под каждый проект.

Часть третья: Deploy

Для деплоя приложений мы используем GitOps в виде ArgoCD для большей части приложений и Ansible для того, с чем по тем или иным причинам ArgoCD не справился. Несмотря на то, что деплой непосредственно поручен сторонним инструментам, оркестрация всего процесса остается за Jenkins.

Jenkins в этой модели не просто «запускает что-то дальше», а управляет процессом через API ArgoCD:

  • инициирует деплой;

  • рефрешит и синкает то, что нужно и с нужными параметрами;

  • асинхронно получает статусы приложений и возвращает пользователю внятную обратную связь в виде состояния приложения и его последних логов.

И управляет процессом деплоя через Anible:

  • скачивает нужные роли, inventory и зависимости;

  • запускает роли с нужными параметрами на основе файла описания ansible деплоя;

  • формирует параметры конфигурации роли — параметры, которые переопределяют default-значениями из репозитория конфигурации;

  • управляет очерёдность запуска ролей.

Псевдокод для наглядности и визуализации процесса:

class DeploymentOrchestrator {

  DeploymentResult deploy(ReleaseScope scope, String environment) {
    def appName = argoClient.startDeployment(
      environment: environment,
      releaseFile: scope.releaseFile.path
    )
  
    while (true) {
      def status = argoClient.getStatus(appName)
      
      logger.info("Deploy status for ${appName}: ${status.phase}")
      
      if (status.phase in ["FAILED", "ERROR"]) {
        return DeploymentResult.failed(status.message, lastAppLogs)
      }
      
      if (status.phase == "HEALTHY") {
        return DeploymentResult.success(appName)
      }
      
      sleep(10)
    }
  }
}

Идемпотентность процесса

Отдельной задачей было сделать весь пайплайн идемпотентным. На этапе формирования страниц, веток и файлов описания поставки релиза достаточно проверить текущее состояние этих объектов, чтобы понять – требуется ли их изменение или нет. На этапе деплоя состояние приложений контролирует ArgoCD или идемпотентность Ansible роли. Для этапа сборки нужно было выработать максимально простой способ определения состояния этой самой сборки.

Очевидно, что бессмысленно повторно прогонять сборку проекта, если он уже был успешно собран для текущего состояния релизной ветки. Особенно если релиз перекатывается пайплайном из-за дополнительных проверок, движения по контурам или просто в результате повторного запуска.

Поэтому, после успешной сборки, на последний коммит релизной ветки мы просто ставим релизный тег. На следующем прогоне OneClick пайплайн смотрит, есть ли на текущем HEAD этой ветки нужный тег. Если новых коммитов не появилось, проект исключается из сборки.

class ProjectBuildWorkflow {
  void build(Project project, String releaseTag) {
    if (!buildPlanner.shouldBuild(project, releaseTag)) {
      logger.info("Skip build for ${project.name}: no new commits")
      return
    }
    
    shell.run(project.buildCommand)
    artifacts.publish(project.artifactPath)
    git.tag(
      repo: project.repo,
      commit: git.lastCommit(project.releaseBranch),
      tag: releaseTag
    )

    git.pushTag(project.repo, releaseTag)
  }
}

На практике, помимо идемпотентности, этот подход значительно сказался на времени выполнения пайплайна: OneClick не тратит время на повторную сборку уже собранного состояния ветки и ведёт себя предсказуемо при повторных прогонах. Фактически разница между первым и последующими запусками пайплайна может достигать десятков минут, т. к. выполнение действий по изменению состояния релиза не требуется.

Архитектура библиотеки

Чтобы OneClick не превратился в разросшийся Jenkins-скрипт, на раннем этапе было принято решение вынести основную логику в shared library и условиться, что она не будет являться просто набором переиспользуемых методов, станет полноценным фреймворком.

В попытках добиться максимального уровня разделения ответственности, тестируемости и масштабируемости архитектура слоёв библиотеки претерпевала не один рефакторинг. Ниже представлена примерная структура в её текущей реализации:

cicd
 ├─── clients
 │ ├─── jira
 │ │ └─── model
 │ └─── gitlab
 │ │ └─── api
 │ └─── …
 ├─── config
 ├─── core
 │ └─── git
 │ │ └─── model
 │ └─── …
 ├─── exceptions
 ├─── jenkins
 │ ├─── steps
 │ │ ├─── conflict
 │ │ │  ├─── context
 │ │ │  ├─── provider
 │ │ │  └─── resolvers
 │ │ └─── …
 │ └─── workflows
 │   ├─── merge
 │   └─── …
 ├─── models
 │ ├─── dto
 │ └─── …
 └─── utils

В core живёт чистая бизнес-логика, которая ничего не знает о Jenkins. Это модели, парсеры и код, принимающий решения на основе результатов различных обработок и других входных данных.

Например, разбор конфликтов во время git merge:

class ConflictOutputParser {
  private static final PATTERN = ~/CONFLICT.*in\s(\S+)/
   
  static List<ConflictFile> parse(String mergeOutput) {
    if (!mergeOutput) {
      return []
    }
    
    def conflicts = []
    def matcher = mergeOutput =~ PATTERN
    matcher.each { match ->
      conflicts << new ConflictFile(path: match[1])
    }
    
    return conflicts
  }
}

Этот маленький фрагмент хорошо показывает главную идею резделения: мы вынесли логику из Jenkins-скрипта в обычный код, который можно изолированно тестировать и развивать.

Отдельно в пакет вынесены clients для работы с внешними API: Gitlab, Jira, Vault и многими другими системами. За счёт этого Jenkins перестал быть местом, где просто живёт вся релизная логика, но стал orchestration-слоем поверх библиотеки.

В пакете jenkins находится слой, завязанный на Jenkins-контекст:

  • steps — атомарные операции, выполняющие одно конкретное действие;

  • workflows — сценарии, которые собирают несколько шагов в целостное поведение, либо сценарии сценариев. Именно здесь у нас живёт не просто техническое действие, а Domain-Driven Design.

Например, merge с попыткой автоматического разрешения конфликтов:

class MergeWorkflow {
  private final GitStep gitStep
  private final ConflictResolverStep conflictResolverStep
    
  boolean merge(String sourceBranch, String targetBranch, allowUnresolved = false) {
    try {
      MergeResult mergeResult = gitStep.merge(sourceBranch)
        
      if (mergeResult.hasConflicts()) {
        List<ConflictFile> conflictFiles = mergeResult.conflicts*.path
          
        logger.logWarn("Обнаружены конфликты в файлах ${conflictFiles}")
        
        ResolutionRequest resolutionRequest = new ResolutionRequest(
          conflictFilePaths: conflictFiles,
          targetBranch: targetBranch,
          sourceBranch: sourceBranch,
          failOnUnresolved: allowUnresolved
        )
        ResolutionResult resolutionResult = conflictResolverStep.resolve(resolutionRequest, gitStep)
          
      if (resolutionResult.notResolvedButAllow) {
        logger.logWarn("Конфликты не решены, но это допустимо")
        return false
      }
        
      if (resolutionResult.fullyResolved) {
        gitStep.commit("Merge branch ${sourceBranch} into ${targetBranch} (auto-resolved conflicts)")
      } else {
        throw new PipelineException("Не удалось решить конфликты:" +
         " ${resolutionResult.unresolvedFiles}")
      }
        
      gitStep.push(targetBranch, buildConfig.gitToken)
      return true
        
    } catch (Exception ex) {
      script.error("Возникла ошибка при разрешении конфликтов: ${ex.message}")
    }
  }
}

На этом примере хорошо видно, что workflow описывает именно логику процесса:

  • merge без конфликтов проходит;

  • если конфликты не разрешены и их существование не допустимо, то процесс останавливается;

  • если конфликты не разрешены, но их существование допустимо, то возвращается предупреждение, но пайплайн может идти дальше.

Как мы тестируем пайплайны

Напрашивается закономерный вопрос: ломали ли мы пайпы с такой сложной и разветвлённой логикой, внедряя новый функционал? К сожалению, да. И не раз! В числе причин — синтаксические ошибки, кривая сигнатура метода, вызов несуществующего конструктора. Всё это приводит к потоку задач на неработающие джобы. Поначалу мы пытались охватить большую часть логики обычным тестированием в самом Jenkins, прогоняя сборки на моках. Но в какой-то момент это стало занимать слишком много времени и перестало охватывать все крайние случаи и сценарии.

В итоге мы пришли к тому, что неплохо было бы тестировать пайплайны как код. Причём не только с помощью unit тестов, но и с проверкой логики выполнения всего сценария. В нашей команде есть бывшие (хотя таких, как известно, не бывает) автотестеры, которые с радостью взялись за эту интересную и довольно редко встречающуюся в CI/CD задачу. В качестве инструмента тестирования был выбран стандартный фреймворк тестирования Java-приложений — Junit.

Условно, все тесты OneClick можно разбить на 3 основных уровня тестирования. Все они так или иначе наследуются от jenkins.unit.BasePipelineTest с большим количеством моков и переопределений реально существующих в Jenkins методов.

Уровень 1: unit-тесты для чистой логики

На первом уровне идут unit-тесты. Например, тот же парсер конфликтов можно тестировать вообще без Jenkins:

@Test
void shouldParseConflictFilesFromGitOutput() {
  def output = """
    Auto-merging Chart.yaml
    CONFLICT (content): Merge conflict in Chart.yaml
    Auto-merging package.json
    CONFLICT (content): Merge conflict in package.json
  """

  def conflicts = ConflictOutputParser.parse(output)
  
  assertThat(conflicts*.path)
    .containsExactly("Chart.yaml", "package.json")
  
  assertThat(ConflictOutputParser.hasConflicts(output)).isTrue()
}

Уровень 2: unit-тесты для workflow-логики

На втором уровне тестируется уже сценарная логика через моки steps и зависимостей.

Например, MergeWorkflow можно проверять так:

@Test
void shouldResolveConflictsAndCommitForEnvRepo() {
  def mergeResult = MergeResult.withConflicts(
    ["Chart.yaml", "package.json"],
    "conflicts"
  )

  def resolutionResult = new ResolutionResult(
    resolvedContent: [:],
    unresolvedFiles: [],
    fullyResolved: true
  )

  when(gitStep.merge("feature/test")).thenReturn(mergeResult)
  when(conflictResolver.resolve(any(), eq(gitStep))).thenReturn(resolutionResult)
  
  def result = workflow.merge("feature/test", "main")
  
  assertTrue(result)
    
  verify(gitStep).add(".")
  verify(gitStep).commit("Merge feature/test into main (auto-resolved)")
  verify(gitStep).push("main", "test-token")
}

Или негативный сценарий:

@Test
void shouldThrowExceptionWhenConflictsNotResolved() {
  def mergeResult = MergeResult.withConflicts(["Chart.yaml"], "conflicts")
  
  when(gitStep.merge("feature/test")).thenReturn(mergeResult)
  when(gitStep.getRepoPath()).thenReturn("team/service")
  
  def ex = assertThrows(RuntimeException.class) {
    workflow.merge("feature/test", "main")
  }
  
  assertTrue(ex.message.contains("Conflicts in regular repo"))
    
  verify(conflictResolver, never()).resolve(any(), any())
  verify(gitStep, never()).commit(any())
  verify(gitStep, never()).push(any(), any())
}

Тут уже тестируются реальные правила процесса, а не только слой данных или DTO.

Уровень 3: integration-тесты

Третий уровень — интеграционные тесты самих скриптов oneClick, то есть выполняется проверка успешности/неуспешности прохождения всего релизного цикла.

Здесь поднимается подготовленное Jenkins-подобное окружение:

  • подгружается shared library;

  • регистрируются Jenkins DSL-методы;

  • подставляются замоканные параметры окружения и учётные записи;

  • статические зависимости подменяются моками;

  • затем запускается сам скрипт пайплайна.

Условный setup выглядит так:

@BeforeAll
void setUp() {
  super.setUp()
    
  helper.libLoader.preloadLibraryClasses = false
  
  def (name, branch) = parseLibNameAndBranch(scriptText)
  def lib = library(name)
    .defaultVersion(branch)
    .targetPath("<notNeeded>")
    .retriever(projectSource())
  helper.registerSharedLibrary(lib.build())
    
  def scriptText = getScriptText(scriptName)
  saveAndLoadScript(scriptText)
    
  helper.registerAllowedMethod("findFiles", [Map])
  helper.registerAllowedMethod("modernSCM", [Map])
  helper.registerAllowedMethod("hidden", [Map])
  helper.registerAllowedMethod("extendedChoice", [Map])
  …
  addCredential("credId_1", TEST_CRED_1)
  addCredential("credId_2", TEST_CRED_2)
    
  addEnvVar("WORKSPACE", "/workspace")
  
  MainClassLib.utils = new MockUtils()
}

А сам интеграционный тест уже очень похож на проверку реальной джобы в Jenkins:

@Test
void mainReleasePipeSuccess() {
  addEnvVar("JOB_NAME", "job_param_value")
  
  addParam("release", "release_num_value")
  addParam("deployTo", "stand_deploy_value")
  
  runScript(script)
  
  assertJobStatusSuccess()
  assertThat(buildLog).contains("some_log_from_success_job")
}

Или можно проверять последовательность значимых этапов:

@Test
void mainReleasePipeDependencyResolvingTest() {
  addEnvVar("JOB_NAME", "job_param_value")
  
  addParam("release", "release_num_value")
  addParam("deployTo", "stand_deploy_value")
  
  runScript(script)
    
  assertThat(buildLog).containsSequence(
    "Release scope collected",
    "Release branch created",
    "Projects build planned",
    "Deployment started",
    "Deployment finished"
  )
}

То есть мы проверяем не только отдельные классы, но и сам сценарий исполнения пайплайна.

Запуск тестов выполняется с помощью Gitlab CI в репозитории библиотеки по событию создания MR.

В результате внедрения тестирования количество отказов сборок сократилось практически до нуля. Любой новый функционал сразу покрывается тестами, код проходит кросс-ревью, а любое изменение перестало быть ожиданием нового потока задач на низком старте.

Вместо вывода

Если попытаться сформулировать основной тезис статьи, то звучать он будет так: как только релизный процесс начинает жить на пересечении нескольких инструментов и включает в себя не только стандартные задачи CI/CD, то он перестает быть просто автоматизацией.

В этот момент лучше относиться к нему как к программному продукту:

  • с архитектурой;

  • явным разделением слоев;

  • доменной логикой, вынесенной из средства интеграции ПО;

  • тестами на нескольких уровнях;

  • нормальной инженерной ценой изменений.

Да, это требует больше усилий, чем написать очередной Jenkins-скрипт. Но если процесс действительно критичен, эти усилия окупаются довольно быстро, в чём мы убедились на практике.