Версионирование и теги: semver, conventional commits и неизменяемый релизный ref
Semver кодирует контракт совместимости: major ломает, minor добавляет, patch чинит. Conventional commits дают CI вывести bump автоматически. Аннотированный неизменяемый git-тег плюс digest — аудируемый ref, на который указывает релиз, и то, что переотгружает откат.
Bump выглядел безобидно: 2.4.1 → 2.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.1→2.4.2): обратносовместимый багфикс. Потребитель может взять его вслепую. - MINOR (
2.4.1→2.5.0): обратносовместимая новая функциональность. Старые вызыватели продолжают работать; новые получают больше. - MAJOR (
2.4.1→3.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, которому он предшествует.
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.
Тег — это неизменяемый 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:... означает, что релизная запись указывает на байты, которые никогда не изменятся, так что «переотгрузить последний хороший релиз» возвращает ровно к тому, что тестировалось, а не к тому, во что тег теперь резолвится.
- 01Почему semver-bump определяется поверхностью совместимости потребителя, а не размером изменения кода, и приведи пример, где крошечный дифф — это major?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.