Канон схемы нумерации версий проекта. Вынесено из CONTRIBUTING.md, который остался коротким онбордингом. Как менялись релизы по существу — в
../../HISTORY.md; полный список изменений — в../../CHANGELOG.md; решение о динамической версии — ADR-0005.
Это НЕ SemVer. Проект использует собственную схему. Не применяйте привычные правила SemVer — они сломают инвариант «каждый тег =
vX.Y.0».
MAJOR . MINOR . PATCH
│ │ │
│ │ └─ +1 на принятое изменение (смерженный PR — по его номеру);
│ │ обнуляется при инкременте MINOR
│ │
│ └─ +1 ВСЕГДА при постановке git-тега + GitHub Release
│
└─ меняется только при фундаментальных изменениях:
выход за пределы локального инструмента,
поддержка других языков программирования и т.п.
Следствия схемы:
- Теги ставятся только на границе MINOR → все теги имеют вид
vX.Y.0. PATCH-тегов не существует, поэтому коллизий версий нет. - PATCH — счётчик принятых изменений с последнего тега, а не номер
«патч-релиза». В «логическом» счётчике (
scripts/version.py, README-бейдж) изменение опознаётся по номеру PR —(#NNNN)в теме коммита либоMerge pull request #NNNN; номера складываются во множество, поэтому один PR даёт +1 независимо от того, на сколько коммитов он разбит и сколько раз попал в историю. Коммит без номера (прямой пуш в main) считается отдельно, но только с first-parent линии — иначе внутренние коммиты слитой ветки завышали бы счёт. CI-бот (chore(ci): update badges) и склейкиgit pull(Merge branch 'main' of …) не считаются. Например,1.2.17— это «17 принятых изменений после тегаv1.2.0», а НЕ «17-й патч-релиз». Отдельного релиза1.2.17не существует — на PyPI/Releases уходят только тегированныеX.Y.0. (Метаданнаяsetuptools-scmX.Y.0.postN— независимая «сырая дистанция»: считает все коммиты, включая CI-бота и merge; см. § Release vs dev.) - Почему по номерам, а не по графу истории. Прежняя формула
считала коммиты на first-parent линии, то есть меряла форму истории — а
форма зависит от окна. В свежем клоне она линейная (squash-мержи), но
git pullmerge'ом уводит всё пришедшее с GitHub во второй родитель, и на first-parent линию оно не попадает: два принятых PR + один локальный коммит давали 2 вместо 3, а сама склейка добавляла +1 за ничто. Перейти на--no-mergesбыло нельзя — тогда мерж ветки из трёх коммитов давал 6 вместо 4. Ни одна топологическая формула не даёт обе цифры, поэтому считаются сущности (номера PR), а не рёбра графа. Оба сценария закреплены тестами на настоящем git-репозитории (tests/test_version_script.py): мок обхода графа подменял бы ровно то место, где жил дефект. - Клон без тегов версию не показывает.
git describeтам падает, и MAJOR.MINOR неоткуда взять —scripts/version.pyпечатает предупреждение в stderr («подтяните теги:git fetch --tags») и отдаёт неполную версию. Так клонирует облачная сессия иactions/checkoutбезfetch-depth: 0; в CI-джобах, которым нужна версия,fetch-depth: 0обязателен. CHANGELOG.md: блок[Unreleased]копится и при теге переносится в[X.Y.0]. Промежуточные PATCH-версии в CHANGELOG не документируются построчно (иначе CHANGELOG превратится вgit log).- Краткость и ротация CHANGELOG. Запись = одна строка на
изменение (
- <что> (#PR)), детали — в PR/issue; многострочные пересказы — антипаттерн (раздули[Unreleased]перед v1.8.0). В живомCHANGELOG.mdдержим только[Unreleased]+ три последних MINOR; более старые релизы ротируются в../archive/changelog-archive.md, аscripts/check_docs_guardrails.pyстережёт лимит в 3 версионных заголовка. - Секция
[X.Y.0]— это и есть тело GitHub-релиза.scripts/extract_release_notes.pyвырезает её по имени тега и отдаёт вbody_path(workflowrelease.yml); автосписок заголовков PR остаётся ниже приложением «Full changelog». Отсюда практическое следствие: забытая запись под тег роняет релиз — скрипт завершается с ошибкой, а не отдаёт пустое тело. Проверка стоит в job'еverify, от которого зависят оба публикующих job'а: пока она жила только в публикации GitHub Release, PyPI выходил при любом состоянии CHANGELOG — эти два job'а независимы друг от друга по построению. Пустой релиз читался бы как «ничего не изменилось», что хуже автосписка, который он заменяет.
Git-теги — единственный источник истины. pyproject.toml больше НЕ
объявляет version статически: она вычисляется из git-тегов через
setuptools-scm (dynamic = ["version"], version_scheme = "post-release").
Вручную править [project].version нельзя — статической строки там нет,
а CI-проверка её возврат заблокирует (см. ниже).
Из-за этого одновременно живут две формы одного и того же номера:
| Форма | Когда | Пример | Откуда берётся | Для чего |
|---|---|---|---|---|
| Release (метаданные пакета на теге) | HEAD стоит ровно на теге vX.Y.0 |
1.5.0 |
setuptools-scm |
То, что уходит на PyPI / GitHub Release; чистый PEP 440 без local-сегмента |
| Dev (метаданные пакета вне тега) | N коммитов после последнего тега (все коммиты, включая CI-бота и merge) | 1.5.0.post3+g1a2b3c4 |
setuptools-scm (post-release) |
Технически отличимая сборка «после релиза 1.5.0» — видно, что это не официальный релиз |
| Логический счётчик (человекочитаемый) | всегда | 1.5.3 |
scripts/version.py |
Удобный «тег + число смерженных PR» (first-parent, без CI-бота) для тегирования и заметок; это не PEP 440 и не метаданные пакета |
Почему две формы сосуществуют. setuptools-scm обязан выдавать валидный
PEP 440 (X.Y.0.postN+g<hash>) — это то, что понимают pip/PyPI и что
попадает в метаданные установленного пакета. Логическая схема проекта
(X.Y.N, где N = число принятых изменений после тега — first-parent коммитов
без CI-бота, ≈ смерженных PR) удобнее человеку, но PEP 440 не является —
поэтому она остаётся отдельным справочным счётчиком в scripts/version.py, а
не источником для сборки. (Из-за разной логики подсчёта логический N обычно
меньше postN-дистанции setuptools-scm, которая считает все коммиты.)
Формы две, а бейджа в шапке тоже два — и они не дублируют друг друга:
| Бейдж | Значение | Источник | Когда меняется |
|---|---|---|---|
release/pypi |
1.10 |
scripts/generate_release_badge.py |
на релизе |
version |
1.10.109 |
scripts/generate_version_badge.py |
на каждое принятое изменение |
.0 в подписи релиза не показывается намеренно: PATCH в теге всегда ноль, это
константа, а не номер патча.
release/pypi — не витрина, а проверка: скрипт сам сверяет последний
релизный тег с версией, реально опубликованной на PyPI. Сошлись — зелёный
1.10; публикация отстала — красный 1.11 ≠ pypi 1.10; до PyPI не достучались
— серый 1.10 · pypi ? (проверка не выполнена, и бейдж говорит об этом, вместо
того чтобы зеленеть на нехватке данных).
Раньше на этом месте стояли два отдельных бейджа — Release (git-тег) и PyPI
(опубликованный пакет). Пока публикация работает, они показывают одно и то же и
выглядят лишними; их смысл появляется ровно в момент расхождения, о котором ни
один из них не сообщал — сравнивать надо было глазами. Один проверяющий бейдж
занимает меньше места и не молчит.
Релизным считается только тег вида vX.Y.Z: рядом живут служебные
(v-checkpoint-...), и git describe --tags без маски выбирает их наравне с
релизными.
Когда какая используется:
stepik_grader.__version__иstepik-grader --versionчитают метаданные пакета (importlib.metadata) — то есть release- или dev-форму, вычисленнуюsetuptools-scmприpip install/сборке. На теге увидишь1.5.0, вне тега —1.5.0.postN+g<hash>.scripts/version.py— печатает логическийX.Y.N; нужен человеку при подготовке тега/заметок, не при сборке.
UX вывода
--version. На теге--versionпечатает чистые метаданные пакета (1.5.0) без изменений. Вне тега к тем же сырым метаданным добавляется явная пометка:1.5.0.post3+g1a2b3c4 (dev build, not a release)— чтобы пользователь не принял PEP 440 postN/local-сегмент за официальный релиз. Логика —cli._format_version_for_display()/cli._is_dev_build()(наличие+в версии); способ вычисления самой версии (setuptools-scm) не менялся.
Когда поднимать/тегировать релиз:
- MINOR (
vX.(Y+1).0) — качественный скачок (см. таблицу выше). Ставится git-тегvX.Y.0+ GitHub Release;setuptools-scmподхватит его сам. - PATCH не тегируется — это просто «дистанция коммитов» от последнего тега, она растёт автоматически.
- MAJOR (
v2.0.0) — выход за рамки «локальный инструмент для Python-задач Stepik» (другие языки/платформы). - Ручное редактирование версии в
pyproject.tomlне требуется и запрещено — всё делает тег. - ⛔ Блокирующий шаг ПЕРЕД тегом
vX.Y.0— ротация CHANGELOG. До постановки тега перенеси самый старый MINOR изCHANGELOG.mdв../archive/changelog-archive.mdдословно, чтобы в живомCHANGELOG.mdосталось ровно[Unreleased]+ три последних MINOR (см. «Краткость и ротация CHANGELOG» выше), и переименуй[Unreleased]→[X.Y.0] — ДАТА, добавив сверху новый пустой[Unreleased]. Это не опциональная уборка, а гейт:check_docs_guardrails.pyдержит версионный бюджетCHANGELOG.md= 3, и без ротации CI на релизе падает. Гейт стоит в job'еverifyworkflowrelease.yml— до того, как что-либо опубликовано. Раньше эта фраза была обещанием, а не фактом:release.ymlguard документации не запускал вовсе, и релиз с четырьмя версиями в живом CHANGELOG проходил насквозь.
Гейт перевода стоит там же, до публикации. Запись, оставшаяся английской,
уезжает в GitHub Release и на PyPI необратимо, поэтому
scripts/check_changelog_translated.py --strict проверяет извлечённые release
notes в том же job verify: без единой кириллической буквы вне кода запись
считается непереведённой и роняет прогон. На PR та же проверка только
предупреждает — там запись ещё может быть черновиком.
CI-защита от дрейфа. scripts/check_version_consistency.py
следит, чтобы (1) статический version не вернулся в pyproject.toml и
(2) верхняя релизная запись CHANGELOG.md (и, мягко, таблица метрик
CLAUDE.md) не расходилась с последним git-тегом. Baseline берётся из
git describe --tags. Поэтому отдельной ручной сверки версий по репозиторию
делать не нужно — за это отвечает CI.
Полную таблицу эволюции релизов (от
v1.0.0до текущего) и отличия от оригинала см. в../../HISTORY.md— он ссылается на эту политику, а не копирует её.