ADR 0030 — Механизированная политика веток
Контекст
Политика веток Brabo существовала как презентация и как соглашение в
CLAUDE.md: конвейер dev → qa → main, таксономия веток, продвижение только
между соседними ступенями, вычисляемая версия, hotfix, возвращающийся обратным
распространением.
Записанное соглашение — не механизм. Пока правило живёт только в документе, оно зависит от того, чтобы все о нём помнили в неподходящий час, — а неподходящий час это всегда час инцидента, в 3 часа ночи, когда помнить — ровно то, что не происходит. Хуже: правило, которое никто не проверяет, не может быть нарушено заметно. Оно нарушается молча, а симптом появляется месяцы спустя, далеко от причины.
ФАЗА 6 механизировала всю политику в самом репозитории Brabo. Этот ADR закрывает фазу: отображает правило → механизм, фиксирует, что было отрезано, с ценой возвращения каждого среза, и отделяет то, что из этого становится шаблоном для bootstrap Gitflow продукта, — это будущая фаза, а не код сейчас.
Две ограничения сформировали всё. Первое: это репозиторий пользователя, а не
организации. Команд GitHub здесь не существует, а GITHUB_TOKEN не читает
членство в команде даже в организации. Второе: продукт нельзя трогать в этой
фазе. ФАЗА 6 — это CI/CD репозитория; то, чему она учит продукт, становится
этим ADR, а не кодом.
Решение
Правило → механизм
| правило политики | механизм | где живёт логика |
|---|---|---|
имя funcao/descritivo, regex ^.{0,15}/\S{0,32}$ | check pr-police | scripts/ci/pr-police.ts |
| префикс в закрытом списке из 9 функций | pr-police | то же |
работа рождается из dev, hotfix — из main | pr-police, через загрязнение | то же |
| назначение согласовано с функцией | pr-police | то же |
| продвижение только между соседними ступенями | pr-police + promote | pr-police.ts, version.ts |
| метка семейства на каждом PR | pr-police | то же |
| одобрение, требуемое по назначению | check approval-ladder | scripts/ci/approval-ladder.ts |
| два режима одобрения по переменной | approval-ladder | то же |
разные люди в main | сопоставление перебором с возвратом | то же |
| версия по наибольшему воздействию цикла | promote + tag-release | scripts/ci/version.ts |
N увеличивается при отклонении | подсчёт существующих тегов | то же |
финальная привязана к последней -qa.N | tag-release, через дерево + родителя | то же |
| hotfix порождает PATCH без привязки | tag-release, через второго родителя | то же |
| прямой push заблокирован | rulesets | docs/reference/rulesets.md |
| обратное распространение обязательно и упорядочено | check backmerge-gate | scripts/ci/gate.ts |
| merge в постоянную ветку всегда ручной | отсутствие механизма, по решению | — |
Вся логика — тестируемый скрипт; workflow — тонкая оболочка, читающая окружение и вызывающая скрипт. 149 тестов покрывают счастливые пути и случаи отказа.
Семь уроков, которые стоили больше кода
Они здесь, потому что каждый стоил эмпирического открытия, и ни один не очевиден в сторонней документации.
1. Каждое семейство триггеров читает workflow из разного места.
pull_request и push читают из ветки события; pull_request_target и
workflow_dispatch — из ветки по умолчанию. С pull_request_target у
pr-police было ноль запусков — main отставала на 65 коммитов. Это
курица и яйцо: check, который двигает конвейер, должен уже быть в main,
чтобы иметь возможность запуститься.
2. Тег и PR, созданные через GITHUB_TOKEN, не запускают workflow.
Правило против рекурсии. Release v0.2.0 никогда не опубликовалась из-за
этого, а PR обратного распространения без check никогда не стал бы зелёным —
цепочка застряла бы навсегда. Отсюда BRABO_BOT_TOKEN.
3. «Не смог проверить» никогда не может стать «всё в порядке».
promotion-check читал настройку merge через gh api --jq и сравнивал с
'true'. Когда у токена нет разрешения, команда завершается успешно,
возвращая пустоту — а пустота не 'true', так что проверка начинала
проваливать по неправильной причине или, хуже, одобрять. Сегодня есть три
состояния, и невозможность становится предупреждением, тогда как настоящая
гарантия смотрит на свершившийся факт: у коммита два родителя.
4. Равенство дерева сильнее равенства коммита. Привязка сравнивала sha —
невыполнимо, потому что --no-ff создаёт новый коммит. Сравнение дерева и
требование, чтобы -qa.N была родителем, сильнее: если бы другая сторона
merge принесла файл, дерево изменилось бы.
5. Загрязнение, а не происхождение. Выяснить, откуда родилась ветка,
неразрешимо задним числом: при P ⊆ Q ⊆ head argmin расстояния выбирает
самую продвинутую содержащуюся постоянную ветку, а не исходную. Правильный
вопрос другой — «несёт ли эта ветка вершину постоянной ветки, более
продвинутой, чем та, которую она объявляет?», — и на него есть ответ.
6. Освобождение по автору, никогда по префиксу. Освобождать ветки,
начинающиеся с dependabot/, было бы открытой брешью: любой может так назвать
ветку.
7. Объявленное состояние должно быть проверяемым. .release/gate.json
едет в PR обратного распространения в qa и dev; продвижение может поднять
эту старую копию обратно и воскресить замок без стоящего за ним hotfix —
заперев репозиторий навсегда, потому что нет ожидающего обратного
распространения, которое бы его разрешило. Check спрашивает git, спустился ли
hotfix, и сбрасывает выполненные замки. locked — запись намерения;
содержимость — истина.
Что было отрезано и цена возвращения
| отрезано | почему | что стоит вернуть |
|---|---|---|
ступень rc / UAT | без окружения и без людей для её исполнения это была бы церемониальная ступень | создать ветку, строку в ESCADA, строку в ESTAGIO_POR_BRANCH, строку в лестнице одобрения, и порядок gate получает ступень — цепочка hotfix становится четырьмя PR |
таксономия с rcfix | умерла вместе со ступенью rc | запись в FUNCOES_DE_CORRECAO_ALTA плюс тесты происхождения и назначения |
| команды GitHub для ролей | у репозитория пользователя нет команд, а GITHUB_TOKEN не читает членство даже в org | роли — списки handles в переменных; переход на команды потребовал бы org и токен с read:org |
| deploy | шаг, который никогда не запускается, гниёт: его никто не тестирует, и в день включения он будет неправильным | собственный workflow, запускаемый тегом, плюс Environments — здесь нечего переподключать |
| GitHub Environments | то же | создать, когда появится окружение |
GOVERNANCE.md | отрезан в ФАЗЕ DOC; критерий живёт в branching-policy.md | написать файл и перенести туда раздел о ролях |
Лестница одобрения в режиме community не отрезана: она реализована,
протестирована и выключена конфигурацией. Разница намеренная — код,
выключенный переменной, с тестом обеих сторон, демонстрируем сегодня; шаг
конвейера, выключенный переменной, — нет.
Что становится шаблоном bootstrap продукта
Продукт подготавливает репозитории с Gitflow (ФАЗА 2). Что ФАЗА 6 выучила и что должно войти в этот шаблон, в будущей фазе:
| входит в шаблон | не входит |
|---|---|
| конвейер и таксономия как данные, а не код — у продукта есть проекты с разными лестницами | workflows Brabo, скопированные буквально |
| gate обратного распространения: это правило, которое дороже всего стоит, когда его нет | BRABO_BOT_TOKEN — каждому репозиторию нужны свои учётные данные |
| версия, вычисляемая из тега, без PR bump | списки одобряющих этого репозитория |
| «не смог проверить» как явное третье состояние в gates | — |
| освобождение бота по автору | — |
GitProvider уже выставляет capabilities, а ADR 0028 фиксирует, что защита
ветки расходится между providers. Шаблону придётся деградировать: где нет
ruleset, правило становится check, а сообщение говорит, что не удалось
гарантировать, — вместо того чтобы изображать гарантию.
Последствия
Политика перестала зависеть от памяти. Неправильное имя ветки, продвижение через ступень, hotfix без обратного распространения и непривязанный финальный тег теперь — провалы check с обучающим сообщением, а не поздние открытия.
Конвейер был пройден от начала до конца, а не только протестирован
модульно. v0.1.0 → v0.2.0-dev.1..4 → -qa.1..3 → v0.2.0, с разыгранным
отклонением между штампами qa, чтобы доказать, что N считает сам.
Финальная привязалась к -qa.3 с идентичным деревом.
Обязательный check, который никогда не запускается, запирает PR навсегда.
Это самое неудобное последствие: список обязательных checks теперь — связь
между ruleset (применяемым вручную) и workflows (версионируемыми). Новый job в
CI либо входит в список rulesets.md, либо остаётся вне намеренно, и кто-то
пишет почему. Именно поэтому у job backmerge-gate нет if:: старый вердикт,
приклеенный к sha, который не запускался, освободил бы merge во время замка.
Обход gate принадлежит актору, а не пути. Rulesets GitHub не ограничивают
обход по пути. Кто может писать .release/gate.json, технически может писать
что угодно в main. Реально ограничивают workflow и история. Зафиксировано как
ограничение инструмента.
Применение ruleset остаётся ручным. Репозиторий версионирует источник
(docs/reference/rulesets.md); применение — действие owner. Автоматизация
потребовала бы токена, способного менять собственные защиты, — что свело бы на
нет их смысл.
Сосредоточение ролей у owner записано, а не подразумевается. Ответственный
за релиз и дежурный по hotfix — owner, пока APPROVAL_MODE=solo. Обе
обязанности вновь открываются при переходе на community, где запасной
вариант дежурного должен будет быть задокументированным исключением, никогда
обходом.
Ничего в продукте не изменилось. Ни одна строка apps/ не тронута. То,
чему фаза научила продукт, — в таблице шаблона выше и стало планом, а не
кодом.
Ссылки: ADR 0028 (расхождение защиты между providers), 0029 (механизм документации, используемый этой фазой) и полная политика в branching-policy.