Post

Жизненный цикл версии: от SNAPSHOT до релиза

Жизненный цикл версии: от SNAPSHOT до релиза

Практически любой Java-разработчик рано или поздно встречает в проекте версию с суффиксом SNAPSHOT. Например, в Gradle-проекте version = "0.1.0-SNAPSHOT". Или в корпоративной системе что-нибудь более внушительное:7.3217.14-SNAPSHOT.

Пока идет обычная разработка, эта строка редко вызывает вопросы. Проект собирается, появляются новые коммиты, исправляются ошибки, проходят Pull Request, а версия остается прежней. Но в какой-то момент разработка очередной версии заканчивается и вместо 0.1.0-SNAPSHOT появляется 0.1.0.

Что именно произошло?

Можно ли просто удалить -SNAPSHOT и считать релиз готовым? Зачем тогда нужны Git tag, GitHub Release и milestone? Как связаны номер версии и Semantic Versioning? Где появляются Conventional Commits? Что именно публикуется в Maven Central? И при чем здесь CI/CD, Scrum и Kanban?

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

Что такое SNAPSHOT

Начнем с исходной точки:

1
version = "0.1.0-SNAPSHOT"

Суффикс SNAPSHOT означает, что перед нами разрабатываемая версия проекта. В данном случае мы фактически говорим:

Сейчас идет разработка будущей версии 0.1.0. Ее содержимое еще не зафиксировано и может изменяться.

Например, сегодня можно выполнить ./gradlew build и получить:stream-template-engine-0.1.0-SNAPSHOT.jar. Затем добавить новую функциональность, исправить несколько ошибок и на следующий день снова выполнить ./gradlew build. Gradle снова создаст файл stream-template-engine-0.1.0-SNAPSHOT.jar. Название и номер версии остались прежними, но содержимое двух JAR уже различается.

Условно это можно представить так:

1
2
3
4
5
6
7
commit A ──► stream-template-engine-0.1.0-SNAPSHOT.jar
   │
   ▼
commit B ──► stream-template-engine-0.1.0-SNAPSHOT.jar
   │
   ▼
commit C ──► stream-template-engine-0.1.0-SNAPSHOT.jar

Все три сборки имеют одинаковую версию, хотя были получены из разных состояний исходного кода. Именно поэтому SNAPSHOT нельзя воспринимать как фиксированную точку истории проекта. Это скорее имя текущей линии разработки будущего выпуска.

Зачем нужны SNAPSHOT-версии

На первый взгляд изменяемость SNAPSHOT-версии выглядит немного странно. Если номер версии нужен для идентификации, зачем разрешать нескольким разным сборкам называться одинаково?

Причина в том, что во время разработки обычно важнее обозначить не каждую отдельную сборку, а версию, над которой сейчас идет работа.

Предположим, мы разрабатываем stream-template-engine и планируем первым выпуском сделать версию 0.1.0. До релиза код библиотеки может измениться десятки или сотни раз: сегодня добавили новый API, завтра исправили ошибку в matcher, послезавтра дописали тесты.

Теоретически каждой такой сборке можно было бы присваивать новый номер: 0.0.1, 0.0.2, 0.0.3 и так далее.

Но эти номера начали бы обозначать не реальные выпуски библиотеки, а просто очередные промежуточные состояния разработки. Вместо этого все такие сборки можно объединить под одним обозначением — 0.1.0-SNAPSHOT.

То есть 0.1.0-SNAPSHOT можно читать примерно так:

Мы разрабатываем будущую 0.1.0, а перед нами одно из ее текущих промежуточных состояний.

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

Например, приложение использует библиотеку common-utils. Команда библиотеки уже работает над будущей 2.8.0, и приложению нужны изменения, которые появятся именно в ней. Ждать полноценного релиза 2.8.0 необязательно — библиотека может публиковать промежуточные сборки как common-utils:2.8.0-SNAPSHOT.

Сегодня под этим номером будет опубликована одна сборка, завтра после очередных изменений — другая. Номер остается прежним, потому что обе они относятся к одной и той же разрабатываемой будущей версии 2.8.0.

Именно поэтому SNAPSHOT особенно часто встречается в корпоративной разработке. CI-сервер может регулярно, например после изменений в основной ветке или во время ежедневной сборки, собирать проект и публиковать очередной snapshot во внутренний Nexus или Artifactory.

В результате разработчик постоянно видит версии вроде 2.8.0-SNAPSHOT, 4.3.1-SNAPSHOT или 7.3217.14-SNAPSHOT, но обычно почти не обращает внимания на сам суффикс. Проект собирается каждый день, Jenkins создает очередной build, где-то появляются новые артефакты, тестовый стенд обновляется — SNAPSHOT воспринимается просто как привычная часть номера версии.

Если во внутреннем репозитории опубликована common-utils:2.8.0-SNAPSHOT, позже там может появиться более свежая сборка той же разрабатываемой версии. Поэтому зависимость:

1
implementation("com.company:common-utils:2.8.0-SNAPSHOT")

не стоит понимать как требование получить один конкретный JAR, навсегда соответствующий номеру 2.8.0-SNAPSHOT. Смысл здесь ближе к следующему:

Используй опубликованную snapshot-сборку разрабатываемой версии 2.8.0.

У полноценного релиза контракт будет другим. Если опубликована common-utils:2.8.0, этот номер уже должен однозначно обозначать конкретный выпуск, содержимое которого со временем не меняется.

Получается принципиальное различие:

1
2
3
4
5
6
7
8
9
10
2.8.0-SNAPSHOT
│
└── разрабатываемая версия
    под этим номером появляются новые сборки


2.8.0
│
└── выпущенная версия
    номер соответствует конкретному релизу

И вот здесь возникает более интересный вопрос: если во время разработки SNAPSHOT может постоянно меняться, в какой момент будущая версия перестает быть движущейся целью и превращается в зафиксированный релиз?

От SNAPSHOT к release version

Итак, пока идет разработка, версия 0.1.0-SNAPSHOT может соответствовать множеству последовательно меняющихся состояний проекта. Но бесконечно оставаться SNAPSHOT она не должна: в какой-то момент запланированная работа заканчивается и появляется версия, которую мы готовы отдать пользователям.

Для нашего Gradle-проекта изменение выглядит так. Былоversion = "0.1.0-SNAPSHOT", стало:version = "0.1.0". После сборки вместо stream-template-engine-0.1.0-SNAPSHOT.jar получим stream-template-engine-0.1.0.jar. Формально разница действительно сводится к нескольким символам. Смысл версии при этом меняется гораздо сильнее.

0.1.0-SNAPSHOT обозначала то, что сейчас разрабатывается. Версия 0.1.0 должна обозначать то, что уже выпущено.Это можно представить так:

1
2
3
4
5
6
7
8
9
10
11
12
0.1.0-SNAPSHOT
│
├── состояние A
├── состояние B
├── состояние C
└── состояние D
          │
          │ решили выпускать
          ▼
        0.1.0
          │
          └── конкретный выпуск

До этой точки исходный код продолжал изменяться, а очередные сборки могли оставаться 0.1.0-SNAPSHOT. После выпуска номер 0.1.0 уже должен быть связан с конкретным состоянием проекта и конкретным набором артефактов.

Выпущенная версия должна быть неизменяемой

Предположим, библиотека stream-template-engine:0.1.0 опубликована и другой проект добавил ее как зависимость:

1
implementation("dev.abykov:stream-template-engine:0.1.0")

Сегодня разработчик собирает свое приложение и получает нашу библиотеку. Через месяц он снова собирает тот же commit своего приложения с той же зависимостью. Естественное ожидание состоит в том, что stream-template-engine:0.1.0 по-прежнему означает тот же выпуск библиотеки. Если после публикации 0.1.0 мы обнаружили ошибку, исправили ее и под тем же номером загрузили новый JAR, это ожидание нарушается.

Получилась бы довольно неприятная ситуация:

1
2
3
4
5
6
7
8
9
10
11
12
понедельник

stream-template-engine:0.1.0
        │
        └── JAR A


пятница

stream-template-engine:0.1.0
        │
        └── JAR B

Координаты зависимости одинаковые, а код фактически разный.

Два разработчика могли бы собрать один и тот же проект с одной и той же объявленной версией зависимости и получить разные результаты. Более того, спустя некоторое время стало бы трудно даже ответить на вопрос, какая именно 0.1.0 использовалась в конкретной сборке. Именно поэтому опубликованные release-версии рассматриваются как неизменяемые.

Если после выпуска 0.1.0 обнаружилась ошибка, старый релиз остается 0.1.0, а исправление выпускается под новым номером, например 0.1.1.

1
2
3
4
5
6
0.1.0
  │
  │ нашли ошибку
  │ исправили
  ▼
0.1.1

Таким образом история версий не переписывается. 0.1.0 продолжает обозначать первоначальный выпуск, а 0.1.1 — следующий выпуск с исправлением.

Почему именно 0.1.1, а не 0.2.0 или 1.0.0, разберем немного позже, когда доберемся до Semantic Versioning.

Версия — это еще не весь релиз

На этом месте может возникнуть вполне логичный вопрос: если для выпуска достаточно заменить 0.1.0-SNAPSHOT на 0.1.0 и собрать проект, зачем вообще нужны остальные сущности?

Действительно, Gradle ничего не мешает сделать именно так. Для него значение version — прежде всего часть информации о собираемом проекте и его артефактах.

Но человеку и процессу разработки одной строки version = "0.1.0" недостаточно. Она сама по себе не отвечает, например, на вопросы:

  • какие задачи должны были попасть в 0.1.0;
  • все ли они закончены;
  • какой конкретно commit соответствует выпущенной версии;
  • какие изменения произошли по сравнению с предыдущим выпуском;
  • где посмотреть описание релиза;
  • откуда другой проект должен получить опубликованный артефакт.

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

Можно написать version = "0.1.0", но эта строка сама по себе не создает ни Git tag, ни GitHub Release; не закрывает задачи и не публикует библиотеку. Чтобы выпуск стал управляемым и воспроизводимым, вокруг номера версии появляется дополнительная инфраструктура. И первый вопрос возникает еще до того, как мы удалим -SNAPSHOT:

Что именно должно быть закончено, чтобы будущую 0.1.0 вообще можно было считать готовой?

Для этого перейдем от номера версии к задачам будущего выпуска.

Как понять, что должно войти в релиз

Будущая версия 0.1.0 существует задолго до того, как мы действительно ее выпустим. Пока в build.gradle все еще указано 0.1.0-SNAPSHOT, разработка продолжается: появляются новые идеи, исправляются ошибки, добавляются тесты и документация.

Постепенно возникает довольно практическая проблема: где проходит граница будущего релиза?

Предположим, перед первым выпуском библиотеки накопились такие задачи:

1
2
3
4
5
6
7
Add configurable charset support
Add CI build
Improve README
Add Javadocs
Add benchmarks
Configure Maven Central publishing
Add Spring integration

Все они могут быть полезными. Но это еще не означает, что абсолютно все необходимо закончить перед 0.1.0. Например, для первого выпуска можно сделать configurable charset, настроить CI, привести в порядок документацию и подготовить публикацию. Benchmarks можно перенести на следующую версию, а Spring integration вообще оставить на будущее.

То есть нам нужно зафиксировать не только список существующих задач, но и набор задач, который образует конкретный будущий выпуск.

Issue описывает отдельную работу

В GitHub отдельную задачу удобно оформлять как Issue. Например:

1
2
3
4
#12 Add configurable charset support
#15 Configure CI
#18 Improve documentation
#21 Configure Maven Central publishing

