Конвенция коммитов и семантическое версионирование

Урок про то, как превратить сообщения коммитов в машиночитаемый формат и автоматически считать из них версию и changelog.

Conventional Commits — соглашение о структуре сообщения коммита (тип(область): описание), которое позволяет инструментам понимать смысл изменения и автоматизировать версии и журнал изменений.

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

Сообщение «fix stuff» бесполезно: по нему нельзя ни понять, что менялось, ни решить, как поднимать версию, ни собрать changelog. Через полгода даже сам автор не вспомнит, что скрывается за «правки» или «обновил». А когда таких коммитов в истории тысячи, проект теряет память о себе: расследование инцидента превращается в чтение диффов вслепую. Conventional Commits задаёт жёсткий, но простой формат, который читается и человеком, и скриптом. Главная выгода — автоматизация: инструмент сам определяет следующий номер версии и генерирует журнал изменений, а люди перестают спорить «это minor или patch?». Дополнительный бонус — дисциплина мышления: чтобы выбрать тип, разработчик вынужден честно ответить себе, что именно он делает — добавляет возможность, чинит баг или просто переставляет код. Часто на этом этапе и обнаруживается, что в один коммит свалено три разных по смыслу изменения.

Формат сообщения

Структура такая:

<тип>(<область>): <краткое описание>

<тело: что и зачем>

<футер: BREAKING CHANGE / ссылки на задачи>

