ADR 0030 — Política de branches mecanizada
Contexto
A política de branches do Brabo existia como apresentação e como convenção no
CLAUDE.md: esteira dev → qa → main, taxonomia de branches, promoção só entre
degraus vizinhos, versão calculada, hotfix voltando por retropropagação.
Convenção escrita não é mecanismo. Enquanto a regra mora só no documento, ela depende de todo mundo lembrar dela na hora errada — e a hora errada é sempre a de incidente, às 3h da manhã, quando lembrar é justamente o que não acontece. Pior: uma regra que ninguém verifica não tem como ser violada visivelmente. Ela é violada em silêncio, e o sintoma aparece meses depois, longe da causa.
A FASE 6 mecanizou a política inteira no próprio repositório do Brabo. Este ADR fecha a fase: mapeia regra → mecanismo, registra o que foi cortado com o custo de reintroduzir cada corte, e separa o que disso vira template para o bootstrap de Gitflow do produto — que é fase futura, não código agora.
Duas restrições moldaram tudo. A primeira: este é um repositório de usuário,
não de organização. Times do GitHub não existem aqui, e o GITHUB_TOKEN não
lê membership de time nem em organização. A segunda: o produto não pode ser
tocado nesta fase. A FASE 6 é CI/CD do repositório; o que ela ensina ao
produto vira este ADR, não código.
Decisão
Regra → mecanismo
| regra da política | mecanismo | onde a lógica vive |
|---|---|---|
nome funcao/descritivo, regex ^.{0,15}/\S{0,32}$ | check pr-police | scripts/ci/pr-police.ts |
| prefixo na lista fechada de 9 funções | pr-police | idem |
trabalho nasce de dev, hotfix de main | pr-police, por contaminação | idem |
| destino coerente com a função | pr-police | idem |
| promoção só entre degraus vizinhos | pr-police + promote | pr-police.ts, version.ts |
| label de família em todo PR | pr-police | idem |
| aprovação exigida por destino | check approval-ladder | scripts/ci/approval-ladder.ts |
| dois modos de aprovação por variável | approval-ladder | idem |
pessoas distintas em main | emparelhamento por backtracking | idem |
| versão pelo maior impacto do ciclo | promote + tag-release | scripts/ci/version.ts |
N incrementa por reprovação | contagem das tags existentes | idem |
final ancorada na última -qa.N | tag-release, por árvore + pai | idem |
| hotfix gera PATCH sem âncora | tag-release, pelo segundo pai | idem |
| push direto bloqueado | rulesets | docs/reference/rulesets.md |
| retropropagação obrigatória e ordenada | check backmerge-gate | scripts/ci/gate.ts |
| merge em permanente é sempre manual | ausência de mecanismo, por decisão | — |
Toda lógica é script testável; workflow é casca fina que lê ambiente e chama o script. São 149 testes cobrindo caminho felizes e casos de falha.
As sete lições que valeram mais que o código
Elas estão aqui porque cada uma custou uma descoberta empírica, e nenhuma está óbvia em documentação de terceiros.
1. Cada família de gatilho lê o workflow de um lugar diferente.
pull_request e push leem da branch do evento; pull_request_target e
workflow_dispatch, da branch padrão. Com pull_request_target o
pr-police teve zero execuções — a main estava 65 commits atrás. É
ovo-e-galinha: o check que faz a esteira andar precisa já estar na main para
poder rodar.
2. Tag e PR criados com o GITHUB_TOKEN não disparam workflow. Regra contra
recursão. A Release da v0.2.0 nunca publicou por isso, e um PR de
retropropagação sem check nunca ficaria verde — a cadeia travaria para sempre.
Daí o BRABO_BOT_TOKEN.
3. "Não consegui verificar" nunca pode virar "está tudo bem". O
promotion-check lia a configuração de merge com gh api --jq e comparava com
'true'. Quando o token não tem permissão, o comando tem sucesso devolvendo
vazio — e vazio não é 'true', então a verificação passava a reprovar por
motivo errado, ou pior, a aprovar. Hoje há três estados, e a impossibilidade
vira aviso enquanto a garantia real olha o fato consumado: o commit tem dois
pais.
4. Igualdade de árvore é mais forte que igualdade de commit. A âncora
comparava shas — impossível de satisfazer, porque --no-ff cria um commit novo.
Comparar árvore e exigir que a -qa.N seja pai é mais forte: se o outro
lado do merge tivesse trazido um arquivo, a árvore mudaria.
5. Contaminação, não origem. Descobrir de onde uma branch nasceu é
indecidível a posteriori: com P ⊆ Q ⊆ head, o argmin de distância escolhe a
permanente mais avançada contida, não a de origem. A pergunta certa é outra —
"esta branch carrega o topo de uma permanente mais avançada que a que ela
declara?" — e essa tem resposta.
6. Isenção por autor, nunca por prefixo. Isentar branches que começam com
dependabot/ seria uma brecha aberta: qualquer um nomeia uma branch assim.
7. Estado declarado tem que ser conferível. O .release/gate.json viaja nos
PRs de retropropagação para qa e dev; uma promoção pode subir aquela cópia
velha de volta e ressuscitar uma trava sem hotfix por trás — travando o
repositório para sempre, porque não há retropropagação pendente que a resolva. O
check pergunta ao git se o hotfix já desceu e deixa cair travas satisfeitas.
locked é o registro da intenção; a contenção é a verdade.
O que foi cortado, e o custo de voltar
| cortado | por quê | o que custa reintroduzir |
|---|---|---|
degrau rc / UAT | sem ambiente e sem gente para exercê-lo, seria degrau cerimonial | criar a branch, uma linha em ESCADA, uma em ESTAGIO_POR_BRANCH, uma na escada de aprovação, e a ordem do gate ganha um degrau — a cadeia do hotfix passa a ser quatro PRs |
taxonomia com rcfix | morreu com o degrau rc | entrada em FUNCOES_DE_CORRECAO_ALTA mais os testes de origem e destino |
| times do GitHub para papéis | repo de usuário não tem times, e o GITHUB_TOKEN não lê membership nem em org | papéis são listas de handles em variáveis; migrar para times exigiria a org e um token com read:org |
| deploy | passo que nunca roda apodrece: ninguém o testa, e no dia de ligar estará errado | workflow próprio disparado pela tag, mais os Environments — nada a religar aqui |
| GitHub Environments | idem | criar quando houver ambiente |
GOVERNANCE.md | cortado na FASE DOC; o critério mora no branching-policy.md | escrever o arquivo e mover a seção de papéis para lá |
A escada de aprovação em modo community não foi cortada: está
implementada, testada e desligada por configuração. A diferença é deliberada —
código desligado por variável, com teste dos dois lados, é demonstrável hoje;
passo de pipeline desligado por variável não é.
O que vira template do bootstrap do produto
O produto provisiona repositórios com Gitflow (FASE 2). O que a FASE 6 aprendeu e que deve entrar nesse template, em fase futura:
| entra no template | não entra |
|---|---|
| a esteira e a taxonomia como dados, não código — o produto tem projetos com escadas diferentes | os workflows do Brabo copiados literalmente |
| o gate de retropropagação: é a regra que mais custa quando falta | o BRABO_BOT_TOKEN — cada repo precisa da sua credencial |
| versão calculada da tag, sem PR de bump | as listas de aprovadores deste repo |
| "não consegui verificar" como terceiro estado explícito nos gates | — |
| isenção de bot por autor | — |
O GitProvider já expõe capabilities, e o ADR 0028 registra que proteção de
branch divirge entre providers. O template terá de degradar: onde não houver
ruleset, a regra vira check e a mensagem diz o que não pôde ser garantido — em
vez de fingir garantia.
Consequências
A política deixou de depender de memória. Nome errado de branch, promoção pulando degrau, hotfix sem retropropagação e tag final desancorada são agora falhas de check com mensagem que ensina, não descobertas tardias.
A esteira foi exercitada de ponta a ponta, não só testada em unidade.
v0.1.0 → v0.2.0-dev.1..4 → -qa.1..3 → v0.2.0, com uma reprovação encenada
entre os carimbos de qa para provar que o N conta sozinho. A final ancorou
na -qa.3 com árvore idêntica.
Um check required que nunca roda trava o PR para sempre. É a consequência
mais incômoda: a lista de checks obrigatórios é agora um acoplamento entre o
ruleset (aplicado à mão) e os workflows (versionados). Job novo no CI ou entra
na lista de rulesets.md, ou fica de fora de propósito e alguém escreve por quê.
É por isso que o backmerge-gate não tem if: no job: veredito velho colado a
um sha que não rodou liberaria merge durante trava.
O bypass do gate é do ator, não do caminho. Rulesets do GitHub não
restringem bypass por path. Quem pode escrever .release/gate.json pode,
tecnicamente, escrever qualquer coisa em main. O que limita de fato é o
workflow e o histórico. Fica registrado como limitação da ferramenta.
Aplicação de ruleset continua manual. O repositório versiona a fonte
(docs/reference/rulesets.md); aplicar é ato do owner. Automatizar exigiria um
token com poder de mudar as próprias proteções — o que anularia o ponto delas.
A concentração de papéis no owner está escrita, não subentendida.
Responsável de release e plantão de hotfix são o owner enquanto
APPROVAL_MODE=solo. As duas atribuições reabrem na migração para community,
onde o fallback de plantão terá de ser exceção documentada, nunca burla.
Nada do produto mudou. Nenhuma linha de apps/ foi tocada. O que a fase
ensinou ao produto está na tabela de template acima, e virou plano, não código.
Referências: ADR 0028 (divergência de proteção entre providers), 0029 (o mecanismo de docs que esta fase usa), e a política completa em branching-policy.