Issue может описывать новую функциональность, ошибку, техническую задачу, работу с документацией или практически любое другое изменение проекта. У него может быть собственное обсуждение, labels, исполнитель, связанные Pull Request и другие атрибуты. Но сам по себе Issue отвечает прежде всего на вопрос:

Что нужно сделать?

Например, #12 Add configurable charset support говорит, что библиотеке необходимо добавить возможность явно задавать кодировку. При этом из самого существования Issue еще не следует, в какой версии эта возможность должна появиться. Для этого нужен следующий уровень планирования.

Milestone объединяет задачи будущего выпуска

В GitHub несколько Issues можно объединить в Milestone. Для нашей будущей версии можно создать milestone v0.1.0 и включить в него выбранные задачи:

1
2
3
4
5
6
Milestone: v0.1.0

├── #12 Add configurable charset support
├── #15 Configure CI
├── #18 Improve documentation
└── #21 Configure Maven Central publishing

Теперь v0.1.0 — это уже не просто номер, который когда-нибудь появится в build.gradle. У будущего выпуска появился определенный scope, то есть запланированный объем работ.

Смысл milestone можно сформулировать довольно просто:

Эти задачи должны быть закончены, чтобы мы считали v0.1.0 готовой к выпуску.

По мере работы Issues закрываются, а GitHub показывает прогресс milestone. Например, если завершены три задачи из четырех, будущий выпуск готов на 75% с точки зрения запланированного набора задач.

Важно, что эти 75% ничего не говорят о количестве написанного кода, затраченном времени или реальной сложности оставшейся работы. Одна незакрытая задача вполне может оказаться сложнее трех уже завершенных. Это всего лишь удобный ответ на вопрос:

Сколько задач из запланированных для этого milestone уже закрыто?

Milestone не является версией проекта

Здесь появляется первая важная граница между похожими сущностями. Мы можем одновременно иметь version = "0.1.0-SNAPSHOT" в Gradle и Milestone v0.1.0 в GitHub. Противоречия здесь нет.

Первая запись говорит:

Сейчас мы разрабатываем будущую версию 0.1.0.

Вторая:

Вот набор задач, который мы запланировали для будущего выпуска v0.1.0.

Milestone никак не изменяет build.gradle, не запускает Gradle, не создает JAR и вообще не является частью Git. Это сущность системы управления проектом — в нашем случае GitHub. Поэтому milestone может существовать еще тогда, когда до самого релиза довольно далеко:

1
2
3
4
5
6
7
8
9
10
11
12
                 Milestone v0.1.0
                        │
          ┌─────────────┼─────────────┐
          ▼             ▼             ▼
       Issue #12     Issue #15     Issue #18
          │             │             │
          └─────────────┼─────────────┘
                        │
                        ▼
                 работа продолжается

               0.1.0-SNAPSHOT

Здесь milestone описывает план, а SNAPSHOTтекущее состояние разработки будущей версии.

А если во время разработки планы изменились

Milestone не высечен в камне. Предположим, в v0.1.0 изначально запланировали benchmarks, но во время разработки стало понятно, что они задержат первый выпуск и не являются обязательными для пользователей библиотеки. Такую задачу можно перенести в следующий milestone:

1
2
3
4
5
6
7
8
9
10
v0.1.0
├── charset
├── CI
├── documentation
└── publishing


v0.2.0
├── benchmarks
└── ...

Может произойти и обратная ситуация. Во время разработки обнаружилась ошибка, без исправления которой выпускать 0.1.0 нельзя. Появляется новый Issue, и он добавляется в текущий milestone.

Таким образом milestone не пытается предсказать будущее раз и навсегда. Он позволяет в каждый момент явно видеть актуальные границы планируемого выпуска.

Это особенно полезно, когда backlog проекта начинает расти. Без такой границы легко попасть в ситуацию, когда перед каждым релизом находится еще одна небольшая задача, которую «тоже неплохо бы добавить», и выпуск постоянно отодвигается.

Знакомый аналог из Jira

Сама идея наверняка знакома разработчикам, которые работали с Jira. Там у задачи часто можно встретить поле Fix Version:

1
2
PROJ-123
Fix Version: 3.7.0

По смыслу это означает:

Эта задача должна войти в выпуск 3.7.0.

В другом Issue может стоять та же версия:

1
2
PROJ-148
Fix Version: 3.7.0

и еще в одном:

1
2
PROJ-152
Fix Version: 3.7.0

В результате вокруг версии 3.7.0 тоже формируется набор запланированных изменений. Поэтому концептуально GitHub Milestone и Jira Fix Version решают очень похожую задачу:

1
2
3
4
5
6
7
GitHub                         Jira

Milestone v0.1.0              Fix Version 3.7.0
       │                              │
       ▼                              ▼
набор Issues                   набор Issues
для выпуска                    для выпуска

Конкретные возможности и модель данных у GitHub и Jira различаются, поэтому ставить между ними строгий знак равенства не стоит. Но для понимания назначения milestone такая аналогия вполне подходит.

Milestone смотрит вперед

Это свойство особенно пригодится дальше, когда рядом появятся Git tag и GitHub Release. Все три сущности могут называться v0.1.0, но milestone отличается от них направлением во времени.

Пока релиз еще не состоялся:

1
2
3
4
5
6
7
8
9
10
11
12
                 сегодня
                    │
                    ▼
             0.1.0-SNAPSHOT
                    │
                    │ работа
                    ▼
            Milestone v0.1.0
                    │
                    │ что должно быть готово
                    ▼
               будущий релиз

Milestone отвечает на вопрос о будущем:

Что должно войти в v0.1.0?

Позже Git tag будет отвечать уже на совсем другой вопрос:

Какое конкретное состояние исходного кода стало v0.1.0?

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

Как изменения доходят до исходного кода

Milestone определил границы будущего выпуска. Например, мы решили, что в v0.1.0 должны войти четыре Issues:

1
2
3
4
5
6
Milestone v0.1.0

├── #12 Add configurable charset support
├── #15 Configure CI
├── #18 Improve documentation
└── #21 Configure Maven Central publishing

Но пока это только план. Само добавление Issue в milestone, разумеется, никак не меняет исходный код библиотеки. Каждую задачу еще нужно реализовать. Для обычного изменения путь может выглядеть примерно так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Issue
  │
  ▼
рабочая ветка
  │
  ▼
commits
  │
  ▼
Pull Request
  │
  ▼
code review / CI
  │
  ▼
merge
  │
  ▼
основная ветка проекта

Например, для Issue #12 Add configurable charset support создается отдельная ветка. В ней появляется реализация, тесты и необходимые изменения документации. После этого создается Pull Request, изменения проходят проверки и в случае успешного review вливаются в основную ветку.

Затем закрывается следующий Issue, потом еще один. Постепенно содержимое milestone перестает быть только планом и превращается в реальные изменения проекта.

Конкретный Git workflow при этом может быть разным. В небольшом проекте feature-ветки могут сразу вливаться в main:

1
2
3
4
feature/*
    │
    ▼
  main

В другом проекте между ними существует интеграционная ветка develop:

1
2
3
4
5
6
7
feature/*
    │
    ▼
 develop
    │
    ▼
  main

Могут использоваться release-ветки, trunk-based development или другие стратегии. Для дальнейшего разговора это не принципиально. Нам важно лишь, что в некоторый момент проверенные изменения оказываются в той точке истории Git, из которой будет подготовлен выпуск.

Работу с main, develop, feature-ветками, Pull Request и code review мы подробно разбирали отдельно в статье «Типичный Git workflow: main, develop, feature и Pull Request».

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

Один релиз состоит из множества изменений

Допустим, разработка 0.1.0-SNAPSHOT заняла несколько недель. За это время в основную ветку попали десятки коммитов:

1
A ── B ── C ── D ── E ── F ── G ── H

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

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

1
2
3
4
5
6
7
8
9
добавили новый публичный API

исправили ошибку в matcher

добавили тесты

обновили README

отрефакторили внутренний класс

Первое изменение добавляет новую возможность пользователю библиотеки. Второе исправляет ее поведение. Остальные вообще могут не менять возможностей публичного API. Эта информация понадобится нам дальше. По истории изменений можно составлять release notes и changelog, а характер изменений связан с тем, какой номер должна получить следующая версия.

Но для этого хорошо бы, чтобы назначение коммита можно было понять не только после внимательного изучения diff.

Сообщение коммита тоже является частью истории

Git позволяет написать практически любое сообщение:

1
git commit -m "changes"

Можно и так:

1
git commit -m "fix"

Или даже:

1
git commit -m "finally works"

Для Git все эти варианты совершенно нормальны. Коммит будет создан и ничем технически не станет хуже коммита с аккуратным описанием. Проблема появится позже. Представим, что перед выпуском мы смотрим на историю:

1
2
3
4
5
6
7
changes
fix
tests
more fixes
small update
finally works
readme

По ней трудно понять, что именно произошло между двумя версиями проекта. А если историю нужно анализировать автоматически — например, сформировать changelog или определить типы изменений, — произвольные сообщения становятся еще большей проблемой. Поэтому поверх обычных Git commits часто вводят дополнительное соглашение о том, как именно описывать изменение в сообщении коммита.

Одно из таких соглашений — Conventional Commits.

Conventional Commits: структура истории изменений

Conventional Commits — это соглашение о формате сообщений коммитов. Оно не изменяет работу Git и не добавляет новых типов коммитов. Его задача гораздо проще: сделать так, чтобы по сообщению можно было понять характер изменения. Вместо произвольного:

1
added charset

можно написать:

1
feat: add configurable charset support

Вместо:

1
fixed matcher

получится:

1
fix: handle overlapping placeholders

У сообщения появляется небольшая, но предсказуемая структура:

1
<type>: <description>

Например:

1
2
3
4
5
feat: add configurable charset support
fix: handle overlapping placeholders
docs: add usage examples
test: cover empty replacement values
refactor: simplify placeholder lookup

Первая часть сообщения говорит, какого рода изменение произошло, а вторая кратко описывает само изменение.

Типы изменений

Два типа имеют особое значение в самой спецификации Conventional Commits.

  • feat обозначает новую функциональность:
    1
    
    feat: add Path API
    
  • fix — исправление ошибки:
    1
    
    fix: preserve binary data between placeholders
    

На практике проекты обычно используют и дополнительные типы. Например:

1
2
3
4
5
6
7
8
9
docs: improve README

test: add matcher edge cases

refactor: simplify replacement lookup

build: configure publishing

ci: add GitHub Actions workflow

Здесь уже хорошо видно преимущество соглашения. Сравним две истории одного и того же проекта:

1
2
3
4
5
6
add path support
matcher fix
tests
readme
github workflow
some refactoring

и:

1
2
3
4
5
6
feat: add Path API
fix: handle overlapping placeholders
test: add matcher edge cases
docs: improve README
ci: add GitHub Actions workflow
refactor: simplify replacement lookup

Во втором случае даже без просмотра исходного кода можно довольно быстро составить представление о том, что происходило с проектом. При этом Conventional Commits не задает закрытый список всех возможных типов. Помимо feat и fix, команда может договориться использовать docs, test, refactor, build, ci и другие обозначения, если они полезны для конкретного проекта.

Главное здесь не количество префиксов, а единый понятный формат истории.

Scope: где произошло изменение

Иногда одного типа недостаточно.

Предположим, библиотека состоит из нескольких заметных частей, и мы хотим сразу показать, к какой из них относится изменение. Для этого после типа можно указать scope:

1
feat(api): add Path processing method

или:

1
fix(matcher): handle overlapping placeholders

Общая форма становится такой:

1
<type>(<scope>): <description>

scope дает дополнительный контекст, но не является обязательным. Например, история может выглядеть так:

1
2
3
4
5
6
7
feat(api): add Path API

fix(matcher): handle overlapping placeholders

test(matcher): add prefix collision cases

docs(readme): add usage examples

Для маленького проекта scope может оказаться лишним. Если весь проект состоит из нескольких классов, сообщения вроде fix(matcher) иногда только создают дополнительный шум. Как и многие соглашения вокруг Git, данное имеет смысл использовать тогда, когда оно действительно помогает читать историю.

Breaking changes

Особый случай — изменение, которое нарушает обратную совместимость публичного API. Предположим, в версии 1.4.0 библиотека предоставляет метод:

1
process(InputStream input, OutputStream output)

А затем мы решили изменить его контракт так, что существующий пользовательский код больше не сможет работать без изменений. Для обозначения такого изменения Conventional Commits предлагает поставить ! перед двоеточием:

1
feat(api)!: change processing API

Более подробное объяснение можно добавить в footer сообщения:

1
2
3
feat(api): change processing API

BREAKING CHANGE: process now requires ProcessingOptions

В обоих случаях мы явно сообщаем:

Это изменение несовместимо с предыдущим публичным API.

Такой сигнал особенно важен для библиотек. Пользователь новой версии должен понимать, что обычного обновления зависимости может оказаться недостаточно — его код тоже придется изменить.

Зачем формализовывать сообщения коммитов

Для небольшого pet-проекта аккуратная история уже сама по себе полезна. Через несколько месяцев гораздо приятнее увидеть:

1
2
3
feat: add configurable charset support
fix: handle overlapping placeholders
docs: add Path API example

чем:

1
2
3
update
fix
more changes

Но у предсказуемого формата есть еще одно важное свойство: сообщения становятся пригодны для машинной обработки. Инструмент может пройти по истории и отличить feat от fix, найти breaking changes, собрать список изменений и на его основе сформировать changelog или release notes.

Например, из истории:

1
2
3
4
feat: add configurable charset support
feat: add Path API
fix: handle overlapping placeholders
docs: improve README

можно автоматически получить что-то похожее на:

1
2
3
4
5
6
7
8
Features

- add configurable charset support
- add Path API

Bug Fixes

- handle overlapping placeholders

Документацию при этом можно не включать в пользовательский changelog вообще или вынести в отдельную группу. Именно поэтому Conventional Commits часто встречается рядом с инструментами автоматизации релизов.

Conventional Commits и номер версии

Теперь возникает особенно интересная связь. Мы научились отличать исправление:

1
fix: handle overlapping placeholders

от новой функциональности:

1
feat: add configurable charset support

и от несовместимого изменения:

1
feat(api)!: change processing API

То есть история Git теперь содержит информацию не только о том, что менялось, но и о характере этих изменений. А характер изменений непосредственно связан с другим вопросом:

Какой номер должна получить следующая версия?

Например, почему после 1.4.2 в одном случае появляется 1.4.3, в другом — 1.5.0, а иногда вообще 2.0.0? Conventional Commits сам по себе этого не определяет. Он описывает формат истории изменений.

Правила, по которым смысл изменений отражается в номере версии, задает уже другой механизм — Semantic Versioning.

Semantic Versioning: что означает номер версии

До сих пор мы использовали номера версий как что-то само собой разумеющееся: 0.1.0, 0.1.1, 1.4.2, 2.0.0.

Но сами числа тоже могут нести информацию.

Если после 1.4.2 вышла 1.4.3, это может означать одно. Переход с 1.4.2 на 1.5.0 — другое. А появление 2.0.0 должно заставить пользователя библиотеки обратить особое внимание на обновление.

Одно из наиболее распространенных соглашений, которое придает этим числам определенный смысл, называется Semantic Versioning, или сокращенно SemVer.

Версия в SemVer состоит из трех основных чисел:

1
2
3
4
5
6
7
MAJOR.MINOR.PATCH

  2  .  4  .  1
  │     │     │
  │     │     └── PATCH
  │     └──────── MINOR
  └────────────── MAJOR

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

Именно поэтому SemVer особенно полезен для библиотек. Обновляя зависимость с 1.4.2 до 1.4.3, разработчик хочет понимать, чего ожидать от такого обновления и насколько велика вероятность, что существующий код придется менять.

PATCH: совместимые исправления

Последнее число — PATCH — увеличивается, когда выпускаются обратно совместимые исправления ошибок. Например, было 1.4.2. В библиотеке обнаружили ошибку в обработке placeholder, исправили ее и выпустили 1.4.3. С точки зрения пользователя публичный API при этом не должен ломаться. Если код компилировался и корректно использовал библиотеку 1.4.2, само обновление до 1.4.3 не должно требовать переписывания вызовов API из-за несовместимого изменения контракта.

Получается:

1
2
3
4
5
6
1.4.2
  │
  │ исправление ошибки
  │ без несовместимого изменения API
  ▼
1.4.3

После следующего исправления может появиться 1.4.4, затем 1.4.5 и так далее. И здесь становится понятна связь с Conventional Commits. Если после предыдущего релиза в истории появился, например:

1
fix: handle overlapping placeholders

то такое изменение хорошо соответствует смыслу patch-релиза.

Но это пока именно смысловая связь, а не магия Git. Сам по себе коммит с префиксом fix не изменит 1.4.2 на 1.4.3. Это может сделать человек или инструмент автоматизации, если проект настроен соответствующим образом.

MINOR: новая совместимая функциональность

Среднее число — MINOR — увеличивается, когда проект получает новую функциональность, но сохраняет обратную совместимость публичного API.

Предположим, в 1.4.2 библиотека умеет обрабатывать InputStream, а затем мы добавили новый удобный API для работы с Path. Существующий код пользователей продолжает работать, но библиотека получила новую возможность. Версия может измениться так:

1
2
3
4
5
6
1.4.2
  │
  │ новая обратно совместимая
  │ функциональность
  ▼
1.5.0

NB: при изменении MINOR значение PATCH сбрасывается в ноль. Не 1.5.2, а 1.5.0. А следующий patch-релиз этой линии уже будет 1.5.1.

С Conventional Commits такой тип изменения естественно сочетается с feat:

1
feat: add Path API

Снова важно не перепутать соглашения: Conventional Commits классифицирует изменение, а SemVer определяет смысл номера выпуска. Автоматически связывать одно с другим или нет — отдельное решение проекта.

MAJOR: несовместимые изменения

Первое число — MAJOR — меняется, когда новая версия содержит несовместимые изменения публичного API. Предположим, пользователи версии 1.5.3 вызывают:

1
engine.process(input, output);

В новой реализации контракт API изменился, и теперь требуется передавать дополнительные настройки:

1
engine.process(input, output, options);

Если старый пользовательский код после обычного обновления зависимости больше не компилируется или требует адаптации к новому контракту, произошло breaking change. Такое изменение должно быть заметно уже по номеру версии:

1
2
3
4
5
6
1.5.3
  │
  │ несовместимое изменение
  │ публичного API
  ▼
2.0.0

При увеличении MAJOR остальные компоненты снова начинаются с нуля. Таким образом, для стабильных версий основную идею SemVer можно представить так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PATCH
│
└── обратно совместимое исправление
    1.4.2 → 1.4.3


MINOR
│
└── новая обратно совместимая функциональность
    1.4.2 → 1.5.0


MAJOR
│
└── несовместимое изменение публичного API
    1.4.2 → 2.0.0

Номер версии превращается из простого счетчика выпусков в часть контракта с пользователем.

Почему публичный API здесь так важен

SemVer имеет смысл только тогда, когда понятно, что проект считает своим публичным API. Для библиотеки это могут быть публичные классы, интерфейсы, методы и их поведение, предназначенные для использования внешним кодом.

В сервисе публичным контрактом могут быть HTTP API, форматы сообщений или другие интерфейсы взаимодействия. Если проект вообще не определил границы своего публичного API, утверждение «мы соблюдаем Semantic Versioning» становится довольно расплывчатым. Непонятно, какое изменение считать обратно совместимым, а какое — breaking change.

Например, переименование внутреннего package-private класса библиотеки может вообще не затронуть пользователя:

1
2
3
4
internal MatcherState
        │
        ▼
internal MatchState

С точки зрения публичного API ничего не произошло. А удаление публичного метода, которым могли пользоваться внешние проекты уже может потребовать изменения MAJOR:

1
2
3
4
public process(InputStream, OutputStream)
        │
        X
      удален

Поэтому SemVer — это не столько правило арифметики над тремя числами, сколько соглашение о совместимости между выпусками.

Что особенного в версиях 0.x.y

Теперь вернемся к нашей библиотеке.

Мы начали с 0.1.0-SNAPSHOT и собираемся выпустить 0.1.0. Но если MAJOR обозначает несовместимые изменения, почему первая цифра вообще равна нулю? В SemVer версии 0.y.z отведены для первоначальной разработки. В этот период проект еще активно развивается, а публичный API не считается стабильным.

Например:

1
2
3
4
5
6
7
8
9
10
0.1.0
  │
  ▼
0.2.0
  │
  ▼
0.3.0
  │
  ▼
0.4.0

Между такими выпусками API может заметно изменяться. То, что работало с 0.2.0, не обязано без изменений работать с 0.3.0. Это не означает, что версии 0.x.y должны намеренно ломать совместимость или что правила версионирования можно полностью игнорировать. Проект все равно может придерживаться собственной последовательной политики обновления MINOR и PATCH. Но важное обещание SemVer на этом этапе другое: стабильность публичного API еще не гарантируется так, как после 1.0.0.

Для молодой библиотеки это удобно. Не приходится слишком рано фиксировать API, который еще формируется на практике.

Когда появляется 1.0.0

Переход к 1.0.0 — это уже не просто очередное увеличение числа. Он означает, что публичный API проекта определен и считается стабильным. Условно жизненный цикл молодой библиотеки может выглядеть так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
0.1.0
  │
  │ первоначальная разработка
  ▼
0.2.0
  │
  │ API развивается
  ▼
0.3.0
  │
  │ API сформировался
  ▼
1.0.0
  │
  │ стабильный публичный API
  ▼
1.1.0
  │
  │ совместимое расширение
  ▼
1.1.1
  │
  │ исправление
  ▼
2.0.0
    несовместимое изменение API

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

SemVer не решает, когда выпускать релиз

Здесь важно провести еще одну границу. Semantic Versioning отвечает на вопрос:

Как должен измениться номер версии с учетом совместимости нового выпуска?

Но SemVer не определяет:

  • какие Issues должны войти в релиз;
  • когда milestone можно считать завершенным;
  • как организовать Git branches;
  • сколько Pull Request должно попасть в выпуск;
  • когда команда вообще решит выпускать новую версию.

Например, после 1.4.2 в проекте могут накопиться десять fix-коммитов. SemVer подсказывает, что совместимый выпуск с этими исправлениями относится к уровню PATCH, но не требует выпускать новую версию после каждого отдельного fix.

Решение о моменте релиза остается частью процесса разработки. И теперь у нас уже есть две стороны будущего выпуска.

Milestone отвечает:

Что мы планируем включить в релиз?

Semantic Versioning помогает ответить:

Как номер этого релиза отражает характер и совместимость изменений?

Но остается еще один вопрос. Допустим, все задачи завершены, нужные изменения находятся в основной ветке, а в Gradle уже установлено version = "0.1.0". Через полгода в репозитории появятся сотни новых коммитов.

Как тогда определить, какой именно commit был выпущен как 0.1.0?

Для этого нам понадобится Git tag.

Git tag: как зафиксировать точку выпуска

Предположим, все задачи milestone v0.1.0 завершены, изменения прошли review и оказались в основной ветке проекта. В Gradle установлена релизная версия:

1
version = "0.1.0"

Мы готовы выпустить библиотеку. В этот момент история Git может выглядеть примерно так:

1
2
3
4
A ── B ── C ── D ── E
                    ↑
                   HEAD
                   main

Допустим, именно commit E содержит состояние проекта, которое мы называем версией 0.1.0. Пока релиз происходит сегодня, это кажется очевидным. Мы только что закончили работу и прекрасно знаем, где находится нужный код. Но разработка продолжается. Через несколько недель история станет длиннее:

1
2
3
4
A ── B ── C ── D ── E ── F ── G ── H
                                   ↑
                                  HEAD
                                  main

Затем появятся еще десятки или сотни коммитов. И через год вопрос:

Из какого именно состояния исходного кода была собрана версия 0.1.0?

уже не будет иметь очевидного ответа.

Конечно, Git хранит каждый commit и его уникальный идентификатор. Теоретически можно записать куда-нибудь hash нужного коммита 4e37a61 и считать, что именно он соответствует 0.1.0. Но человеку гораздо удобнее работать не с набором хешей, а с осмысленными именами. Для этого в Git существуют tags.

Tag — имя для конкретной точки истории

Git tag позволяет присвоить понятное имя определенному commit. Например, отметим commit E тегом v0.1.0:

1
2
3
4
5
A ── B ── C ── D ── E ── F ── G ── H
                    ↑
                  v0.1.0
                                   ↑
                                  main

Теперь независимо от того, насколько далеко уйдет main, имя v0.1.0 продолжит указывать на состояние проекта, соответствующее первому релизу. Позже появится следующий выпуск:

1
2
3
A ── B ── C ── D ── E ── F ── G ── H ── I ── J
                    ↑                   ↑
                  v0.1.0             v0.2.0

Еще позже — следующий:

1
2
3
A ── B ── C ── D ── E ── F ── G ── H ── I ── J ── K ── L
                    ↑                   ↑         ↑
                  v0.1.0             v0.2.0     v0.2.1

История разработки продолжает двигаться вперед, а tags остаются постоянными ориентирами внутри нее. Поэтому tag удобно воспринимать как именованную отметку на конкретной точке истории Git.

Чем tag отличается от branch

На схеме tag может напоминать branch: и то и другое представляет собой понятное имя, связанное с commit. Но ведут они себя по-разному.

Рассмотрим main:

1
2
3
A ── B ── C
          ↑
         main

Добавляем новый commit:

1
2
3
A ── B ── C ── D
               ↑
             main

main автоматически переместилась с C на D.

После следующего commit она снова двинется вперед:

1
2
3
A ── B ── C ── D ── E
                    ↑
                   main

Branch — движущийся указатель на текущую вершину определенной линии разработки. С tag все иначе. Если v0.1.0 поставлен на C:

1
2
3
4
5
A ── B ── C
          ↑
        v0.1.0
          ↑
         main

То после новых коммитов main уйдет вперед, а tag останется на месте:

1
2
3
A ── B ── C ── D ── E
          ↑         ↑
        v0.1.0     main

Именно это поведение и нужно для релизов. main отвечает примерно на вопрос:

Где сейчас находится актуальная линия разработки?

А тег v0.1.0:

Где находится состояние проекта, которое мы выпустили как 0.1.0?

Как создается tag

Если текущий commit соответствует будущему релизу, простейший tag можно создать командой git tag v0.1.0. После этого его можно посмотреть среди остальных tags с помощью команды git tag. Например:

1
2
3
v0.1.0
v0.2.0
v0.2.1

Если нужно отметить не текущий, а конкретный commit, можно указать его явно с помощью команды git tag v0.1.0 4e37a61.

Теперь имя v0.1.0 связано с этим состоянием истории. Важно, что созданный таким образом tag пока существует только в локальном Git-репозитории. Обычный git push не обязан отправлять новые tags вместе с веткой. Конкретный tag можно опубликовать отдельно:

1
git push origin v0.1.0

После этого он появится и в удаленном репозитории.

Lightweight и annotated tags

В Git существует два основных вида tags: lightweight и annotated. Команда, которую мы только что использовали:

1
git tag v0.1.0

Создает lightweight tag. В упрощенном виде это просто имя, указывающее на определенный commit:

1
2
3
4
v0.1.0
   │
   ▼
commit E

Для временных или вспомогательных отметок этого может быть достаточно. Но для релизов чаще используют annotated tags:

1
git tag -a v0.1.0 -m "Release v0.1.0"

Такой tag является отдельным объектом Git и кроме ссылки на commit хранит дополнительную информацию: автора tag, дату создания и сообщение. Упрощенно:

1
2
3
4
5
6
7
8
v0.1.0
   │
   ├── tagger
   ├── date
   ├── message: Release v0.1.0
   │
   ▼
commit E

Annotated tag также можно криптографически подписать, что позволяет дополнительно подтверждать происхождение релизной отметки.

Для небольшого проекта это может быть не критично, но сама идея хорошо соответствует назначению release tag: мы не просто оставляем техническую закладку, а явно фиксируем факт выпуска определенного состояния проекта.

Версия проекта и Git tag — разные сущности

Теперь можно вернуться к различию, которое легко пропустить. В Gradle у нас находится version = "0.1.0", а в Git v0.1.0. Они намеренно названы похоже, но существуют независимо друг от друга. Gradle version участвует в идентификации проекта и собираемых артефактов:

1
2
3
4
version = "0.1.0"
        │
        ▼
stream-template-engine-0.1.0.jar

Git tag отмечает исходный commit:

1
2
3
4
v0.1.0
   │
   ▼
commit E

Получается:

1
2
3
4
5
6
7
8
9
10
11
12
             RELEASE 0.1.0

          ┌───────┴───────┐
          │               │
          ▼               ▼

   Gradle version       Git tag

       0.1.0             v0.1.0
          │                 │
          ▼                 ▼
     версия JAR        commit в Git

Ничто в самом Git не требует, чтобы tag назывался так же, как Gradle version. Мы вполне могли бы создать tag first-release или release-august.

Точно так же значение version = "0.1.0" само по себе не создает tag. Связь между ними устанавливает наш release process: мы договариваемся, что tag v0.1.0 отмечает исходный код релиза, артефакты которого имеют версию 0.1.0.

Зачем tag нужен на практике

Кроме красивой истории релизов, tag дает вполне практическую возможность вернуться к исходному состоянию конкретной версии. Например:

1
git checkout v0.1.0

Git переключит рабочее дерево на состояние, отмеченное этим tag.

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

1
git diff v0.1.0 v0.2.0

или посмотреть историю между ними:

1
git log v0.1.0..v0.2.0

Таким образом tag связывает понятное человеку имя выпуска с точной технической точкой истории Git.

Но tag еще не является Release

После публикации tag v0.1.0 GitHub уже знает, какой commit соответствует этой версии. Однако сам tag почти ничего не рассказывает пользователю библиотеки. Он не объясняет, что нового появилось в 0.1.0, какие ошибки исправлены, есть ли особенности обновления и какие изменения вообще вошли в выпуск.

То есть мы уже умеем ответить на технический вопрос:

Какой commit является v0.1.0?

Но остается другой:

Что представляет собой выпуск v0.1.0 для человека?

И здесь поверх Git tag появляется еще одна сущность — GitHub Release.

GitHub Release: представление выпуска для пользователя

После предыдущего шага в Git уже существует tag: v0.1.0. Он решает важную техническую задачу: позволяет однозначно определить, какой commit соответствует выпущенной версии. Но если пользователь откроет список tags репозитория, информации о самом выпуске там будет немного. Он увидит имя v0.1.0, дату, commit и сможет перейти к исходному коду. Для разработчика библиотеки этого может быть достаточно, но для ее пользователя обычно интереснее другие вопросы:

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

Для этого GitHub предоставляет отдельную сущность — Release.

Release создается на основе tag

GitHub Release связан с определенным Git tag. В нашем случае получится примерно такая связь:

1
2
3
4
5
6
7
GitHub Release v0.1.0
          │
          ▼
     Git tag v0.1.0
          │
          ▼
       commit E

Tag по-прежнему остается частью Git и указывает на конкретную точку истории. Release существует уже на уровне GitHub и добавляет к этой технической отметке человекочитаемое представление выпуска. Например, для v0.1.0 можно написать release notes:

1
2
3
4
5
6
7
8
9
10
11
Stream Template Engine v0.1.0

First public release.

Features:
- streaming placeholder replacement
- configurable charset support
- Path and Stream APIs

Bug fixes:
- correct handling of overlapping placeholders

Теперь пользователю не нужно изучать десятки коммитов и Pull Request, чтобы понять, что представляет собой версия. У выпуска появляется собственная страница с описанием.

Tag существует без GitHub Release

Здесь важно еще раз разделить эти сущности. Можно выполнить и остановиться на этом:

1
2
git tag -a v0.1.0 -m "Release v0.1.0"
git push origin v0.1.0

Tag уже существует в Git и прекрасно выполняет свою задачу. GitHub Release создавать никто не обязан. Более того, GitHub для самого существования tag вообще не нужен. Репозиторий может находиться на другом сервере, а tag останется обычной частью Git.

Получается:

1
2
3
4
5
6
7
8
9
10
Git
│
└── tag v0.1.0


GitHub
│
└── Release v0.1.0
        │
        └── связан с tag v0.1.0

Поэтому выражения «создать tag» и «создать release» не являются двумя названиями одного действия.

Что обычно находится в Release

Содержимое release notes зависит от проекта, но чаще всего пользователю важны изменения относительно предыдущей версии.

Например:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
## What's Changed

### Features

- Add configurable charset support
- Add Path API

### Bug Fixes

- Handle overlapping placeholders

### Documentation

- Add usage examples

Для более зрелого проекта там могут появиться предупреждения о breaking changes, инструкции по миграции или ссылки на подробную документацию. Например:

1
2
3
4
5
## Breaking Changes

The processing API now requires ProcessingOptions.

See the migration guide before upgrading from v1.x.

И здесь снова становится полезна структурированная история коммитов, которую мы обсуждали раньше. Если между v0.1.0 и v0.2.0 находятся Conventional Commits:

1
2
3
4
feat: add configurable charset support
feat: add Path API
fix: handle overlapping placeholders
docs: add usage examples

То часть release notes уже можно сформировать автоматически. Инструмент способен отделить feat от fix, сгруппировать изменения и построить первоначальный список. Разработчику остается проверить его и добавить контекст, который невозможно получить только из сообщений коммитов.

То есть цепочка начинает складываться:

1
2
3
4
5
6
7
8
9
10
Conventional Commits
        │
        ▼
структурированная история
        │
        ▼
changelog / release notes
        │
        ▼
GitHub Release

Это не означает, что GitHub Release требует Conventional Commits. Release notes вполне можно написать вручную. Структурированная история просто делает автоматизацию гораздо удобнее.

Release notes и changelog — не совсем одно и то же

Эти понятия часто используются рядом и иногда практически взаимозаменяемо, но между ними полезно видеть небольшое различие.

Release notes обычно относятся к конкретному выпуску: v0.2.0. Они рассказывают пользователю, что важно именно в этой версии.

Changelog чаще представляет собой накопительную историю изменений проекта:

1
2
3
4
5
6
7
8
9
10
CHANGELOG

v0.3.0
├── ...
│
v0.2.0
├── ...
│
v0.1.0
└── ...

Например, в репозитории может существовать файл CHANGELOG.md, в котором последовательно описываются все выпуски. При этом содержимое секции v0.2.0 из changelog вполне может использоваться в качестве основы для release notes v0.2.0.

Строгой технической границы здесь нет: конкретные проекты организуют документацию релизов по-разному. Но полезно помнить, что GitHub Release — это сущность конкретного выпуска на GitHub, а changelog — способ вести историю изменений проекта в целом.

GitHub Release может содержать файлы

К Release можно прикреплять дополнительные assets: архивы, бинарные файлы, документацию или другие результаты сборки. Кроме того, GitHub автоматически предлагает архивы исходного кода для соответствующего tag: Source code (zip) и Source code (tar.gz).

И здесь появляется еще одна потенциальная путаница. Наличие GitHub Release и файлов на его странице не означает, что Java-библиотека опубликована в Maven Central. Это два разных способа распространения результатов проекта. Пользователь может вручную скачать какой-нибудь файл со страницы Release:

1
2
3
GitHub Release v0.1.0
        │
        └── assets

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

1
2
3
dependencies {
    implementation("dev.abykov:stream-template-engine:0.1.0")
}

Чтобы такая запись заработала у других разработчиков, артефакт должен быть опубликован в доступном Maven-репозитории. До этого этапа мы еще доберемся отдельно.

Release тоже не собирает проект

Создание GitHub Release само по себе не запускает Gradle и не превращает исходный код в JAR. Мы можем вручную создать Release v0.1.0, написать красивое описание и при этом вообще ничего не публиковать в Maven Central. Можно организовать и обратный процесс: собрать и опубликовать библиотеку в Maven-репозитории, не создавая GitHub Release.

То есть эти действия независимы:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Git tag
│
└── фиксирует commit


GitHub Release
│
└── представляет выпуск пользователю


Gradle build
│
└── создает артефакты


Maven repository
│
└── распространяет Java-артефакты

В хорошо организованном release process они связываются между собой, а часть действий обычно автоматизируется. Но технически каждый механизм решает собственную задачу.

Milestone, tag и Release — три разных v0.1.0

Теперь можно наконец поставить рядом три сущности, которые сопровождали нас последние несколько разделов. До выпуска существовал Milestone v0.1.0. В момент выпуска появился Git tag v0.1.0. А затем GitHub Release v0.1.0. Названия почти одинаковые, но смысл разный:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Milestone v0.1.0
        │
        ▼
       ПЛАН

Что должно войти в будущий выпуск?


Git tag v0.1.0
        │
        ▼
   ТОЧКА В ИСТОРИИ

Какой commit стал этим выпуском?


GitHub Release v0.1.0
        │
        ▼
 ПРЕДСТАВЛЕНИЕ ВЫПУСКА

Что изменилось и что важно пользователю?

Можно посмотреть на них и во времени:

1
2
3
4
5
6
7
8
9
10
11
             подготовка                  выпуск

                 │                         │
                 ▼                         ▼

        Milestone v0.1.0          Git tag v0.1.0
                 │                         │
                 │                         ▼
                 │                GitHub Release v0.1.0
                 │
                 └──────► работа ────────►

Milestone в первую очередь смотрит вперед: он описывает то, что мы хотим выпустить. Tag фиксирует момент, когда определенное состояние исходного кода стало выпуском. GitHub Release уже представляет этот состоявшийся выпуск человеку.

Но пока мы в основном работали с исходным кодом и информацией вокруг него. Пользователь Java-библиотеки в конечном итоге хочет не tag и не страницу GitHub Release. Ему нужен JAR, который можно подключить как обычную зависимость. Поэтому следующий вопрос звучит так:

Как из отмеченного tag исходного кода получается опубликованный Java-артефакт?

Здесь мы возвращаемся к Gradle.

От исходного кода к артефакту: где здесь Gradle

До сих пор большая часть release process происходила вокруг исходного кода. Мы определили задачи будущего выпуска через milestone, провели изменения через Git, выбрали номер версии и отметили конкретное состояние репозитория tag v0.1.0.

Но пользователь Java-библиотеки обычно не хочет клонировать ее Git-репозиторий и самостоятельно собирать исходный код.

Ему нужен готовый результат: stream-template-engine-0.1.0.jar. И здесь мы возвращаемся к Gradle.

Что делает Gradle

В начале статьи мы уже запускали ./gradlew build, тогда Gradle собирал разрабатываемую версию и создавал stream-template-engine-0.1.0-SNAPSHOT.jar. Теперь ситуация изменилась. В build.gradle указана релизная версия:

1
version = "0.1.0"

Поэтому после сборки ./gradlew build в build/libs появится уже релизный JAR stream-template-engine-0.1.0.jar.

В самом простом виде происходит следующее:

1
2
3
4
5
6
7
8
9
10
11
12
исходный код
     │
     ▼
   Gradle
     │
     ├── компиляция
     ├── тесты
     ├── проверки
     └── упаковка
            │
            ▼
stream-template-engine-0.1.0.jar

Конкретный набор задач зависит от конфигурации проекта и подключенных Gradle plugins, но для нашей картины важно главное: Gradle превращает исходный проект в результаты сборки, которыми можно пользоваться дальше. Один из таких результатов — JAR.

Что такое artifact

Здесь появляется термин artifact, или артефакт сборки. В общем смысле build artifact — это файл, полученный в результате процесса сборки и предназначенный для дальнейшего использования, распространения или публикации. Для Java-библиотеки наиболее очевидный артефакт — JAR: stream-template-engine-0.1.0.jar.

Но одним JAR дело не обязательно ограничивается. При публикации библиотеки могут использоваться и другие файлы, например JAR с исходным кодом stream-template-engine-0.1.0-sources.jar или Javadoc stream-template-engine-0.1.0-javadoc.jar.

То есть один выпуск проекта может иметь несколько связанных артефактов:

1
2
3
4
5
6
7
stream-template-engine 0.1.0
        │
        ├── stream-template-engine-0.1.0.jar
        │
        ├── stream-template-engine-0.1.0-sources.jar
        │
        └── stream-template-engine-0.1.0-javadoc.jar

Основной JAR нужен для использования библиотеки, sources.jar позволяет IDE показывать и открывать исходный код зависимости, а javadoc.jar содержит сгенерированную API-документацию.

Поэтому понятия версия и артефакт тоже не являются синонимами.

Версия 0.1.0 обозначает выпуск проекта, а stream-template-engine-0.1.0.jar — один из файлов, полученных для этого выпуска.

Откуда JAR получает номер версии

Название основного JAR обычно формируется из имени проекта и его версии. Если проект называется stream-template-engine, а в Gradle указано version = "0.1.0", получаем:

1
2
3
4
5
6
stream-template-engine
        +
      0.1.0
        │
        ▼
stream-template-engine-0.1.0.jar

И здесь можно еще раз увидеть отличие от Git tag. У нас одновременно существуют:

version = "0.1.0" — версия Gradle-проекта;

v0.1.0 — Git tag;

stream-template-engine-0.1.0.jar — результат сборки.

Все они относятся к одному выпуску, но принадлежат разным уровням:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Git
│
└── v0.1.0
    отмечает исходный код


Gradle project
│
└── version = "0.1.0"
    определяет версию проекта


Build
│
└── stream-template-engine-0.1.0.jar
    создает артефакт выпуска

Связь между ними обеспечивает release process.

Можно ли собрать JAR прямо из tag

Tag сам ничего не собирает. Это всего лишь ссылка внутри истории Git. Но он позволяет точно получить исходный код, из которого должна собираться соответствующая версия. Например, через некоторое время main уже ушла далеко вперед:

1
2
3
A ── B ── C ── D ── E ── F ── G ── H
                    ↑              ↑
                  v0.1.0          main

Мы можем переключиться на состояние v0.1.0 с помощью команды git checkout v0.1.0, а затем выполнить ./gradlew build. Таким образом tag дает нам точное состояние исходного кода, а Gradle уже работает с этим состоянием и выполняет сборку.

Получается простая связь:

1
2
3
4
5
6
7
8
9
10
Git tag v0.1.0
       │
       ▼
исходный код релиза
       │
       ▼
     Gradle
       │
       ▼
артефакты 0.1.0

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

Означает ли это, что сборка полностью воспроизводима

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

Результат может зависеть и от других вещей: версии JDK и Gradle, используемых plugins, внешних зависимостей, параметров сборки, окружения и даже от того, насколько сама сборка настроена на воспроизводимость. То есть:

1
2
3
4
Git tag
   │
   ▼
фиксированный исходный код

еще не обязательно означает:

1
2
3
4
повторная сборка
   │
   ▼
байт-в-байт тот же JAR

Полностью reproducible build — отдельная инженерная задача. Для нашей темы достаточно более базового свойства: tag позволяет однозначно установить, какой исходный код относится к выпуску.

JAR в build/libs еще не опубликован

После ./gradlew build у нас наконец есть готовый файл build/libs/stream-template-engine-0.1.0.jar. Но пока он находится только на машине или CI-сервере, где выполнялась сборка. Другой разработчик еще не сможет просто написать:

1
2
3
dependencies {
    implementation("dev.abykov:stream-template-engine:0.1.0")
}

И ожидать, что Gradle каким-то образом сам найдет наш локальный build/libs. Чтобы зависимость можно было получать автоматически, артефакты нужно поместить в репозиторий артефактов (Artifact repository). И здесь слово «репозиторий» используется уже не в смысле Git.

Git-репозиторий хранит историю исходного кода:

1
2
3
Git repository
      │
      └── source code + commits + tags

Artifact repository хранит опубликованные результаты сборки и информацию, необходимую для работы с ними:

1
2
3
Artifact repository
      │
      └── published libraries and metadata

Для публичных Java-библиотек одним из главных таких репозиториев является Maven Central. И именно туда теперь должен отправиться наш выпуск 0.1.0, чтобы из локального JAR превратиться в обычную зависимость для других Java-проектов.

Публикация артефакта: Maven-репозитории и Maven Central

После сборки у нас появился готовый артефакт: stream-template-engine-0.1.0.jar. Пока он лежит в локальном build/libs, пользоваться им можем в основном мы сами. Конечно, JAR можно отправить коллеге, положить на файловый сервер или прикрепить к GitHub Release, а затем подключать вручную.

Но для управления зависимостями Java-проектов используется гораздо более удобный механизм — Maven-репозитории. Несмотря на название, они относятся не только к проектам, которые собираются Apache Maven. Наш проект по-прежнему собирается Gradle.

Что такое Maven-репозиторий

Maven-репозиторий — это хранилище опубликованных программных компонентов, организованных по определенным правилам. Вместо того чтобы сообщать разработчику:

Скачай stream-template-engine-0.1.0.jar по этой ссылке, положи его в нужную директорию и не забудь повторить это после обновления версии,

Вместо этого, мы публикуем библиотеку в репозитории. После этого пользователь описывает какая именно зависимость ему нужна, а система сборки сама находит и загружает необходимые файлы. В Gradle это выглядит привычно:

1
2
3
dependencies {
    implementation("dev.abykov:stream-template-engine:0.1.0")
}

Разработчику уже не важно, под каким физическим путем находится JAR внутри хранилища. Для идентификации библиотеки используются ее координаты.

Координаты артефакта

Основные координаты Maven-компонента состоят из трех частей:

1
groupId : artifactId : version

Для нашей библиотеки это может быть:

dev.abykov:stream-template-engine:0.1.0.

Разберем каждую часть.

groupId определяет группу, организацию или пространство имен, которому принадлежит компонент: dev.abykov. Часто для него используется обратная запись доменного имени. Например, проекты разных владельцев могут иметь координаты, начинающиеся с org.springframework, com.fasterxml.jackson или org.apache. Это уменьшает вероятность того, что две независимые библиотеки случайно получат одинаковые координаты.

artifactId идентифицирует конкретный компонент внутри этой группы: stream-template-engine.

Наконец, version указывает нужный выпуск: 0.1.0.

Вместе эти три значения однозначно определяют, какой компонент и какую его версию мы хотим получить:

1
2
3
4
5
6
7
dev.abykov : stream-template-engine : 0.1.0
    │                │                │
    │                │                └── версия
    │                │
    │                └── компонент
    │
    └── группа / пространство имен

Именно поэтому при публикации библиотеки одного имени JAR недостаточно.

  • stream-template-engine-0.1.0.jar — имя файла.
  • dev.abykov:stream-template-engine:0.1.0 — координаты опубликованного компонента в Maven-экосистеме.

Откуда Gradle знает, где искать зависимость

Координаты отвечают на вопрос что искать, но остается вопрос где искать. Для этого в Gradle указываются repositories.

Например:

1
2
3
repositories {
    mavenCentral()
}

Теперь запись:

1
2
3
dependencies {
    implementation("dev.abykov:stream-template-engine:0.1.0")
}

можно читать примерно так:

Найди в настроенных репозиториях компонент stream-template-engine группы dev.abykov версии 0.1.0 и используй его как зависимость.

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

И здесь становится особенно полезна неизменяемость release-версий, которую мы обсуждали в начале статьи. Если dev.abykov:stream-template-engine:0.1.0 опубликована как релиз, координаты должны продолжать обозначать один и тот же выпуск.

В репозитории находится не только JAR

Можно было бы представить Maven-репозиторий просто как большую директорию с JAR-файлами:

1
2
3
4
repository
├── library-a-1.0.0.jar
├── library-b-2.4.1.jar
└── stream-template-engine-0.1.0.jar

Но этого недостаточно для нормального управления зависимостями. Предположим, наша библиотека сама использует другую библиотеку. Пользователь подключает stream-template-engine, и Gradle должен узнать не только расположение нашего JAR, но и какие зависимости необходимы ему для работы.

Поэтому вместе с артефактами публикуются metadata компонента. В Maven-модели важную роль играет POM — Project Object Model. Для опубликованной версии рядом с JAR может находиться файл вроде stream-template-engine-0.1.0.pom.

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

1
2
3
4
5
6
stream-template-engine 0.1.0
        │
        ├── stream-template-engine-0.1.0.jar
        ├── stream-template-engine-0.1.0.pom
        ├── stream-template-engine-0.1.0-sources.jar
        └── stream-template-engine-0.1.0-javadoc.jar

В Gradle-экосистеме также существует собственный формат Gradle Module Metadata, который способен хранить более богатую информацию о вариантах компонента. Но базовая идея остается той же: в artifact repository публикуется не просто произвольный JAR, а описанный программный компонент.

Благодаря metadata система сборки может разрешать целое дерево зависимостей автоматически.

Транзитивные зависимости

Предположим, приложение подключает библиотеку A:

1
2
3
4
application
    │
    ▼
library A

Но самой A для работы нужна библиотека B:

1
2
3
4
5
6
7
application
    │
    ▼
library A
    │
    ▼
library B

Разработчику приложения часто не приходится вручную добавлять B. Система сборки узнает о ней из metadata библиотеки A и разрешит такую зависимость транзитивно. В реальном Java-проекте дерево быстро становится значительно больше:

1
2
3
4
5
6
7
8
application
   │
   ├── library A
   │      ├── library C
   │      └── library D
   │
   └── library B
          └── library E

Gradle занимается поиском подходящих модулей, версиями, конфликтами и построением итогового dependency graph. Поэтому Maven repository — это гораздо больше, чем место, откуда скачивается JAR. Это одна из ключевых частей инфраструктуры управления зависимостями.

Что такое Maven Central

Maven Central — публичный центральный репозиторий компонентов Maven-экосистемы. Огромное количество Java- и JVM-библиотек, которые мы ежедневно подключаем в проекты, доступны именно через него. Когда в Gradle написано:

1
2
3
repositories {
    mavenCentral()
}

Мы тем самым разрешаем Gradle искать зависимости в Maven Central. Например, при объявлении:

1
implementation("org.apache.commons:commons-lang3:3.x.x")

Gradle использует координаты компонента, находит соответствующую версию в Central, получает metadata и необходимые артефакты. Для пользователя библиотеки это выглядит почти незаметно:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
build.gradle
    │
    │ dependency coordinates
    ▼
Gradle
    │
    │ поиск компонента
    ▼
Maven Central
    │
    │ metadata + artifacts
    ▼
локальный Gradle cache
    │
    ▼
classpath проекта

Именно к такому результату мы хотим прийти со своей библиотекой: чтобы для ее использования было достаточно добавить репозиторий и обычную dependency.

Почему Maven Central, если у нас Gradle

Название здесь действительно может немного сбивать с толку. В нашем проекте нет необходимости переходить с Gradle на Maven ради публикации в Maven Central. Это разные уровни.

  • Gradle — build tool. Он компилирует проект, запускает тесты, собирает артефакты и может выполнять их публикацию.
  • Maven Central — artifact repository. Он хранит опубликованные компоненты и отдает их потребителям.

Поэтому совершенно нормальна такая схема:

1
2
3
4
5
6
7
8
9
10
11
            наш проект

              Gradle
                │
                │ build / publish
                ▼
           Maven Central
                │
                │ dependency
                ▼
         другой Gradle-проект

И точно так же опубликованную нами библиотеку сможет использовать проект, который собирается Maven. Получается даже симметрично:

1
2
3
4
5
6
7
8
Gradle project ───┐
                  │
                  ▼
             Maven Central
                  │
          ┌───────┴───────┐
          ▼               ▼
    Gradle project     Maven project

Репозиторий отделяет производителя библиотеки от ее потребителей.

А что тогда Nexus и Artifactory

Публичная библиотека может быть опубликована в Maven Central, но внутри компании выкладывать все внутренние компоненты в публичный интернет обычно не требуется. Поэтому организации используют собственные artifact repositories. Часто для этого применяются Sonatype Nexus Repository или JFrog Artifactory.

Например, внутри компании могут существовать компоненты:

  • com.company:common-utils:2.8.0
  • com.company:document-model:7.12.0
  • com.company:integration-api:4.3.1

И доступ к ним имеют только внутренние проекты. Для разработчика принцип практически не меняется:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
публичный проект

Gradle
  │
  ▼
Maven Central


корпоративный проект

Gradle
  │
  ▼
Nexus / Artifactory

Именно поэтому SNAPSHOT, с которого мы начали статью, так часто встречается в корпоративных системах. CI может регулярно собирать текущую разрабатываемую версию и публиковать ее во внутренний Maven-репозиторий, откуда ее получают другие модули или проекты. Теперь тот пример из начала статьи замыкается:

1
2
3
4
5
6
7
8
9
10
разработка common-utils:2.8.0-SNAPSHOT
              │
              ▼
            Gradle
              │
              ▼
      Nexus / Artifactory
              │
              ▼
     зависимые проекты

Здесь уже понятно, что означает каждый шаг: Gradle создает и публикует компонент, artifact repository хранит очередную snapshot-версию, а зависимые проекты получают ее по Maven-координатам.

Публикация тоже является отдельным действием

Важно не смешивать обычную сборку и публикацию. После ./gradlew build артефакты появились локально. Но это еще не означает, что они автоматически оказались в Maven Central. Для публикации Gradle-проект отдельно настраивается: определяется публикация, координаты компонента, необходимые metadata и целевой repository. Для Maven-совместимой публикации в Gradle обычно используется plugin maven-publish.

В простейшей ментальной модели это два разных этапа:

1
2
3
4
5
6
7
8
9
10
./gradlew build
      │
      ▼
локальные артефакты


publish
      │
      ▼
artifact repository

Для Maven Central процесс дополнительно включает требования самого Central к публикуемому компоненту и учетным данным издателя.

Конкретную настройку публикации здесь подробно разбирать не будем: это отдельная практическая тема. Для жизненного цикла версии важно другое — сборка создает артефакт, а публикация делает его доступным другим проектам через repository.

Теперь выпуск действительно дошел до пользователя

Мы начинали с локальной разрабатываемой версии 0.1.0-SNAPSHOT. Затем определили состав будущего релиза, реализовали изменения, выбрали номер версии, зафиксировали исходный код tag, создали GitHub Release и собрали артефакты. После публикации в Maven Central появляется последняя связь:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Git tag v0.1.0
       │
       ▼
исходный код релиза
       │
       ▼
     Gradle
       │
       ▼
артефакты 0.1.0
       │
       ▼
  Maven Central
       │
       ▼
dev.abykov:stream-template-engine:0.1.0
       │
       ▼
другой проект

И теперь другой разработчик действительно может использовать выпуск, не зная, как устроен наш Git-репозиторий, какие ветки мы применяем и где физически находится JAR. Для него весь пройденный нами release process в конечном итоге превращается в одну строку:

1
implementation("dev.abykov:stream-template-engine:0.1.0")

Но пока значительную часть этого процесса мы выполняли мысленно или вручную: запускали тесты, собирали проект, создавали tag, публиковали артефакты. В реальном проекте повторять одну и ту же последовательность вручную для каждого Pull Request и каждого выпуска быстро становится неудобно и, что важнее, ненадежно.

Поэтому следующим слоем над этим процессом появляется автоматизация — CI/CD.

CI/CD: как автоматизируется жизненный цикл версии

К этому моменту release process уже состоит из довольно большого количества действий. При каждом изменении нужно убедиться, что проект компилируется и тесты проходят. Перед выпуском необходимо собрать правильную версию, создать tag, подготовить Release и опубликовать артефакты. Часть этих действий можно выполнять вручную:

1
2
3
4
5
6
git pull
./gradlew build
git tag ...
git push ...
./gradlew publish
...

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

Кроме того, если над проектом работает несколько разработчиков, возникает дополнительный вопрос: почему вообще нужно доверять тому, что у каждого из них локально все было собрано одинаковым образом? Значительную часть этой работы поэтому передают системе автоматизации. Здесь и появляются CI/CD pipelines.

Что такое CI

Continuous Integration, или CI, — практика частой интеграции изменений в общую кодовую базу с автоматической проверкой этих изменений. В нашем проекте простейший CI pipeline может запускаться при каждом Pull Request и выполнять:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Pull Request
     │
     ▼
checkout исходного кода
     │
     ▼
настройка JDK
     │
     ▼
./gradlew build
     │
     ├── compile
     ├── tests
     └── checks
     │
     ▼
успех / ошибка

Теперь Pull Request нельзя считать просто предложением изменить несколько файлов. Вместе с ним автоматически проверяется, что новое состояние проекта хотя бы проходит установленный набор технических проверок.

Например, разработчик создал PR с новой функциональностью:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
feature/configurable-charset
          │
          ▼
     Pull Request
          │
          ▼
          CI
       ┌──┴──┐
       ▼     ▼
     tests  build
       │     │
       └──┬──┘
          ▼
        success

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

Почему Continuous Integration называется continuous

Слово continuous иногда создает впечатление, будто CI-сервер должен буквально непрерывно что-то собирать. Смысл немного другой. Идея состоит в том, что изменения регулярно и часто интегрируются в общую кодовую базу, а автоматические проверки запускаются как часть этого процесса.

Вместо модели:

1
2
3
4
5
разработчик A ── три месяца работы ──┐
                                    │
разработчик B ── три месяца работы ──┼──► большой merge
                                    │
разработчик C ── три месяца работы ──┘

Предпочтительнее короткий цикл:

1
2
3
4
5
6
7
8
9
10
11
12
13
небольшое изменение
       │
       ▼
      PR
       │
       ▼
      CI
       │
       ▼
     merge
       │
       ▼
следующее небольшое изменение

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

CI — это не только тесты

В простом проекте CI действительно часто начинается с одной команды ./gradlew build, но постепенно pipeline может обрастать дополнительными проверками. Например:

1
2
3
4
5
6
7
8
9
CI
│
├── compile
├── unit tests
├── integration tests
├── static analysis
├── code style
├── dependency checks
└── build artifacts

Конкретный набор зависит от проекта. Главное свойство остается прежним: проверки выполняются автоматически и воспроизводимо для каждого подходящего изменения.

Где выполняется CI

CI — это практика и процесс, а не конкретный продукт. Реализовать его можно с помощью разных систем:

1
2
3
4
5
6
GitHub Actions
GitLab CI/CD
Jenkins
TeamCity
CircleCI
...

Для проекта на GitHub естественным выбором часто становится GitHub Actions. Например, workflow может запускаться на push и pull_request, поднимать нужную JDK и выполнять Gradle build. В корпоративной среде ту же роль часто выполняет Jenkins или другая внутренняя CI-система. С точки зрения жизненного цикла версии конкретный инструмент не так важен:

1
2
3
4
5
6
7
изменение
    │
    ▼
CI system
    │
    ▼
одинаковый набор автоматических проверок

А что такое CD

Сокращение CI/CD часто произносится как единый термин, из-за чего может показаться, что CI и CD — две последовательные команды одного инструмента. На самом деле CD описывает следующий уровень автоматизации. Причем под буквой D могут иметь в виду две близкие, но разные практики:

Continuous Delivery и Continuous Deployment.

Их полезно различать.

Continuous Delivery

Continuous Delivery означает, что проект автоматически доводится до состояния, в котором его можно выпустить. После успешных проверок система может собрать необходимые артефакты, подготовить пакет или выполнить другие шаги, необходимые для доставки. Но окончательное решение о выпуске остается за человеком.

Условно:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
изменение
    │
    ▼
   CI
    │
    ▼
проверки прошли
    │
    ▼
готовый результат
    │
    ▼
ручное решение
"выпускаем"
    │
    ▼
  release

То есть проект постоянно находится в состоянии готовности к выпуску, но сам выпуск не обязан происходить после каждого изменения. Для библиотеки это вполне естественная модель.

Мы можем постоянно проверять main, собирать артефакты и знать, что проект готов к релизу, а v0.2.0 выпустить только тогда, когда завершен соответствующий milestone.

Continuous Deployment

Continuous Deployment идет на шаг дальше. Если все автоматические проверки прошли, изменение автоматически доставляется пользователю или в production без отдельного ручного решения:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
изменение
    │
    ▼
   CI
    │
    ▼
автоматические проверки
    │
    ▼
  success
    │
    ▼
автоматический deployment
    │
    ▼
 production

Такой подход часто обсуждается применительно к веб-сервисам: изменение попало в основную ветку, прошло pipeline и автоматически оказалось в production. Но для библиотеки понятие deployment выглядит немного иначе.

У библиотеки нет сервера для deployment

stream-template-engine не является веб-приложением, которое нужно развернуть на production-сервере.

У нее нет:

1
2
3
4
5
6
7
application.jar
      │
      ▼
production server
      │
      ▼
running application

Результат нашего release process — опубликованный компонент, который смогут получить другие проекты. Поэтому для библиотеки доставка может означать публикацию артефактов в Maven Central:

1
2
3
4
5
6
7
8
9
10
11
12
13
исходный код
      │
      ▼
     CI
      │
      ▼
build + tests
      │
      ▼
release artifacts
      │
      ▼
Maven Central

Это хороший пример того, почему CI/CD не стоит понимать исключительно как «Jenkins выкатывает приложение на сервер». Конкретная форма доставки зависит от того, что вообще производит проект.

Pipeline для обычного Pull Request и для релиза различается

Не каждое изменение должно публиковать новую версию библиотеки. Для обычного Pull Request нам достаточно проверить код:

1
2
3
4
5
6
7
8
Pull Request
     │
     ▼
     CI
     │
     ├── compile
     ├── tests
     └── checks

После merge работа над 0.2.0-SNAPSHOT может продолжиться. А release pipeline имеет другую задачу:

1
2
3
4
5
6
7
8
9
10
11
12
13
release
   │
   ▼
checkout нужного commit
   │
   ▼
build + tests
   │
   ▼
создание release artifacts
   │
   ▼
публикация

Такое разделение важно. Мы не хотим после каждого fix: или docs: автоматически публиковать новую стабильную версию только потому, что CI успешно завершился. CI отвечает за качество и интеграцию изменений. Release process отвечает за выпуск определенной версии.

Автоматизация может связать их, но не делает эти понятия одинаковыми.

Tag может стать триггером release pipeline

Теперь особенно хорошо видно, зачем нам понадобился Git tag. Мы договорились, что v0.1.0 обозначает конкретное состояние исходного кода релиза. Следовательно, появление release tag можно использовать как однозначный сигнал системе автоматизации:

Нужно собрать и опубликовать именно этот выпуск.

Например:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
git tag v0.1.0
       │
       ▼
git push origin v0.1.0
       │
       ▼
GitHub Actions
       │
       ▼
checkout v0.1.0
       │
       ▼
./gradlew build
       │
       ▼
publish artifacts
       │
       ▼
Maven Central

Это уже значительно надежнее ручной последовательности команд. Система сама получает состояние проекта, отмеченное tag, выполняет сборку в известном окружении, запускает проверки и публикует соответствующие артефакты. При желании тот же pipeline может создать или дополнить GitHub Release.

Автоматизация связывает независимые механизмы

Именно здесь становится видно, почему в предыдущих разделах мы постоянно подчеркивали независимость отдельных сущностей. Сам по себе Git tag не запускает Gradle. Gradle сам по себе не создает GitHub Release. GitHub Release сам по себе не публикует библиотеку в Maven Central. Maven Central ничего не знает о нашем milestone. Но CI/CD pipeline может связать эти действия в единый процесс:

1
2
3
4
5
6
7
8
9
10
11
Git tag
   │
   │ trigger
   ▼
CI/CD pipeline
   │
   ├── Gradle build
   ├── tests
   ├── artifacts
   ├── Maven Central publishing
   └── GitHub Release

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

А где здесь ежедневные SNAPSHOT-сборки

Теперь можно вернуться еще к одному примеру из начала статьи. В корпоративном проекте CI может работать не только с Pull Request и релизами, но и регулярно публиковать текущие snapshot-сборки. Например:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
merge в develop
      │
      ▼
    Jenkins
      │
      ├── build
      ├── tests
      └── publish
              │
              ▼
            Nexus
              │
              ▼
     2.8.0-SNAPSHOT

Или pipeline может запускаться по расписанию как nightly build:

1
2
3
4
5
6
7
8
9
10
каждую ночь
    │
    ▼
актуальный develop
    │
    ▼
build + tests
    │
    ▼
SNAPSHOT artifact

Именно поэтому разработчик большого корпоративного проекта может годами видеть SNAPSHOT, Jenkins builds и версии в Nexus, но почти не задумываться, как все эти части связаны. Большая часть release infrastructure уже построена кем-то другим. В собственном проекте эти связи приходится создать самостоятельно — и поэтому становится гораздо лучше видно устройство всего процесса.

CI/CD не определяет сам процесс разработки

При этом автоматизация не отвечает на все вопросы проекта. GitHub Actions или Jenkins не решают сами:

  • какие задачи нужно взять в работу;
  • что должно попасть в v0.2.0;
  • когда выпуск считать достаточно полным;
  • какой Issue важнее;
  • нужно ли выпускать новую версию сегодня;
  • как команде организовать работу над задачами.

CI/CD автоматизирует техническое движение изменений и результатов сборки, но не заменяет управление работой. И здесь мы подходим к еще одной группе терминов, которые часто оказываются в одной куче с Git workflow и release process: Agile, Scrum и Kanban.

Они тоже относятся к разработке программного продукта, но находятся совсем на другом уровне.

А при чем здесь Agile, Scrum и Kanban

К этому моменту мы прошли довольно длинную цепочку:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Issue
  │
  ▼
изменение исходного кода
  │
  ▼
Pull Request
  │
  ▼
CI
  │
  ▼
main
  │
  ▼
Git tag
  │
  ▼
Release
  │
  ▼
artifact
  │
  ▼
Maven Central

Может возникнуть вопрос: а где во всей этой схеме находятся Agile, Scrum и Kanban? Ответ немного неожиданный: нигде внутри этой цепочки. Они не являются дополнительными шагами между Issue и Release. Это другой уровень организации разработки. Чтобы увидеть разницу, полезно разделить несколько слоев:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
УПРАВЛЕНИЕ РАБОТОЙ
Agile / Scrum / Kanban
        │
        ▼
что делать, в каком порядке,
как организовать работу команды


УПРАВЛЕНИЕ ИЗМЕНЕНИЯМИ
Issue → Branch → Commit → PR → Merge
        │
        ▼
как изменение проходит
через исходный код


УПРАВЛЕНИЕ ВЕРСИЯМИ И РЕЛИЗАМИ
SemVer → Version → Tag → Release
        │
        ▼
как изменения превращаются
в определенный выпуск


СБОРКА И ДОСТАВКА
CI/CD → Gradle → Artifact → Repository
        │
        ▼
как выпуск проверяется,
собирается и доставляется

Эти слои связаны друг с другом, но отвечают на разные вопросы.

Именно поэтому вопрос вроде «что лучше использовать — Scrum или Git Flow?» изначально поставлен не совсем корректно. Scrum организует работу команды, а Git branching strategy организует изменения в репозитории. Они не являются альтернативами друг другу и вполне могут использоваться одновременно.

Agile — не конкретный workflow

Начнем с самого широкого понятия. Agile — это не последовательность действий и не конкретная система управления задачами. Нельзя включить Agile в настройках GitHub и получить новый workflow. Под этим названием объединяется набор идей и принципов разработки, в которых большое значение имеют небольшие итерации, работающий результат, обратная связь и способность менять планы по мере появления новой информации.

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

1
2
3
4
5
6
7
минимальный API
      │
      ▼
первая реализация matcher
      │
      ▼
тестирование

Во время тестирования обнаружилась проблема с пересекающимися placeholder:

1
2
3
4
5
6
7
8
9
10
первая реализация
      │
      ▼
overlapping prefix
      │
      ▼
новый test case
      │
      ▼
исправление matcher

Затем реальное использование API показало, что нужна настройка charset:

1
2
3
4
5
6
7
8
9
10
существующий API
      │
      ▼
новая потребность
      │
      ▼
Issue
      │
      ▼
configurable charset

Мы не пытались в самом начале идеально спроектировать библиотеку на несколько лет вперед. Вместо этого создавали работающий результат небольшими шагами и уточняли решение по мере появления новой информации. Это хорошо соответствует Agile-подходу, хотя никакого отдельного «Agile process» в Git-репозитории при этом может вообще не существовать.

Scrum организует работу итерациями

Scrum гораздо конкретнее. Он предлагает организовать работу вокруг ограниченных по времени итераций — спринтов. В упрощенном виде процесс можно представить так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Product Backlog
      │
      ▼
Sprint Planning
      │
      ▼
Sprint Backlog
      │
      ▼
    Sprint
      │
      ▼
  Increment
      │
      ▼
Review / Retrospective

Предположим, над stream-template-engine работает команда и в backlog находятся задачи:

1
2
3
4
5
6
configurable charset
CI
documentation
benchmarks
Maven Central publishing
Spring integration

На Sprint Planning команда может решить, что цель ближайшего спринта — приблизить библиотеку к первому публичному выпуску. В Sprint Backlog попадут, например:

1
2
3
4
5
6
7
Sprint Goal:
Prepare the project for the first public release

Sprint Backlog:
- configurable charset
- CI
- documentation

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Sprint
  │
  ├── Issue
  │     │
  │     ▼
  │   Branch
  │     │
  │     ▼
  │     PR
  │     │
  │     ▼
  │   Merge
  │
  ├── Issue
  │     │
  │     ▼
  │    ...
  │
  └── Issue

Scrum определяет как команда организует работу, а Git workflow — как конкретные изменения проходят через репозиторий.

Sprint и Release — тоже не одно и то же

Здесь есть еще одна распространенная путаница. Если команда работает двухнедельными спринтами, это не означает, что каждые две недели обязательно должна появляться новая release version.

Например:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Sprint 1
   │
   ├── charset
   └── tests

Sprint 2
   │
   ├── CI
   └── documentation

Sprint 3
   │
   └── publishing
          │
          ▼
       v0.1.0

Один релиз может собираться несколько спринтов. Возможна и обратная ситуация: команда способна выпускать изменения несколько раз в течение одного спринта.

1
2
3
4
5
6
7
                Sprint

   ┌──────────────────────────────┐
   │                              │
   │ v1.4.3     v1.4.4     v1.5.0 │
   │                              │
   └──────────────────────────────┘

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

Kanban управляет потоком работы

Kanban смотрит на работу немного иначе. Вместо обязательного разделения на спринты основное внимание уделяется потоку задач через последовательные состояния.

Простейшая Kanban-доска может выглядеть так:

1
2
3
4
5
6
7
┌────────────┬─────────────┬────────────┬──────────┐
│  Backlog   │    Todo     │ In Progress│   Done   │
├────────────┼─────────────┼────────────┼──────────┤
│ Benchmarks │ Charset     │ Matcher    │ Path API │
│ Website    │ CI          │            │ Tests    │
│ Spring API │ Docs        │            │          │
└────────────┴─────────────┴────────────┴──────────┘

Работа постепенно перемещается по доске:

1
2
3
4
5
6
7
8
9
10
11
12
13
Backlog
   │
   ▼
 Todo
   │
   ▼
In Progress
   │
   ▼
 Review
   │
   ▼
 Done

Здесь важен не номер текущего спринта, а состояние потока: сколько работы уже начато, где возникают задержки, не набрали ли разработчики слишком много задач одновременно. Одна из важных идей Kanban — ограничение Work In Progress, или WIP. Если разработчик одновременно начал десять задач:

1
2
3
4
5
6
7
8
9
10
In Progress

#12 Charset
#15 CI
#18 Docs
#21 Publishing
#24 Benchmarks
#27 Matcher optimization
#31 Spring integration
...

формально работы выполняется очень много, но до Done может долго не доходить почти ничего. WIP limit заставляет ограничивать количество одновременно начатой работы и сначала доводить уже взятые задачи до завершения. Например:

1
2
3
4
5
In Progress
WIP limit: 2

#12 Charset
#15 CI

Прежде чем взять следующую задачу, одну из текущих нужно продвинуть дальше.

Issue и карточка на доске отвечают на разные вопросы

Здесь снова появляется знакомое нам разделение сущностей.

Issue: #12 Add configurable charset support

отвечает на вопрос:

Что нужно сделать?

Положение этой задачи на Kanban board: In Progress

отвечает на другой:

В каком состоянии сейчас находится эта работа?

А milestone: v0.1.0

добавляет третий вопрос:

К какому будущему выпуску относится эта работа?

Одна задача таким образом одновременно может иметь несколько измерений:

1
2
3
4
5
6
Issue #12
Add configurable charset support
        │
        ├── Status: In Progress
        │
        └── Milestone: v0.1.0

Это не дублирование информации. Каждый атрибут описывает задачу с собственной стороны.

GitHub Projects и Jira Board

В GitHub поток работы можно визуализировать с помощью GitHub Projects. В корпоративной разработке похожую роль часто выполняет Jira Board.

Например:

1
2
3
4
5
6
GitHub                         Jira

Issue                  ≈      Issue
Milestone              ≈      Fix Version
Project / Board        ≈      Board
Status                 ≈      Status

Как и в случае с Milestone и Fix Version, это не полные технические эквиваленты. Возможности систем отличаются. Но для общей картины соответствие полезно. Мы можем иметь GitHub Issue #12, добавить его в Milestone v0.1.0 и одновременно видеть его на Project board в колонке In Progress. В Jira аналогичная задача может иметь Fix Version 3.7.0 и находиться в статусе In Progress на командной доске.

Kanban и milestone прекрасно существуют одновременно

Иногда после знакомства с этими понятиями возникает ощущение, что нужно выбрать что-нибудь одно: либо milestone, либо Kanban. На самом деле они снова отвечают на разные вопросы.

Milestone группирует работу по выпуску:

1
2
3
4
5
6
v0.1.0
│
├── Charset
├── CI
├── Docs
└── Publishing

Kanban группирует ту же работу по текущему состоянию:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Todo
│
├── Docs
└── Publishing


In Progress
│
└── Charset


Done
│
└── CI

Мы просто смотрим на один набор задач в разных проекциях. В первой нас интересует состав будущей версии. Во второй — движение работы.

Что выбрать небольшому проекту

Для маленькой библиотеки, особенно если над ней работает один человек, полноценный Scrum часто оказывается избыточным. Можно, конечно, каждое утро проводить самому себе Daily Scrum:

Что я сделал вчера?

Что собираюсь сделать сегодня?

Есть ли у меня blockers?

Но практической пользы от такой церемонии будет немного. Гораздо естественнее оставить простой backlog из Issues, объединять задачи ближайшего выпуска через Milestone и при необходимости использовать небольшую Kanban-доску:

1
2
3
4
5
6
7
8
9
10
Issues
   │
   ├── Milestone v0.1.0
   │
   └── Milestone v0.2.0
   │
   ▼
Project Board

Backlog → Todo → In Progress → Done

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

Все эти подходы не конкурируют друг с другом

Теперь можно собрать несколько терминов, которые часто оказываются в одном разговоре, хотя описывают совершенно разные стороны разработки:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Agile
│
└── Какие принципы лежат в основе организации разработки?


Scrum
│
└── Как организовать работу команды итерациями?


Kanban
│
└── Как управлять потоком работы?


Git workflow
│
└── Как изменения проходят через Git?


Conventional Commits
│
└── Как описывать изменения в истории Git?


Semantic Versioning
│
└── Что означает номер выпуска?


CI
│
└── Как автоматически проверять изменения?


CD
│
└── Как автоматизировать подготовку и доставку результата?


Artifact repository
│
└── Где хранить и распространять опубликованные компоненты?

Один проект вполне может одновременно использовать Scrum, Kanban-практики, feature branches, Conventional Commits, Semantic Versioning, GitHub Actions и Maven Central. Противоречия здесь нет: каждый механизм решает свою часть общей задачи.

И теперь у нас наконец есть почти все элементы, с которыми мы сталкивались по отдельности.

Остается собрать их в одну картину и посмотреть на весь путь версии 0.1.0-SNAPSHOT — от первых задач до момента, когда другой разработчик подключает готовую 0.1.0 как обычную Gradle dependency.

Соберем все вместе

Мы начали с одной строки в Gradle:

1
version = "0.1.0-SNAPSHOT"

На первый взгляд SNAPSHOT выглядит всего лишь суффиксом номера версии. Но за переходом от 0.1.0-SNAPSHOT к 0.1.0 скрывается гораздо больше, чем удаление нескольких символов из build.gradle.

Если собрать пройденный путь целиком, жизненный цикл первого выпуска stream-template-engine будет выглядеть примерно так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
                         ПЛАНИРОВАНИЕ

                    Milestone v0.1.0
                           │
                    Issues релиза
                           │
                           ▼

                         РАЗРАБОТКА

                    0.1.0-SNAPSHOT
                           │
                Issue → Branch → Commits
                           │
                           ▼
                     Pull Request
                           │
                    Review + CI
                           │
                           ▼
                          main
                           │
                           ▼

                         РЕЛИЗ

                    version = "0.1.0"
                           │
                           ▼
                     Git tag v0.1.0
                           │
              ┌────────────┴────────────┐
              │                         │
              ▼                         ▼
      GitHub Release               Gradle build
                                        │
                                        ▼
                                   artifacts
                                        │
                                        ▼
                                  Maven Central
                                        │
                                        ▼

             dev.abykov:stream-template-engine:0.1.0
                                        │
                                        ▼
                                другой проект

При этом почти каждый элемент схемы отвечает на собственный вопрос.

Milestone определяет, что мы хотим закончить для будущего выпуска. Issues разбивают этот объем на отдельные задачи. Git хранит историю изменений, а Pull Request и CI помогают провести изменения через review и автоматические проверки.

Semantic Versioning дает смысл номеру выпуска, а Conventional Commits помогают сохранить информацию о характере отдельных изменений в истории проекта.

Gradle version определяет версию собираемого компонента. Git tag связывает имя выпуска с конкретной точкой истории исходного кода. GitHub Release представляет этот выпуск человеку. Gradle создает артефакты, а Maven Central делает опубликованный компонент доступным другим проектам.

Поэтому несколько очень похожих обозначений:

1
2
3
4
5
6
Milestone v0.1.0
version = "0.1.0"
Git tag v0.1.0
GitHub Release v0.1.0
stream-template-engine-0.1.0.jar
dev.abykov:stream-template-engine:0.1.0

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

Отдельно от этой цепочки находятся Agile, Scrum и Kanban. Они не превращают commit в JAR и не определяют устройство Git tag. Эти подходы помогают организовать работу вокруг жизненного цикла продукта: планировать задачи, управлять их потоком, получать обратную связь и постепенно двигать проект вперед.

В результате привычная строка 0.1.0-SNAPSHOT оказывается хорошей точкой входа во всю эту систему.

Она означает не просто «какая-то тестовая версия», а текущее изменяемое состояние будущего выпуска. Milestone очерчивает границы этого выпуска, разработка наполняет его изменениями, tag фиксирует конкретное состояние исходного кода, build превращает его в артефакты, а публикация делает версию доступной пользователям.

После выпуска цикл не заканчивается. В main снова начинается разработка следующей версии:

1
version = "0.2.0-SNAPSHOT"

Появляется новый milestone, новые Issues, новые Pull Request и новые изменения:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0.1.0-SNAPSHOT
       │
       ▼
     0.1.0
       │
       ▼
0.2.0-SNAPSHOT
       │
       ▼
     0.2.0
       │
       ▼
0.2.1-SNAPSHOT
       │
       ▼
     0.2.1
       │
      ...

И то, что раньше выглядело набором разрозненных терминов — SNAPSHOT, milestone, SemVer, tag, Release, artifact, CI/CD, — становится последовательными частями одного процесса: жизненного цикла версии проекта от разработки до пользователя.

This post is licensed under CC BY 4.0 by the author.