open atlas
↑ К треку
CI/CD-пайплайны CICD · 12 · 01

Версионирование и теги: semver, conventional commits и неизменяемый релизный ref

Semver кодирует контракт совместимости: major ломает, minor добавляет, patch чинит. Conventional commits дают CI вывести bump автоматически. Аннотированный неизменяемый git-тег плюс digest — аудируемый ref, на который указывает релиз, и то, что переотгружает откат.

CICD Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Bump выглядел безобидно: 2.4.12.5.0, минорный релиз, «просто новое опциональное поле в ответе». Через сорок минут после деплоя три команды-потребителя запейджились разом — их клиенты десериализовали ответ в строгую структуру, отвергавшую неизвестные поля, и каждый запрос теперь падал в 500. Изменение на практике не было аддитивным; это был ломающий change в одежде минора. Релиз-инженер вписал версию в файл руками, выбрал «minor» нутром и отгрузил. Никто не мог ответить на единственный важный во время отката вопрос — «какая последняя хорошая версия?» — потому что история тегов была супом из v2.5.0, v2.5.0-hotfix, release-2 и latest, который дважды форс-пушили. Прижившийся фикс был не лучшим нутром: он сделал так, чтобы машина решала bump из истории коммитов, и чтобы каждый релиз указывал на один неизменяемый ref, который никто не может сдвинуть.

Semver — контракт, а не счётчик

Прежде чем выбрать следующий номер версии, спроси себя: «Если потребитель обновится не читая changelog — что-нибудь у него сломается?» Ответ и определяет bump (повышение версии). Версия MAJOR.MINOR.PATCH — это обещание тому, кто от тебя зависит, читаемое справа налево:

  • PATCH (2.4.12.4.2): обратносовместимый багфикс. Потребитель может взять его вслепую.
  • MINOR (2.4.12.5.0): обратносовместимая новая функциональность. Старые вызыватели продолжают работать; новые получают больше.
  • MAJOR (2.4.13.0.0): ломающий change. То, что работало раньше, теперь нет — удалённое поле, изменённый дефолт, ужесточённая валидация, переименованный эндпоинт.

Вместе три уровня означают, что любая зависящая команда может прочесть одно число и понять риск обновления без чтения диффа — ровно поэтому bump обязан быть честным.

Баг из Hook был ошибкой категории: команда назвала добавленное поле «минором», но строгая десериализация потребителей делала любое новое поле ломающим. Урок: bump определяется поверхностью совместимости потребителя, а не размером диффа. Однострочное изменение может быть major; тысячестрочный внутренний рефакторинг с неизменным публичным API — patch. До 1.0.0 контракт явно слабее — 0.y.z говорит «сломаться может что угодно» — поэтому библиотеки, годами отгружающие 0.x, тихо отказываются от гарантии. Build-метаданные (+build.5) и pre-release теги (-rc.1, -beta.2) расширяют грамматику: 2.5.0-rc.1 < 2.5.0, и pre-release сортируются раньше своего финала, так что release candidate никогда случайно не перебивает GA, которому он предшествует.

Викторина

Изменение удаляет одно поле из JSON-ответа, которое ни один текущий клиент не читает, но технически это удаление. Часть потребителей использует строгую десериализацию, отвергающую неизвестные поля; никто не читает удалённое поле. Какой корректный semver-bump и почему?

Conventional commits: пусть машина вычисляет bump

Если bump должен кодировать риск совместимости для потребителя, зачем доверять его человеку под давлением? Ручной выбор bump — ровно то человеческое суждение, что провалилось в Hook. Conventional commits переносят решение в сообщение коммита, где оно аудируемо и механично. Тема каждого коммита несёт тип:

feat: add pagination cursor to /orders     → MINOR
fix: stop double-charging on retry          → PATCH
refactor: extract the rate limiter          → нет релиза
feat!: drop the v1 auth header              → MAJOR  (! помечает break)
feat: switch storage backend

BREAKING CHANGE: the on-disk format changed;
run the v3 migration before deploying.       → MAJOR (footer тоже триггерит)

Релиз-инструмент (semantic-release, release-please, changesets) сканирует коммиты с последнего тега, берёт наивысший bump, подразумеваемый любым из них — один feat! где угодно делает весь релиз major — вычисляет следующую версию, пишет changelog и создаёт тег в CI. Версия теперь детерминированная функция истории, а не поле, которое кто-то редактирует. Дисциплина, которой это требует, реальна: bump честен ровно настолько, насколько честны типы коммитов, поэтому feat! и BREAKING CHANGE: надо принуждать (commit-lint на PR, squash-merge с выверенной темой) — иначе автоматизация радостно отгрузит major как patch.