Заголовок обязателен, область и тело — нет. Область в скобках уточняет, какой части системы касается изменение (auth, cart, api), и помогает группировать записи в changelog. Тело раскрывает мотивацию — почему понадобилось изменение, а не пересказывает дифф. Футер хранит метаданные: пометку о ломающем изменении и ссылки на задачи (Closes #42). Заголовок принято держать коротким (около 50 символов) и в повелительном наклонении — «добавить», «исправить», как будто вы продолжаете фразу «этот коммит, если применить, …». Основные типы:

ТипСмыслВлияние на версию
featновая функциональностьMINOR
fixисправление багаPATCH
docsтолько документация
refactorправка без смены поведения
testтесты
choreсборка, зависимости, рутина
perfоптимизацияPATCH

Примеры реальных сообщений:

git commit -m "feat(auth): добавить вход через Google"
git commit -m "fix(cart): не терять скидку при смене валюты"
git commit -m "docs(readme): описать переменные окружения"
git commit -m "refactor(api): вынести валидацию в отдельный модуль"

Связь с semantic versioning

Semver — это формат версии MAJOR.MINOR.PATCH (например, 2.4.1) со строгим смыслом каждого числа:

  • MAJOR — несовместимое изменение, ломающее существующий код пользователей.
  • MINOR — новая возможность, обратно совместимая.
  • PATCH — исправление, не меняющее API.

Главный практический смысл semver — предсказуемость обновлений. Увидев, что у зависимости вышла версия 2.4.2 вместо 2.4.1, вы понимаете: это только багфикс, обновляться безопасно. А скачок до 3.0.0 — повод насторожиться: что-то сломали, нужно читать changelog. Менеджеры пакетов опираются на это в диапазонах версий: запись ^2.4.0 означает «бери любые minor и patch, но не перескакивай на новый major», то есть «давай новое, но не ломай меня». Conventional Commits отображается на semver почти один-в-один: fix → бамп PATCH, feat → бамп MINOR, а ломающее изменение → бамп MAJOR. Ломающее изменение помечают либо восклицательным знаком после типа, либо футером BREAKING CHANGE:.

git commit -m "feat(api)!: переименовать поле userId в accountId"

# или развёрнуто:
git commit -m "feat(api): переименовать userId в accountId

BREAKING CHANGE: клиенты, читавшие userId, должны перейти на accountId"

Покажем правило бампа кодом — функция берёт текущую версию и тип изменения и возвращает следующую:

def bump(version, kind):
    major, minor, patch = (int(x) for x in version.split("."))
    if kind == "breaking":
        return f"{major + 1}.0.0"
    if kind == "feat":
        return f"{major}.{minor + 1}.0"
    if kind == "fix":
        return f"{major}.{minor}.{patch + 1}"
    return version

v = "1.4.2"
for k in ["fix", "feat", "breaking"]:
    v = bump(v, k)
    print(f"{k:9} -> {v}")

Вывод:

fix       -> 1.4.3
feat      -> 1.5.0
breaking  -> 2.0.0

Обратите внимание: feat обнуляет PATCH, а breaking обнуляет и MINOR, и PATCH — это требование semver.

Автогенерация changelog

Раз каждый коммит размечен типом, журнал изменений собирается автоматически — и это, пожалуй, самая ощутимая выгода всей конвенции. Раньше changelog писали вручную, забывали, привирали; теперь он становится точным побочным продуктом нормальной работы. Инструменты вроде standard-version, semantic-release или commitizen читают коммиты с прошлого тега, группируют их по типам и пишут CHANGELOG.md, ставят новый git-тег и (в CI) публикуют релиз. Связка с feat/fix здесь ключевая: разделы «Features» и «Bug Fixes» в журнале — это ровно ваши коммиты соответствующих типов. Многие команды добавляют ещё и хук commit-msg (через commitlint), который проверяет формат сообщения прямо в момент коммита и не даёт записать что-то вне конвенции — так формат соблюдается не на честном слове, а технически.

# standard-version: посчитать версию из коммитов, обновить CHANGELOG, поставить тег
npx standard-version

# semantic-release в CI: полностью автоматический релиз
npx semantic-release

Готовый раздел changelog выглядит так:

## 1.5.0 (2026-06-27)

### Features
* **auth:** добавить вход через Google

### Bug Fixes
* **cart:** не терять скидку при смене валюты

Как это работает под капотом

Инструмент берёт диапазон коммитов последний-тег..HEAD, парсит каждое сообщение регуляркой по шаблону тип(область): описание и определяет максимальный «уровень» изменения среди всех коммитов: есть хоть один breaking → MAJOR, иначе есть feat → MINOR, иначе есть fix → PATCH. Затем считает новый номер, дописывает в CHANGELOG.md сгруппированные записи и создаёт аннотированный тег vX.Y.Z. Никакой магии — просто строгий формат делает сообщения парсимыми.

Частые ошибки

  • Свободный текст в типе. fixed: или Feature: не распознаются — типы пишутся строчными и из фиксированного списка.
  • Забыли пометить breaking change. Сломали API, но закоммитили как feat — инструмент поднимет MINOR вместо MAJOR, и пользователи получат сломанное обновление как «безопасное».
  • Свалка в одном коммите. Коммит «feat + fix + рефакторинг» нельзя корректно отнести к одному типу; делайте атомарные коммиты.
  • Описание с большой буквы или с точкой. Конвенция предписывает повелительное наклонение в нижнем регистре без точки: «добавить», а не «Добавил.».
  • Ручная правка версии. Если вы вручную правите номер в обход инструмента, автоматический расчёт рассинхронизируется с тегами.

Итоги

  • Conventional Commits — формат тип(область): описание, понятный и людям, и скриптам.
  • Маппинг на semver: fix → PATCH, feat → MINOR, breaking change → MAJOR.
  • Breaking помечают ! после типа или футером BREAKING CHANGE:.
  • Размеченные коммиты позволяют автоматически считать версию, ставить тег и генерировать changelog.
  • Атомарные коммиты и точные типы — обязательное условие, иначе автоматика врёт.
Проверьте себя
1. Коммит с типом feat (без пометки breaking) приводит к бампу какой части версии по semver?
AMAJOR
BMINOR
CPATCH
DНе влияет на версию
2. Как в Conventional Commits правильно обозначить несовместимое (ломающее) изменение?
AНаписать тип major:
BПоставить ! после типа или добавить футер BREAKING CHANGE:
CЗакоммитить с типом fix и пометкой [hard]
DПросто увеличить версию вручную
3. За счёт чего инструменты вроде semantic-release могут автоматически собрать CHANGELOG?
AАнализируют изменённые строки кода через AST
BПарсят строго форматированные сообщения коммитов и группируют их по типам
CЧитают комментарии в исходниках
DСпрашивают разработчика при каждом релизе