SemVer, conventional commits и автоматические changelog
SemVer кодирует контракт — MAJOR ломает потребителей, MINOR добавляет совместимо, PATCH чинит совместимо. Conventional commits делают это намерение машиночитаемым, чтобы инструмент посчитал bump и сгенерировал changelog. Забудь пометить слом — и он уедет как MINOR.
Библиотека выпускает 2.4.0 в пятницу. Это правка в одно слово — parse() теперь возвращает null вместо того, чтобы бросать исключение на плохом входе, «крошечное улучшение». За выходные три сервиса-потребителя, обернувшие parse() в try/catch, тихо начинают писать null-строки в базу, потому что исключение, на которое опиралась их ветка ошибки, больше не возникает. Изменение уехало как MINOR, поэтому диапазон ^2.0.0 у каждого потребителя автоматически подтянул его. Защита автора: «Я же ничего не удалял». Но поведение и есть контракт. Слом, не помеченный как слом, уехал ко всем, кто доверял номеру версии.
SemVer — обещание о том, что может сломаться
Зачем вообще нужен номер версии? Потому что потребители автоматизируют обновления: ^2.0.0 молча подтягивает каждый MINOR и PATCH. Если ты пометишь слом неверно, он уедет ко всем, кто тебе доверял. Этот контракт и есть весь смысл раздела.
Версия MAJOR.MINOR.PATCH — это не журнал затраченных усилий, а контракт совместимости, направленный на потребителя. SemVer 2.0.0 фиксирует смысл каждого поля точно:
- MAJOR — ты внёс обратно несовместимое изменение. Любой наблюдаемый потребителем слом считается: удалённая функция, переименованное поле, суженный диапазон входа, изменённое значение по умолчанию или — как в хуке — изменённое поведение, на которое опирался их код. Если корректное прежде использование теперь может сломаться, это MAJOR.
- MINOR — ты добавил функциональность обратно совместимым образом. Новый эндпоинт, новый необязательный аргумент, новое поле в ответе. Старый код продолжает работать нетронутым.
- PATCH — только обратно совместимое исправление бага. Контракт не меняется; ты заставил код честно выполнять то, что уже обещал.
Тонкий, сеньорский момент: «слом» означает контракт, наблюдаемый потребителем, а не «удалённый код». Ужесточение валидации, смена типа ошибки, изменение порядка вывода, который потребитель парсил, — всё это может быть ломающим, хотя ничего не удалено. Автор, говорящий «я ничего не удалял», держит в голове неверную модель.
Два угла важны на практике. 0.y.z — это начальная разработка: по SemVer публичный API явно не стабилен, поэтому что угодно МОЖЕТ измениться в любом релизе — до 1.0 нельзя опираться на то, что MINOR/PATCH вообще что-то значат, поэтому «0ver»-проекты (версии, которые никогда не доходят до 1.0.0 именно чтобы увернуться от обещания MAJOR) раздражают потребителей. А pre-release / build-метаданные расширяют формат: 1.4.0-rc.1 (release candidate, сортируется перед 1.4.0) и 1.4.0+build.42 (build-метаданные, игнорируются при сравнении старшинства) позволяют публиковать превью, не тратя настоящий номер.
| Что ты изменил | Conventional commit | Bump (от 2.4.1) |
|---|---|---|
| Починил падение на пустом входе | fix: handle empty input | PATCH → 2.4.2 |
Добавил необязательный аргумент timeout | feat: add timeout option | MINOR → 2.5.0 |
| Переименовал публичное поле | feat!: rename id to uuid | MAJOR → 3.0.0 |
parse() теперь возвращает null вместо throw | fix: … + футер BREAKING CHANGE: | MAJOR → 3.0.0 |
| Переписал комментарий в доке | docs: clarify return type | релиза нет |
Conventional commits: машиночитаемый сигнал
Разрыв между «я знаю, что это ломающее» и «инструмент это знает» закрывают Conventional Commits — крошечная грамматика на заголовке и футере коммита, которую умеет читать парсер. Контракт:
fix: a bug fix → PATCH
feat: a new feature → MINOR
feat!: a feature that breaks the API → MAJOR (! помечает слом)
# или тот же слом, выраженный в футере:
refactor: drop the legacy code path
BREAKING CHANGE: parse() now returns null instead of throwing.
Wrap callers that relied on the throw.Два способа подать сигнал MAJOR, и оба должны присутствовать в сообщении, чтобы инструмент их увидел: ! после типа/скоупа (feat!:, fix(api)!:) или футер BREAKING CHANGE: (заметь буквальный токен {BREAKING CHANGE} с пробелом, а не дефисом). Типы, не затрагивающие отгружаемое поведение — docs:, chore:, test:, style:, ci: — не дают bump вовсе. Это несущая конвенция всего юнита: bump вычисляется из типа коммита, поэтому непомеченный слом невидим для машины и уезжает не на том уровне.
▸Почему это работает
Зачем футер и !? ! — это быстрый визуальный флаг в строке заголовка, который работает, даже когда тело срезано; футер BREAKING CHANGE: даёт место описать миграцию. Конвенция — использовать оба для всего несущего: feat!: в заголовке, чтобы однострочный squash всё ещё нёс сигнал, и футер, чтобы сгенерированный changelog имел настоящий абзац «что поменять в твоём коде». Один лишь футер — ловушка: он живёт в теле коммита, и squash-merge, оставляющий только заголовок, тихо его роняет.
Два инструмента, две философии: декларировать vs выводить
Когда коммиты несут намерение, инструмент превращает историю в релиз. Два доминирующих варианта стоят на противоположных концах оси «явно vs автоматически».
changesets делает намерение явным и пер-PR. Закончив работу, ты запускаешь changeset add, выбираешь затронутые пакеты, выбираешь bump и пишешь человеческое summary; он кладёт markdown-файл в .changeset/, который едет с твоей веткой и проходит код-ревью. На релизе changeset version поглощает все ожидающие файлы, вычисляет новую версию каждого пакета и пишет changelog; changeset publish отгружает их. Поскольку контрибьютор декларирует bump в проверяемом файле, это отлично для монорепо — он понимает межпакетные bump’ы зависимостей — и ему никогда не приходится угадывать по тексту коммита.
---
"@acme/parser": major
"@acme/cli": patch
---
parse() now returns null on bad input instead of throwing.
Callers that relied on the throw must add an explicit null check.release-please делает намерение выводимым и автоматическим. Бот парсит твои conventional commits на main, вычисляет следующую версию и поддерживает постоянный «release PR», чей diff — это обновлённые файлы версии плюс сгенерированный CHANGELOG.md. PR непрерывно актуализируется по мере новых коммитов; когда ты его мёржишь, release-please ставит тег релиза и публикует. Ты не пишешь файлов намерения — коммит и есть намерение, — что бесшовно, пока коммит не помечен неверно, потому что бот верит коммиту, а не твоим намерениям.
Монорепо из ~12 публикуемых пакетов нужны автоматические, ревьюируемые релизы. Несколько пакетов зависят друг от друга, и контрибьюторы squash-мёржат PR. Выбери механизм релиза.
Режим отказа: непомеченный слом уезжает как MINOR
Всё выше существует, чтобы предотвратить один конкретный инцидент, и он самый частый в автоматизации релизов. Контрибьютор делает ломающее изменение, но пишет feat: improve parse() — без !, без футера. Инструмент послушно вычисляет MINOR, потому что именно это коммит говорит. Новая версия уезжает, и каждый потребитель, запиненный на caret-диапазон (^2.0.0, дефолт npm), автоматически подтягивается в слом без единого человеческого решения. Номер версии соврал, а контракт, которому потребители доверяли как воротам против ломающих изменений, не сработал.
Squash-merge — усилитель. Если слом был просигналлен только в футере BREAKING CHANGE: (в теле коммита), а PR squash-мёржится, оставляя лишь строку заголовка, футер испаряется, и инструмент видит обычный feat: — снова MINOR. Защиты конкретны: насаждай конвенцию через commitlint в CI, чтобы непарсимый или нетипизированный коммит проваливал проверку; требуй маркер в заголовке стиля feat!: (а не только футер) для сломов, чтобы squash его сохранил; или используй changesets, где bump живёт в ревьюируемом файле, который squash не срежет. И генерируй changelog из того же сигнала — рукописный CHANGELOG.md дрейфует от реальности в тот момент, когда кто-то забыл его обновить; сгенерированный по построению ровно то, что коммиты сказали отгрузить.
Ты меняешь функцию так, что она возвращает null на плохом входе вместо throw — существующие вызыватели опирались на throw. От 2.4.1 какой bump верный и как его просигналлить?
Слом был просигналлен только в футере BREAKING CHANGE: в теле коммита. PR squash-мёржат, оставляя лишь заголовок 'feat: improve parser'. release-please бампит MINOR. Почему и что это предотвращает?
Расставь шаги релиза, управляемого коммитами (от нажатия клавиш разработчиком до тегированного релиза):
- 1 Разработчик пишет conventional commit (feat: / fix: / feat!:), фиксирующий намерение
- 2 Инструмент анализирует коммиты с прошлого релиза и классифицирует каждый по типу
- 3 Инструмент вычисляет следующую версию: высшее из PATCH (fix) / MINOR (feat) / MAJOR (слом)
- 4 Инструмент генерирует запись CHANGELOG из классифицированных коммитов
- 5 Мейнтейнер мёржит release PR → версия тегируется и релиз публикуется
- 01Сопоставь fix / feat / feat! их bump'ам SemVer и объясни, почему «я ничего не удалял» — неверный тест на ломающее изменение.
- 02Сравни changesets и release-please и опиши провал на squash-merge, который каждый переживает или которому поддаётся.
Номер версии — контракт, направленный на потребителя, а не журнал усилий: MAJOR значит, что ты внёс обратно несовместимое изменение, MINOR — обратно совместимое добавление, PATCH — обратно совместимый фикс — и сеньорский момент в том, что «слом» означает любое наблюдаемое потребителем изменение контракта, включая изменённое поведение, на которое опирались корректные существующие вызыватели, а не только удалённый код. Помни про углы: 0.y.z — начальная разработка, где ничего не обещано, а -rc.1 / +build расширяют формат для превью. Conventional commits (соглашение о структуре сообщений коммитов) делают это намерение машиночитаемым — fix: → PATCH, feat: → MINOR, feat!: или футер BREAKING CHANGE: → MAJOR — чтобы инструмент посчитал bump и сгенерировал changelog вместо человеческого угадывания. changesets фиксирует намерение явно в ревьюируемом .changeset/-файле на PR, что знает про монорепо и переживает squash-merge; release-please выводит его из коммитов и поддерживает release PR, что бесшовно, но настолько же верно, насколько верны метки коммитов. Инцидент, от которого защищает весь юнит — непомеченный слом, уезжающий как MINOR (автоматически подтягивая каждого caret-запиненного потребителя в него), и его усилитель — squash-merge, роняющий футер BREAKING CHANGE:, живущий только в теле. Ставь маркер слома в строку заголовка, насаждай его в CI и генерируй changelog из того же сигнала, чтобы он никогда не дрейфовал от того, что реально уехало. Теперь, когда встретишь feat:-коммит, касающийся обработки ошибок, — остановись и спроси себя, опирался ли кто-то на старое поведение: именно так непомеченный MAJOR уезжает как MINOR.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.