ADR 0139 — Сигнал тревоги о merge конвейера получает адресата и правило для dev
Контекст
Политика с самого начала говорит, что продвижение — это --no-ff
(branching-policy.md): "a squash would
flatten the lower step's commits, and the stage's tag would end up pointing to a
commit that no longer exists". Правило было нарушено три раза — #367
(2026-08-24), #394 (2026-08-25) и #464 (2026-09-04), — и третий раз обошёлся дороже всех,
потому что это был как раз PR, исправлявший первые два.
То, что обнаружилось при расследовании, было не дырой в обнаружении. Было обратное:
Проверка существовала и сработала. tag-release отклоняет merge в qa без
второго родителя с 2026-07-27, и журнал прогона от 2026-08-25 говорит
прямым текстом:
[tag-release] o merge em `qa` não é merge commit (1 pai)
##[error]o merge em `qa` não é merge commit (1 pai)
Она прозвенела на обоих squash-продвижениях. А история осталась сломанной точно так же, на 293 коммита, пока следующее продвижение не открылось с 11 файлами в "конфликте", который не был конфликтом, — лишь потерянное происхождение.
Не хватило того, КОМУ звенеть. Workflow на push, падающий на
постоянной ветке, не имеет PR, где стать красным: merge уже произошёл. Остаётся прогон на
вкладке Actions, которую никто не открывает, потому что ничто не сообщает о его существовании. В репозитории
было ноль issues за всю историю — канала не существовало. Обнаружение без
адреса — это тревога, звенящая в пустой комнате.
Почему просто не отключить squash
Это был первый рассмотренный выход, и он единственный с абсолютной гарантией:
allow_squash_merge: false убирает кнопку. Он был измерен и
отвергнут, с числом на руках: шесть последних merge в dev — это
squash с одним родителем — squash и есть соглашение для рабочих PR здесь, — а
scripts/changelog.mjs генерирует CHANGELOG через git log --no-merges, рассчитывая на
это: сегодня squash — одна строка; с merge commit каждый промежуточный WIP-коммит
стал бы записью. Отключение squash чинит продвижение, облагая налогом каждый рабочий
PR и вмешиваясь в генерацию changelog. Проблема — в конвейере; счёт
оплачивала бы работа.
Зафиксировано и то, что тоже не годится, чтобы никто не искал снова: merge
queue решил бы это фиксированным методом на очередь, а CLAUDE.md её
явно запрещает; а нативный контроль GitHub по ветке в этом пространстве —
required_linear_history, который делает обратное — запрещает merge commit. Не
существует "требовать merge commit" для ветки.
Решение
1. Тревога получает job с адресатом. tag-release.yml получает avisar,
if: failure(), который открывает issue, называющую ветку, коммит, прогон и исправление, —
и комментирует уже открытую issue вместо открытия второй для той же
ветки, потому что поток одинаковых issues — быстрейший способ научить
кого-то их игнорировать. Заголовок — ключ дедупликации, зафиксированный тестом
(tituloDoAlarme).
Он аутентифицируется через github.token, никогда через PAT: невалидный
BRABO_BOT_TOKEN — один из сбоев, о которых он должен сообщать (checkout умирает с
could not read Username), а тревога, зависящая от того, что может быть сломано, — это тревога,
которая молчит ровно тогда, когда должна звенеть. Тело идёт через
--body-file, никогда не интерполируется в run: — в тексте есть обратные кавычки и $, а
интерполяция содержимого внутри bash -e — это инъекция.
2. Правило начинает покрывать dev, и покрытие УЗКОЕ. dev получает
работу постоянно и намеренно через squash, поэтому "один родитель — дефект" там не
действует — это отклоняло бы каждый PR. Дефектом в dev является PR, который приносил
новое ребро: его head был merge, чей второй родитель ещё не был в
базе. Это был #464, вся поставка которого и была этим ребром.
Различие важно, потому что существует почти идентичный и безобидный сосед:
рабочий PR, втянувший dev в себя перед merge, тоже имеет merge в
head — но второй родитель уже в базе, и squash ничего не теряет.
3. Запрос, идентифицирующий PR, — по merge_commit_sha, и это не
стиль. gh api repos/{repo}/commits/{sha}/pulls возвращает каждый PR,
связанный с коммитом, — а поскольку dev является head PR продвижения, остающегося открытым
весь цикл, каждый коммит dev связан с ним. Чтение .[0].head.ref
вернуло бы dev (постоянную ветку) для любого squash рабочего PR, и правило
отклонило бы весь репозиторий. Ошибка была совершена и поймана
проверкой на реальных данных до отправки; записано, чтобы не вернулась.
4. null — это ответ, и в dev он не становится дефектом. Невозможность узнать,
было ли ребро (API недоступен, PR удалён, объект отсутствует в клоне), отличается
от знания, что его не было. В dev это проходит, потому что базовая частота —
легитимная работа. В qa/main ничего не меняется: туда входит только конвейер, и один родитель —
дефект с известным источником или без него. Это намеренно обратное
доктрине promotion-check ("verificação impossível conta como reprovada") —
там вселенная — только продвижение, здесь — нет.
Последствия
Это не предотвращает, и в этом различии суть. Метод выбирается в клике; настоящее предотвращение потребовало бы одного из двух отвергнутых выше выходов или изъятия merge из рук человека — что упирается в правило CLAUDE.md, что merge в защищённую ветку всегда ручной. Меняется время обнаружения: с "в следующем цикле продвижения" на "минутой позже, в issue с владельцем", когда откат ещё тривиален.
Предотвращение остаётся открытым, и это продуктовое решение. Выход, который решает
без налога на работу, — promote.yml, который уже существует, уже является workflow_dispatch
и уже имеет пару конвейера как input, — начинает сам выполнять git merge --no-ff.
Это сохраняет полномочия (ничто не мержится без запуска человеком)
и механизирует лишь метод, но требует решить, считается ли человеческий dispatch "ручным
merge" в смысле CLAUDE.md, и открыло бы третье исключение из "никогда прямой push
в постоянную ветку". Здесь не принято.
Проверено на реальных данных, а не рассуждением. Правило было запущено против
трёх коммитов, которые его мотивировали: отклоняет bf6db00c9 (squash #464) и одобряет
1eb71c322 и 108d81cc7 (обычные рабочие PR) — без ложных срабатываний.
Находка по пути, вне досягаемости этого ADR: BRABO_BOT_TOKEN
невалиден или просрочен с вечера 2026-09-03, и поэтому ни один тег не
ставится ни на одной ветке — checkout tag-release падает до
любой логики. Решает только ротация секрета, и это TODO(humano).
Зафиксировано в CLAUDE.md.