Rulesets репозитория
Эта страница — версионируемый источник защиты. Применение её в GitHub — ручной шаг: репозиторий не может защитить сам себя, и эта асимметрия намеренная — ослабляющий защиту должен быть человеком, с записью.
Политика, которую применяют эти правила, описана в Política de branches.
Текущее состояние:
gh api repos/daneiel/brabo/rulesetsвозвращает 0. Ничего ещё не применено. До тех пор всё здесь — объявленное намерение, а не реальная защита.
Предусловие: все три ветки должны существовать
У лестницы три ступени, и ruleset не нацеливается на то, чего нет.
git fetch origin
git push origin origin/dev:refs/heads/qa
dev и main уже существуют. qa рождается от dev — лестница начинается
пустой и заполняется продвижениями.
Ветка
rcбыла удалена из лестницы. Если она ещё существует в remote старого клона, удалите её:git push origin --delete rc. Постоянная ветка, которой нет в лестнице, — приглашение для PR нацелиться на ступень, которой больше нет.
Ruleset 1 — три постоянные ветки
Имя: permanentes
Enforcement: Active
Цель: Branch → Include by pattern, три записи: dev, qa, main
Один ruleset на три, а не три ruleset: требование во всех идентично. Между ступенями различается кто одобряет, и это решает
approval-ladderпо назначению, а не защита.
Правила, которые нужно отметить
| правило | значение | почему |
|---|---|---|
| Restrict deletions | ✅ | случайно удалить main через интерфейс необратимо |
| Block force pushes | ✅ | force-push в постоянную ветку переписывает уже продвинутое; тег ступени в итоге указывал бы на несуществующий коммит |
| Require a pull request before merging | ✅ | это центральное правило: никакое изменение не входит без PR |
| ↳ Required approvals | 0 | считает approval-ladder, который знает режим и назначение. Нативное число GitHub не отличает dev от main |
| ↳ Dismiss stale approvals on push | ✅ | одобрение старого коммита не считается — одобренное не то, что будет смёржено |
| ↳ Require review from Code Owners | ✅ | CODEOWNERS ставит владельца ревьюером всего |
| Require status checks to pass | ✅ | см. список ниже |
| ↳ Require branches to be up to date | ❌ | заставило бы делать rebase при каждом merge в dev; CI уже запускается на merge-коммите |
| Block merge queue | — | не используется |
Required approvals = 0 — это не ослабление. GitHub умеет только считать; он
не знает, что main требует PO + управление, а dev — одного разработчика, и
что в режиме solo PR самого владельца проходит без ревью. Поставить здесь 1
сломало бы режим solo: владелец не может одобрить собственный PR через
интерфейс, и PR застрял бы навсегда. Настоящее требование живёт в
approval-ladder, который является обязательной проверкой, — а обязательную
проверку не обойти.
Две конфигурации рядом
Ruleset один и тот же в обоих режимах. Меняется переменная — и поэтому миграция не проходит через Settings → Rules.
solo (сегодня) | community (в будущем) | |
|---|---|---|
| Required approvals в ruleset | 0 | 0 |
| кто требует | проверка Escada de aprovação | та же проверка |
| переменные | APPROVAL_MODE=solo, OWNER_HANDLE | APPROVAL_MODE=community, APROVADORES_* |
dev | 1 от владельца | 1 × devs |
qa | 1 от владельца | 2 × devs |
main | 1 от владельца | 1 × PO + 1 × управление |
| PR самого владельца | проходит без ревью | идёт по лестнице, как у любого |
| разные люди | приостановлено | действует в main |
Required approvals остаётся 0 в обоих случаях, и это обдуманно. GitHub
умеет только считать: не отличает dev от main, не знает ролей и не знает,
что в режиме solo PR самого владельца проходит без ревью. Поставить там 1
сломало бы режим solo — владелец не может одобрить собственный PR через
интерфейс, и каждый его PR застрял бы навсегда. Настоящее требование живёт в
проверке, которая обязательна и не обходится.
Скопируйте и вставьте, чтобы включить каждый режим:
# solo — what's in effect today
gh variable set APPROVAL_MODE --body solo
gh variable set OWNER_HANDLE --body daneiel
# community — fill in the lists BEFORE flipping the switch
gh variable set APROVADORES_DEVS --body "ana,bruno,carla"
gh variable set APROVADORES_PO --body "paula"
gh variable set APROVADORES_GESTAO --body "gustavo"
gh variable set APPROVAL_MODE --body community
Обязательные проверки
Имя точное, как его регистрирует GitHub (это name: job, а не workflow):
Колонка длительности измерена, а не оценена — три реальных запуска,
холодный и тёплый кэш. Она существует, чтобы «оптимизировать CI» начиналось с
числа, а не с догадки. * отмечает строку, измеренную по одному реальному
запуску вместо трёх, — job новый (или только что переименован) и ещё не был
перемерен холодный × тёплый; одно и то же число заполняет обе колонки, пока не
будет.
| проверка | workflow | холодный | тёплый |
|---|---|---|---|
Build, scan e smoke das imagens de produção | ci.yml | 295s | 109s |
Testes TS (api) | ci.yml | 177s* | 177s* |
Testes TS (web) | ci.yml | 169s* | 169s* |
Testes TS (pacotes) | ci.yml | 56s* | 56s* |
Testes do engine (ExUnit) | ci.yml | 124s | 39s |
Auditoria de dependências | ci.yml | 99s | 85s |
Lint | ci.yml | 66s | 69s |
Drift, gerados e build | docs-check.yml | 53s | 51s |
Manifests de Kubernetes | ci.yml | 14s | 13s |
Gitleaks no repositório | ci.yml | 5s | 7s |
Política de branches | pr-police.yml | 7s | 7s |
Escada de aprovação | approval-ladder.yml | 13s | 15s |
Check de promoção | promotion-check.yml | 9s | 9s |
Backmerge gate | backmerge-gate.yml | 7s | 7s |
После AT-303..306 (только бесплатные изменения, без платного runner). Имена в таблице выше не изменились — переименованная обязательная проверка блокирует каждый PR, — а числа выше — это ДО. Что изменилось под капотом:
Build, scan e smoke das imagens de produçãoпропускает тяжёлые шаги, когда diff не затрагивает образ (AT-303). Первый шаг классифицирует diff PR с помощьюscripts/ci/diff-toca-imagem.ts(список разрешённого — того, что никогда не попадает в образ, в smoke или в E2E:docs/кромеdocs/gates.yml,website/,scripts/docs/,deploy/k8s/,.github/кромеci.yml,*.mdв корне кромеTHIRD_PARTY_NOTICES.md,design/*.md; spec доказывает список против настоящих Dockerfile, мутацией). Bake, Trivy, broker, smoke и E2E несутif:на шаге, и job всегда завершается — зелёным, говоря почему в сводке job. Никогдаpaths:в триггере и никогдаif:на job: оба оставили бы обязательную проверку ожидающей или приклеенной к старому вердикту. В сомнении (пустой diff, HEAD, который не merge-коммит PR, падающийgit diff) запускается всё.- Тот же job кэширует браузер Playwright, store pnpm из
e2e/и базу Trivy (AT-304) — см. что кэширует job образов. Testes TS (api)иTestes TS (web)стали АГРЕГАТОРАМИ двух шардов каждый (AT-305). JobsTestes TS (api) — shard N/2иTestes TS (web) — shard N/2запускают каждый половину файлов, с покрытием, но с обнулённой нижней границей, и пишут отчётblobvitest; агрегатор — который сохраняет обязательное имя — проверяет, что оба шарда завершилисьsuccess, что оба отчёта пришли, и запускаетvitest --merge-reports --coverage, который применяет НЕИЗМЕНЁННЫЕcoverage.thresholdsизvitest.config.ts/vite.config.tsк объединённому покрытию (измерено локально: объединённое покрытие идентично покрытию единого запуска). У агрегатора намеренноif: always(): без него красный шард оставил бы егоskipped, аskippedсчитается зелёным для обязательной проверки. Имена шардов не являются обязательными проверками и не должны перечисляться в ruleset. Typecheck выполняется только в шарде 1.Testes do engine (ExUnit)и шарды api ждут новый job,imagens / Ler as imagens do compose(ADR 0197): он читает ссылку на pgvector изdocker/docker-compose.ymlи передаёт её в ихservices:, так что ни один workflow не хранит литерал образа. Он не является обязательной проверкой и не должен перечисляться в ruleset — то, что он решит, проявляется в зависящих от него jobs. Эти jobs несутif: ${{ !cancelled() }}по той же причине, что и агрегаторы выше: если быimagensупал, по умолчанию был бы ПРОПУЩЕНTestes do engine (ExUnit), аskippedсчитается зелёным для обязательной проверки; с ним job запускается, получает пустой образ и падает.claude-code-review.ymlполучилconcurrency(cancel-in-progress, на PR) иtimeout-minutes: 30;docs-check.ymlполучилtimeout-minutesв обоих jobs (AT-306).
| проверка | до, холодный | до, тёплый | после, холодный | после, тёплый | после, PR только docs |
|---|---|---|---|---|---|
Build, scan e smoke das imagens de produção | 295s | 109s | TODO(humano) | TODO(humano) | TODO(humano) |
Testes TS (api) (шарды + агрегатор, время по часам) | 177s | 177s | TODO(humano) | TODO(humano) | — |
Testes TS (web) (шарды + агрегатор, время по часам) | 169s | 169s | TODO(humano) | TODO(humano) | — |
TODO(humano): заполнить колонки «после» запусками этого самого PR (холодный: первый push; тёплый: перезапуск того же коммита; только docs: PR, затрагивающий только
docs/), а затем перенести числа в основную таблицу.
Следствие для критерия edited ниже: job образов теперь зависит и от БАЗЫ
(diff берётся относительно неё), а ci.yml по-прежнему не перезапускается на
edited. Единственное небезопасное направление — PR, пропустивший образы
относительно одной базы и затем перенаправленный на базу, где его diff затронул
бы образ, — редкость, поскольку продвижения запускаются от верхушек постоянных
веток (чей diff всегда что-то затрагивает), а перенаправления обычно сужают
diff. Следующий push переклассифицирует.
ci.yml параллелен, кроме двух агрегаторов — у test-api и test-web
есть needs на свои шарды, больше ни у чего нет needs:.
Полный вердикт PR стоит САМОЙ МЕДЛЕННОЙ цепочки, а не суммы (которая ~12 мин
CPU). До AT-303..305 у того, кто хотел укоротить PR, было две цели, и только
две:
-
холодный кэш: job образов, где 195s из 295s — это
docker buildx bake, самый большой отдельный пункт во всей CI, в 3× больше второго. Bakefile уже собирает четыре образа параллельно с кэшемtype=ghaна образ, и комментарий в его начале измеряет, почему разбивать его на матрицу jobs было бы ХУЖЕ: 1,7 GB образов на артефакт стоят больше сборки, а smoke нужны все четыре в одном daemon; -
старый job
Testes TS (api + web), где 91s из 159s былиpnpm --filter api test, сериализованным черезfileParallelism: falseвapps/api/vitest.config.ts, потому что ~80 spec, затрагивающих базу, делили ОДНУbrabo_testи выполняли TRUNCATE между тестами — параллельные заходы столкнулись бы. Это было исправлено (perf/banco-por-worker-nos-testes-da-api, CHANGELOG): каждый worker Vitest получает СОБСТВЕННУЮ базу —test/support/global-setup.tsодин раз мигрирует TEMPLATE и клонирует по базе на worker черезCREATE DATABASE ... TEMPLATE(дёшево, копия страниц, а не повторный прогон migrations),test/support/test-db-name.tsопределяет, к какой из них подключается spec, поVITEST_POOL_ID, аvitest.config.tsвключаетfileParallelism: trueсmaxWorkers: 4(фиксировано по 4 vCPU runner, не автоопределяется — число должно быть ОДИНАКОВЫМ на машине разработчика с большим числом ядер и на runner, иначе измеренное локально поведение — не то, что выполняется там). Измерено локально: длительность самого набора api упала с 792,58s до 471,23s на чистом запуске (~40%, три повтора без нестабильности). Перемерено на настоящем hosted runner GitHub (PR #491,ci.yml): сам шагpnpm --filter api testупал до 96s — прямо на нижней границе91sтёплого кэша, которую эта таблица называла теоретически лучшим случаем, а не случайный выброс.Это исправление изменило форму job, где оно выполнялось, и именно это сделало возможным ВТОРОЕ сокращение. Шаг тестов самого
apps/webвырос до 125s в том же запуске, который измерил общие343sjob (PR #494, job101317149006), — уже не тот маленький хвост, который описывал акцент ниже, а ровня api.perf/paraleliza-testes-ts(CHANGELOG) разделил job на три параллельных —test-api(сохраняет сервисpostgresи окружениеDATABASE_URL/TEST_DATABASE_URL, поскольку только он трогает базу),test-webиtest-packages(скрипты CI, порт Docker, broker, runner — шесть компонентов, которым Postgres никогда не был нужен), — каждый со своимиcheckout+setup-node+pnpm install. Измерено на настоящем hosted runner GitHub (PR #495,ci.yml): 177s (test-api), 169s (test-web), 56s (test-packages) против 343s заменённого ими job — проверка, державшая PR, теперь САМАЯ МЕДЛЕННАЯ из трёх, а не их сумма, реальное сокращение на ~48%.test-webиtest-packagesбыли стабильны во всех запусках этого PR (164–169s и 55–56s).test-api— нет: второй push измерил его в 388s — хуже общих 343s старого job — при всех шагах по-прежнему зелёных, а затем простой перезапуск ТОГО ЖЕ коммита измерил 172s. Три реальных замераtest-apiв этом PR: 177s, 388s, 172s. Два из трёх ложатся туда же, кудаtest-webиtest-packages(с запасом в пределах цели ~4 мин); запуск в 388s согласуется с конкуренцией за общий runner на стороне GitHub, а не с регрессией разделения — в job ничего не менялось между тем запуском и перезапуском, измерившим 172s. Записано как наблюдаемое, не сглажено: тот, кто будет разбирать следующий медленный запускtest-api, должен знать, что этот разброс уже существовал в день разделения job, и проверить конкуренцию, прежде чем предполагать новую регрессию.
Предупреждение о «двух издержках», жившее здесь, больше не действует — обе посылки изменились. Оно говорило, что разделение
Testes TS (api + web)(1) удалит обязательную проверку при переименовании, навсегда блокируя каждый PR, если только job fan-in или изменение в Settings не сохранит имя, и (2) не окупится, потому что 91s api несли бы setup, на сколько бы jobs его ни делили, ради чистого выигрыша ~7s.Посылка 2 перестала быть верной, когда набор web вырос до стоимости набора api — разделение трёх компонентов сравнимого размера, а не одного большого плюс маленьких хвостов, и превращает «≈150s против 159s» в «177s против 343s». Посылка 1 перестала действовать по ортогональной причине: к этому репозиторию ещё не применён ни один ruleset —
gh api repos/daneiel/brabo/branches/dev/protectionпо-прежнему возвращает404, как в заметке «Текущее состояние» вверху этой страницы, — так что удалять было нечего. Таблица обязательных проверок выше уже отражает новое имя. Если ruleset когда-нибудь будет применён, перечислитеTestes TS (api),Testes TS (web)иTestes TS (pacotes)— никогда не выведенное из употребления имяTestes TS (api + web), которое молча ни с чем не совпало бы и блокировало бы каждый будущий PR, ровно как описывает общее предупреждение ниже.
Имя проверки меньше того, что она охраняет.
Drift, gerados e build— три вещи в заголовке и пять gates в job: целостность.docmap.yml, актуальность сгенерированного, drift, сборка сайта и — с тех пор как справочник API был опубликован сломанным в двух релизах — справочник API рендерится (scripts/docs/api-render-check.mjs).Последний существует, потому что зелёная сборка — не отрисовывающаяся страница: MDX компилируется, SSR пишет содержимое,
docs:buildпроходит, а страница умирает при гидратации в браузере. Весь механизм описан вdocs/explanation/documentation-workflow.md. При добавлении gate в этот job имя проверки не меняется — и поэтому его не нужно перерегистрировать в таблице выше, но это должно быть где-то записано. Записано здесь.
pull_request_targetтребует, чтобы workflow был в ветке ПО УМОЛЧАНИЮ. Быть в базовой ветке PR недостаточно. Это измерено, а не предположено: сpull_request_targetуpr-policeбыло ноль запусков, тогда какapproval-ladderиdocs-check— использующиеpull_request— нормально запустились с того же коммита. Поскольку ветка по умолчанию —main, а она продвигается только по лестнице, которую охраняет сама проверка, триггер стал бы проблемой курицы и яйца. Оба используютpull_request.
Откуда GitHub читает каждый workflow — три семейства, три ответа. Это стоило трёх отдельных открытий за эту фазу и нигде не очевидно:
триггер читает workflow из следствие здесь pull_request,pushветки события работает с первого PR pull_request_targetветки по умолчанию не запускался: mainотстаётworkflow_dispatchветки по умолчанию даже не появляется в списке workflows Последние два создают проблему курицы и яйца, когда ветка по умолчанию устарела: workflow, который двигает конвейер, уже должен быть в
main, чтобы его можно было запустить. Обходом было вручную запустить скриптpromoteпри первом продвижении — тот же скрипт, только ручной триггер. После того какmainполучает workflows, dispatch работает навсегда.
Обязательная проверка, которая никогда не запускается, блокирует PR навсегда. Поэтому триггер
ci.ymlпокрывает все три постоянные ветки — до FASE 6 он срабатывал только на PR вdev, и требование этих проверок при продвиженииdev→qaдало бы вечно ожидающий PR. Триггерpushбыл удалён: приpull_request, покрывающем всё, он лишь дублировал запуски. Добавляя новый job в CI, либо внесите его в этот список, либо оставьте вне его намеренно и пусть кто-то напишет почему.
А обязательная проверка, которая не ПЕРЕЗАПУСКАЕТСЯ, приклеивается к старому вердикту. Это другая сторона урока выше, и она стоила одному PR ошибочного провала.
Типы
pull_requestпо умолчанию —opened,reopenedиsynchronize, — ни один не покрывает смену базы. А смена базы — рутина: GitHub открывает PR против ветки по умолчанию, а автор затем исправляет её наdev. В PR #71Drift, gerados e buildзапустился в первые полдюжины секунд, противorigin/main...HEAD, и упал на семи файлах, которые уже были отревьюены и смёржены в #70. Перенаправление его не перезапустило; красное осталось.Критерий того, кому нужен
edited, — от чего зависит проверка:
проверка зависит от… нужен edited?кто только от HEAD нет ci.yml— тестирует коммит, и база не меняет результат (кроме решения о пропуске в job образов с AT-303 — см. «Следствие для критерияedited» выше)от БАЗЫ или от ТЕЛА PR да pr-police,approval-ladder,promotion-check,backmerge-gate,docs-checkВ
docs-check— и то и другое: drift сравнивает интервал, начинающийся с базы, и читает тело в поисках строкиdocs-not-needed:. Безeditedпредохранительный клапан, документированный чуть ниже, был недостижим — запись обоснования в тело ничего не переоценивала, и только фиктивный коммит разблокировал бы PR.
claude-review намеренно не входит в этот список, и это и есть «пусть
кто-то напишет почему»: ревью через LLM субъективно и стоит токенов, поэтому
оно информирует PR, не имея права её блокировать. Поскольку проверка не
обязательна, job можно пропустить, не оставив PR ожидающим, — и он
пропускается в каждом PR, открытом ботом. Action отказывается работать с
нечеловеческим актором ("Workflow initiated
by non-human actor: <name> (type: Bot)"), так что без этого if проверка
падает каждый раз. Это случилось дважды: в PR #64 и #65 цикла v0.3.1
(продвижение, открытые github-actions[bot]) и снова в PR #526
(Dependabot).
Второй раз стал следствием того, как было записано условие. Оно
перечисляло логин — github-actions[bot], — что верно, пока множество не
изменится, и молча неверно в день, когда появляется новый бот;
dependabot.yml пришёл в PR #520, и следующий PR Dependabot воспроизвёл
провал, урок которого уже был записан. Условие теперь проверяет type автора,
повторяя ehAutorBot (scripts/ci/pr-police.ts), с суффиксом [bot] в
логине как страховкой — GitHub не допускает [ в логине человека, так что его
не подделать.
Для PR продвижения есть вторая, независимая причина: это был бы тот же diff,
отревьюенный повторно, поскольку продвижение несёт только коммиты, уже
отревьюенные в PR в dev. А для Dependabot есть третья, которая закрывает дверь
сама по себе, — его PR запускаются с хранилищем секретов Dependabot,
отдельным от хранилища Actions, так что CLAUDE_CODE_OAUTH_TOKEN приходит
пустым. Даже если разрешить актора, у ревью не было бы учётных данных, а
проверка, падающая из-за отсутствия секрета, хуже пропущенной: она читается как
дефект кода.
Чего не может выполнить PR между постоянными ветками
Drift, gerados e build является обязательной, поэтому запускается в каждом
PR — но шаг drift объявляет себя неприменимым, когда head — постоянная
ветка самого репозитория (продвижение dev→qa, qa→main; back-merge
main→qa, qa→dev). Остальные шаги job — docmap, сгенерированное и сборка
сайта — продолжают выполняться: они зависят только от HEAD и действуют на любой
ступени.
Причина та же, что и у claude-review, с отягчающим обстоятельством.
Избыточность, во-первых: PR между постоянными ветками не имеет авторства, он
упаковывает коммиты, drift которых уже был взыскан в PR в dev, файл за
файлом. Взыскивать снова — значит взыскивать тот же долг на каждой ступени. Но,
в отличие от ревью через LLM, здесь требование было невыполнимым — и именно
это провалило #72, продвижение цикла v1.0.1, из-за файлов docker/**,
пришедших из #70:
| кажущийся выход | почему его нет |
|---|---|
| обновить документацию в PR продвижения | promotion-check требует чистого интервала — head должен быть верхушкой источника. Коммит там проваливает другую обязательную проверку |
повторить docs-not-needed: из исходного PR | тело PR продвижения генерируется promote; обоснование из #70 не пересекает ступень |
| ставить метку на каждое продвижение | это учит пользоваться предохранительным клапаном рефлекторно, пока он ничего не будет значить, — противоположность тому, чего просит .docmap.yml |
Фильтр живёт внутри шага, а не в if: на job, следуя тому же принципу,
который записывает promotion-check: обязательная проверка, индексированная по
sha, которая не запускается, оставляет PR ожидающим навсегда. Шаг запускается,
решает, что не применим, и пишет это в сводку — вместо того чтобы исчезнуть.
Head с именем
mainиз форка — не продвижение. Это рабочая ветка третьего лица, и она проходит drift, как любая другая. Поэтому условие сопоставляет имя и требует того же репозитория — та же оговорка, которуюpr-policeприменяет при классификации семейства PR.
Bypass
| кто | режим | зачем |
|---|---|---|
актор BRABO_BOT_TOKEN | Always | писать .release/gate.json в main |
Ни у одного человека нет bypass — даже у владельца. Этот принадлежит боту и
существует по причине без обхода: gate блокирует ветки, и PR для снятия
блокировки был бы заблокирован самим gate. Коммит остаётся в git log, с датой
и содержимым, и tag-release распознаёт его по тому, что он затрагивает
(.release/ и ничего больше), а не по тому, кем он себя называет.
Предупреждение об охвате bypass. Rulesets GitHub дают bypass актору, а не пути: тот, кто может писать
.release/gate.jsonвmain, технически может писать что угодно. Ограничить по пути в интерфейсе нельзя. Реально ограничивает workflow — он пишет только этот файл — и история, где любой другой прямой коммит сразу бросается в глаза. Записано как есть: ограничение инструмента, а не решение.
Секрет BRABO_BOT_TOKEN
Классический PAT со scopes repo + workflow, в Settings → Secrets and
variables → Actions:
gh secret set BRABO_BOT_TOKEN --body '<token>'
Это не удобство. От него зависят две вещи, и обе молча ломаются без него:
| что | почему |
|---|---|
тег запускает Release | тег, созданный с GITHUB_TOKEN, не запускает workflow |
| PR back-merge рождаются с проверками | PR, открытый с GITHUB_TOKEN, не запускает workflow |
Это правило GitHub против рекурсии, и оно уже взяло свою цену: Release
v0.2.0 так и не была опубликована из-за него. Второй случай хуже — PR
back-merge без проверки никогда не стал бы зелёным, и цепочка застряла бы
навсегда. Поэтому job gate громко падает, когда секрета нет, а не идёт
дальше, оставляя репозиторий в тупике.
Scope workflow — не излишество: PR back-merge несут ветку целиком, и она может
содержать изменение в .github/workflows/**. Без этого scope push отклоняется
ровно в том случае, который важнее всего, — при распространении исправления CI.
Текущее состояние: настроено и испытано. В
v1.1.1конвейер впервые замкнулся сам —tag-releaseпропустил шаг предупреждения, отправил тег с PAT, и это запустилоrelease.yml, который опубликовал Release. Тег, отправленный сGITHUB_TOKEN, его бы не запустил; этот запуск — доказательство того, что токен действителен и имеет scope на push.Шесть тегов остаются осиротевшими —
v0.2.0,v0.3.0,v0.3.1,v1.0.0,v1.0.1иv1.1.0— из периода, когда секрета не было. PAT их не восстанавливает: он действует только для новых тегов. Для них процедура ниже.
Повторная публикация осиротевшего тега
У release.yml есть workflow_dispatch с входом тега, и он существует из-за
шести тегов выше. Один лишь триггер push: tags сделал бы провал
необратимым: повторная публикация потребовала бы удалить и пересоздать тег,
то есть переписать запись, чтобы исправить её следствие.
gh workflow run release.yml -f tag=v1.1.0
Три защиты, и каждая существует из-за конкретного режима отказа:
| защита | против чего |
|---|---|
запустить его может только OWNER_HANDLE (режим solo) | то же ограничение, что и у promote; триггер push здесь не проходит, потому что там авторизовал тег |
вход должен совпадать с ^v[0-9]+\.[0-9]+\.[0-9]+$ | workflow_dispatch принимает свободный текст. Без этого -qa.1 опубликовал бы Release того, что никто не подтвердил как финальное |
| отказ, если Release уже существует | опубликованная заметка — это запись; молча перезаписать её значило бы стереть то, что кто-то уже прочитал или на что сослался |
И checkout делается на теге, а не на ref события. При dispatch
github.ref_name — ветка по умолчанию; без этой предосторожности повторная
публикация породила бы Release с именем main, с changelog main, без
какой-либо ошибки.
Dispatch работает только после того, как это дойдёт до
main. Это проблема курицы и яйца из таблицы триггеров выше:workflow_dispatchчитает workflow из ветки по умолчанию. Пока изменение только вdev, вход даже не появляется.
Ruleset 2 — теги версий
Имя: tags-de-release
Enforcement: Active
Цель: Tag → Include by pattern → v*
| правило | значение |
|---|---|
| Restrict creations | ✅ |
| Restrict updates | ✅ |
| Restrict deletions | ✅ |
Шаблон v* покрывает три формы, которые создаёт конвейер: -dev.N, -qa.N и
финальную. Создавать их может только tag-release.
Что запускает финальный тег и почему ничто из этого не обязательная проверка. Отправка
vX.Y.Zпараллельно запускаетrelease.yml,build-runner-binaries.ymlиpublish-runner.yml. Их jobs никогда не появляются в таблице обязательных проверок выше, и это структурно, а не упущение: обязательные проверки удерживают pull request, а когда тег существует, PR уже смёржен. Тег удерживает ruleset — создаёт его толькоtag-release, и только после конвейера, в конце которого он запускается.Финальный тег также запускает
install-e2e.yml, который испытывает опубликованныйinstall.shна чистой машине — а эфемерный runner Actions именно ею и является. С RN-549 он доходит до конца: пять звеньев, замыкающих установку, локальный агент поднят и ждёт, и первый проектrunnerподхвачен. Он не запускается вpull_request, и причина та же, что держит подпись вне PR: подписанный манифест существует только после финального тега, а запуск скрипта там дал бы ему дверь для пропуска проверки (RN-534). Следствие сказано, а не скрыто — PR, который меняет этот workflow, его не запускает, — а в каждом PR запускаетсяscripts/dev/install-e2e.spec.ts, который проваливается, если триггер получитpull_requestили если фраза, которую workflow ищет grep, перестанет существовать вinstall.sh.С ADR 0149 у
build-runner-binaries.ymlдва job вместо одного: матрицаbuild(четыре цели,fail-fast: false) и jobchecksums. С RN-565 у второго нетneeds:на матрицу — он ждёт, с потолком, assets Release, а не самый медленный job, — поэтому цель, упавшая при сборке или так и не получившая runner, не лишает остальные подписанного манифеста. Он называет в логе, какие цели манифест не покрывает, потому чтоchecksums.txt, перечисляющий три и молчащий о четвёртой, хуже никакого: тот, кто проверяет три, заключает, что проверил релиз.Цель
darwin-x64(Mac Intel) вышла из матрицы в ADR 0174, и причина измерена (AT-065, 2026-09-13): её меткаmacos-13указывала на образ, который GitHub вывел из эксплуатации в декабре 2025 года, а замена на Intel,macos-15-intel, планируется за секунды и собирает, но проваливает--self-test-ptyпод Bun (onDataизnode-ptyникогда не срабатывает — oven-sh/bun#25822, открыт). То же доказательство проходит под Node, поэтому Mac Intel устанавливает runner черезnpm install -g @brabo/runner. Запуск workflow с пустым входомtag— это репетиция: собирает и делает smoke, ничего не прикрепляя, и именно так это было измерено без создания тега.
Bypass
| кто | режим |
|---|---|
| app/бот, запускающий workflow релиза | Always |
Это исключение для push, предусмотренное политикой: версия рождается из
workflow, никогда вручную. Тег, созданный вручную, не проходит проверку того,
что финальный тег указывает на тот же коммит, что и последний -qa.N, а именно
эта проверка не даёт опубликовать что-то отличное от проверенного.
Как применить
Интерфейс: Settings → Rules → Rulesets → New ruleset.
Через API, если предпочитаете версионировать команду:
gh api -X POST repos/daneiel/brabo/rulesets --input ruleset-permanentes.json
gh api repos/daneiel/brabo/rulesets --jq '.[] | "\(.name): \(.enforcement)"'
TODO(humano): два файла
.jsonс payload здесь не версионируются, потому что API rulesets требуетrepository_idи id приложений, различающиеся от установки к установке, — фиксированный файл скопировали бы неправильно. Если хотите их версионировать, сгенерируйте черезgh api repos/daneiel/brabo/rulesets/<id> > docs/reference/...после применения через интерфейс, и этот документ начнёт указывать на них.
Settings → Pages: источник — ветка gh-pages
Применение ручное, по той же причине, что и у rulesets: репозиторий версионирует источник, GitHub получает применение.
Settings → Pages → Build and deployment → Source:
Deploy from a branch, с веткой gh-pages и папкой
/ (root).
Уже применено. gh api repos/daneiel/brabo/pages возвращает
"build_type": "legacy" с source.branch: "gh-pages", и все три ступени
отвечают в сети.
Промежуточный шаг стоит записи, потому что сбивает с толку: пока
build_type был "workflow", docs-deploy.yml коммитил в gh-pages,
и Pages ничего оттуда не отдавал — публикация оставалась в репозитории, не
выходя в сеть, а старый сайт всё ещё отвечал из последнего артефакта
actions/deploy-pages. Ничто в CI не краснеет в этом состоянии.
Почему переключение обязательно, а не предпочтение: actions/deploy-pages
публикует один артефакт как весь сайт и не умеет обновлять часть дерева,
что несовместимо с подкаталогом на ступень. Полный дизайн, отброшенные
альтернативы и исключение для push, которое это открывает, описаны в
ADR 0034.
| ветка | URL | индексируется |
|---|---|---|
| — (индекс трёх) | https://daneiel.github.io/brabo/ | ❌ noindex, follow |
main | https://daneiel.github.io/brabo/prd/ | ✅ |
qa | https://daneiel.github.io/brabo/qa/ | ❌ noIndex |
dev | https://daneiel.github.io/brabo/dev/ | ❌ noIndex |
Все три стали симметричными в
ADR 0071, который также
объясняет сгенерированный корень и 404.html, перенаправляющий старые ссылки
на стабильную ступень.
ADR 0073 отделил
ПУТЬ от ВЕТКИ: main публикуется в /prd/, потому что адрес называет среду
для читающего. 404.html стал переписывать
/brabo/main/<algo> в /brabo/prd/<algo> — старый каталог уходит из
дерева, и keep_files: false его удаляет.
Конфигурация Pages не меняется ни с одним из двух — источником остаётся
ветка gh-pages в папке / (root); изменилось содержимое, которое workflow
собирает перед push. И prd не ветка: её нет ни в одном ruleset, её нет в
git, это лишь имя каталога в gh-pages.
gh-pagesНЕ входит в ruleset постоянных веток. Она не постоянная, и боту нужно пушить в неё — её включение заблокировало бы саму публикацию. Поэтому там же хватаетGITHUB_TOKEN, без bypass и безBRABO_BOT_TOKEN.
И она вне охвата Gitleaks, по причине, которая проявилась лишь позже.
gitleaks detectсканирует каждый достижимый коммит, а не только историю HEAD, — аfetch-depth: 0приносит все refs. Как только появиласьgh-pages, собранный сайт попал в сканирование: 112 находокgeneric-api-keyв одном коммите, все вdev/assets/js/*.js, — это имена файлов аватаров внутри минифицированного bundle со слишком высокой для правила энтропией. Это провалило PR продвижения #78, первый просканированный после её появления.
ci.ymlтеперь удаляетrefs/remotes/origin/gh-pagesперед сканированием. Не через allowlist путей в.gitleaks.toml: он действовал бы для каждого коммита, иdev/, появившийся однажды в исходном коде, молча перестал бы сканироваться. Исключение ref исключает ровно те коммиты, которые не являются исходным кодом.Измерено gitleaks 8.30.1, версией CI: 133 коммита и 112 находок до, 132 и ни одной после — а PAT GitHub с реальной энтропией, подложенный в
apps/api/src/, по-прежнему обнаруживается, что доказывает, что дыра не открылась.
Метки семейств должны существовать
pr-police применяет одну из четырёх меток, и gh pr edit --add-label
падает, если метки нет, — что оставило бы PR без классификации, молча.
gh label create trabalho --color 0E8A16 --description "Work PR: taxonomy prefix for dev"
gh label create promocao --color 1D76DB --description "Promotion between adjacent rungs, going up"
gh label create retropropagacao --color 5319E7 --description "Back-propagation between adjacent rungs, going down"
gh label create correcao-alta --color D93F0B --description "hotfix: a fix that's born high on the ladder"
Без || true намеренно: если команда упадёт, вам нужно это увидеть.
Проверить, что всё на месте
# do the rulesets exist and are they active?
gh api repos/daneiel/brabo/rulesets --jq '.[] | "\(.name): \(.enforcement)"'
# does the protection respond? (should be REJECTED)
git push origin HEAD:dev
Вторая команда — тот тест, который важен. Ruleset, настроенный, но не
проверенный, неотличим от отсутствующего — и разница проявляется только в тот
день, когда кто-то пушит прямо в main.