Викторина

С последнего тега лог коммитов содержит: три коммита `fix:`, один `feat:` и один `feat!:`. Какой bump версии вычислит инструмент conventional-commits из `2.4.1`?

Тег — это неизменяемый ref

Semver-строка — ярлык; git-тег — то, на что релиз реально указывает, и его дисциплина — то, что сделало откат из Hook невозможным. Вес несут два правила. Первое: предпочитай аннотированный тег (git tag -a v2.5.0 -m ...) — это реальный объект, несущий tagger, дату и сообщение, а не lightweight-тег, который есть голый указатель без метаданных и аудит-следа. Второе: релизный тег неизменяем: раз v2.5.0 запушен и из него вырезан релиз, ты его никогда не двигаешь. Если v2.5.0 сломан, ты отгружаешь v2.5.1; ты не форс-пушишь v2.5.0 на новые байты, потому что любой, кто уже вытянул v2.5.0, теперь разойдётся с тобой в том, что эта версия есть — ровно хаос дважды-форс-пушенного latest из Hook. Тег версии — человекочитаемый алиас; несущая идентичность — это digest содержимого (@sha256:...) собранного артефакта, который контент-адресуем и не может быть перезаписан. Релизная запись в GitHub Releases связывает три воедино — тег, semver-имя и digest отгруженного — так что ответ на «какая последняя хорошая версия?» это lookup, а не археологические раскопки. Откат тогда механичен: переотгрузи неизменяемый ref (digest прошлого тега), который именует релизная запись, и ты байт-в-байт вернулся к известно-хорошему.

Почему это работает

Зачем пиновать digest, если тег уже именует версию? Потому что тег — мутабельный указатель, а digest контент-адресуем. Если твой деплой говорит «отгрузить v2.5.0», а кто-то (плохой CI-джоб, форс-пуш, перетегирование) сдвинет v2.5.0 на другие байты, твой откат переотгрузит не тот артефакт под именем, которому ты доверяешь — отказ из мира Docker, где переотгрузка latest заново отгружает баг. Пин @sha256:... означает, что релизная запись указывает на байты, которые никогда не изменятся, так что «переотгрузить последний хороший релиз» возвращает ровно к тому, что тестировалось, а не к тому, во что тег теперь резолвится.

Вспомните перед уходом
  1. 01
    Почему semver-bump определяется поверхностью совместимости потребителя, а не размером изменения кода, и приведи пример, где крошечный дифф — это major?
  2. 02
    Пройди, как conventional commits превращают историю коммитов в версию, и что делает результат достаточно надёжным для автоматизации.
Итог

Номер версии — контракт, читаемый справа налево: PATCH — обратносовместимый фикс, который потребитель берёт вслепую, MINOR — обратносовместимое добавление, оставляющее старых вызывателей работающими, а MAJOR — слом — удаление, изменённый дефолт, ужесточённое правило — определяемый поверхностью совместимости потребителя, а не размером диффа, поэтому однострочное удаление поля — major, а огромный внутренний рефакторинг — patch. До 1.0 гарантия явно слабее. Ручной выбор bump — то человеческое суждение, что проваливается; conventional commits переносят его в сообщение (feat → minor, fix → patch, feat! или BREAKING CHANGE: → major), и релиз-инструмент берёт наивысший bump по всем коммитам с последнего тега, вычисляет следующую версию детерминированно, пишет changelog и создаёт тег в CI — честно лишь при принуждении типов коммитов. Релиз указывает на аннотированный неизменяемый git-тег: раз вырезан, ты отгружаешь v2.5.1, а не форс-пушишь v2.5.0, потому что сдвиг тега заставляет всех, кто его вытянул, разойтись в том, что есть версия. Несущая идентичность под тегом — digest содержимого (@sha256:...), контент-адресуемый и несдвигаемый, а релизная запись GitHub связывает тег, имя и digest, так что «какая последняя хорошая версия?» — это lookup. Откат тогда механичен: переотгрузи digest прошлой релизной записи и ты байт-в-байт вернулся к известно-хорошему — что работает лишь потому, что тег был неизменяем с самого начала. Теперь, когда встретишь версию, выбранную нутром во время инцидента, ты знаешь точный режим отказа — и точный фикс.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.