Na Selenios, durante muito tempo a regra do nosso repositório principal foi a que quase todos os times têm: um pull request só pode ser mergeado se o branch estiver em dia com o main e o CI tiver passado nesse estado exato. É uma boa regra. Garante que o que chega ao main foi testado do jeito que vai ficar lá.
Ela também tem um custo que ninguém coloca no orçamento, e este ano esse custo mudou de escala. Boa parte do nosso código hoje é escrita junto com agentes de programação. Eles escrevem muito código e, a favor deles, escrevem muitos testes. Cada vez que algo era mergeado, todos os outros pull requests abertos ficavam para trás e precisavam ser atualizados, e cada atualização rodava a suíte inteira de novo. Quando medimos, cerca de metade dos minutos de CI de cada pull request ia para isso: não para testar a mudança, mas para testá-la de novo porque o main tinha mudado.
Isto é o que medimos, por que esse custo cresce mais rápido que o código, como passamos para a merge queue do GitHub e o que mudou na primeira semana. A versão curta: a fila elimina a necessidade de atualizar um branch. Não elimina o hábito.
Mais código, e muito mais testes
Entre janeiro e setembro de 2026, os merges no main por mês cresceram 3,3 vezes. Os casos de teste do repositório cresceram 74 vezes.
A segunda linha é a que importa para o CI. Os agentes são bons em escrever testes. Quando você pede uma correção, eles escrevem um teste que reproduz o bug. Quando você pede uma funcionalidade, eles cobrem os casos de borda que uma pessoa deixaria para depois. É exatamente o que queremos, e faz cada execução de CI ser mais longa que a anterior. Uma suíte que roda uma vez por mudança é um investimento. Uma suíte que roda toda vez que um vizinho faz merge é um imposto.
O imposto: manter-se em dia com o main
Com a regra de "exigir branches em dia", um pull request verde deixa de estar verde no momento em que qualquer outra coisa é mergeada. Alguém, ou algum agente, clica em "Update branch", o branch recebe um commit de merge do main e o pipeline inteiro roda de novo: lint, tipos, build, os testes unitários, as suítes que usam banco de dados e, no nosso caso, um ambiente de preview que é implantado de novo para aquele PR.
Olhamos os pull requests abertos depois da nossa última limpeza de CI (os drafts só rodam as verificações rápidas) e mergeados antes da fila:
- 92 % foram atualizados com o
mainpelo menos uma vez, e em média quase três vezes. - Essas atualizações eram 51 % dos minutos de CI de um pull request.
- Cada atualização também implantava de novo o ambiente de preview do PR: cerca de quatro em cada dez deploys de preview eram disparados por uma atualização, não por uma mudança no código.
A proporção ficava em torno da metade não importava quanto tempo o pull request ficasse aberto, e essa é a parte incômoda. Um pull request aberto mais de um dia custa mais que o triplo de um aberto menos de quatro horas, e metade disso continua sendo testar o main de novo.
Por que a IA faz isso crescer mais rápido que o código
O número de atualizações que um pull request precisa é, mais ou menos, o número de merges que entram enquanto ele está aberto. É aí que a conta fica contra você:
- Fazer merge três vezes mais seguido significa que cada pull request aberto fica para trás três vezes mais seguido.
- Além disso, há três vezes mais pull requests abertos para ficar para trás.
- Então as execuções por atualização crescem mais ou menos com o quadrado do ritmo de merges, não de forma linear.
- E cada uma dessas execuções é mais longa, porque a suíte não para de crescer.
Há mais um efeito, e ele é próprio dos agentes: eles seguem instruções. Se um fluxo diz "atualize o branch antes do merge", um agente vai fazer isso sempre, às três da manhã, para um branch que ninguém está esperando. No nosso repositório, gh pr update-branch estava na lista de comandos que os agentes podiam rodar sem perguntar.
O que uma merge queue faz no lugar
Uma merge queue inverte o problema. Em vez de cada pull request se manter em dia por conta própria, o pull request avisa "estou pronto" e o GitHub faz o teste sobre o main na hora do merge:
- Cada entrada da fila é um branch temporário: o
main, mais as entradas à frente, mais este pull request. - O CI roda uma vez sobre essa combinação. Se passar, esse commit exato é o que é mergeado.
- Várias entradas são testadas em paralelo, cada uma supondo que as da frente vão passar. Se uma falha, ela sai da fila e as de trás são remontadas sem ela.
A garantia é a mesma de antes, "o que chega ao main foi testado do jeito que fica lá", mas o custo é uma execução por pull request em vez de uma execução por cada merge que aconteceu enquanto ele estava aberto.
A migração
A mudança na configuração do GitHub é pequena. O trabalho é fazer o CI se comportar bem numa execução que não tem um pull request associado. Estas foram as nossas mudanças.
1. Escutar o evento da fila e corrigir a concorrência. As execuções da fila chegam como eventos merge_group. Nosso grupo de concorrência usava o número do PR, que numa execução da fila está vazio: todas as entradas cairiam no mesmo grupo e cada uma cancelaria a da frente.
on:
pull_request:
branches: [ main ]
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
types: [checks_requested]
concurrency:
# A merge_group run has no PR number: fall back to its own
# gh-readonly-queue ref, which is unique per queue entry.
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
2. Fazer o "o que mudou" funcionar na fila. Pulamos os builds do frontend quando um PR só mexe no backend, com um filtro por caminhos. A versão que tínhamos fixada não sabia o que uma entrada da fila mudava. A versão 4 compara a entrada com a sua base (merge_group.base_sha..head_sha), então cada entrada só constrói o que toca.
- uses: dorny/[email protected]
id: filter
with:
filters: |
backend:
- 'backend/**'
- 'pnpm-lock.yaml'
frontend:
- 'frontend/**'
- 'pnpm-lock.yaml'
3. Decidir o que a fila roda. No nosso CI, pull requests em draft só rodam as verificações rápidas. Uma execução da fila não tem a marca de draft, então a condição que usamos resulta em "não é draft" e a fila sempre roda a suíte completa. É o que queremos na hora do merge. Os deploys de preview, por outro lado, não escutam a fila: um branch temporário da fila não precisa de preview.
4. Ligar. Passamos o requisito para um ruleset do repositório: o check que resume o CI é obrigatório, "os branches precisam estar em dia" fica desligado e a fila, ligada.
{
"type": "merge_queue",
"parameters": {
"merge_method": "SQUASH",
"max_entries_to_build": 3,
"max_entries_to_merge": 1,
"grouping_strategy": "ALLGREEN",
"check_response_timeout_minutes": 60
}
}
Mantivemos a proteção de branch clássica, que é a que sustenta a evidência dos nossos controles de compliance (revisão de code owners, sem bypass), e só desligamos o requisito de "em dia".
Antes de ligar no main, ensaiamos num branch descartável, com o seu próprio ruleset e alguns pull requests de teste. Duas coisas desse ensaio valiam a pena saber:
- Quando uma entrada falha, o GitHub remonta as de trás em menos de um minuto, mas não cancela as execuções antigas delas. Essas continuam até o fim, e você paga por elas.
- Então um teste instável deixa de ser só irritante: ele tira um pull request da fila e custa uma rodada de execuções órfãs para todos os de trás. Corrigimos os dois testes mais instáveis que conhecíamos no mesmo momento em que ligamos a fila.
A primeira semana
Comparamos pull requests de duração parecida, porque os que foram abertos desde que a fila começou são, por definição, recentes e curtos.
- Menos atualizações. Os pull requests abertos menos de 4 horas que foram atualizados com o
mainpassaram de 82 % para 27 %. Os abertos entre 4 e 24 horas, de 98 % para 40 %. - Menos redeploys de preview. Os previews disparados por uma atualização caíram entre 55 % e 62 % por pull request.
- A fila custa algo. Cada pull request agora paga uma execução na fila, e ela é sempre uma execução completa da suíte inteira.
- O líquido, por enquanto. Para pull requests abertos menos de 4 horas, manter-se em dia com o
maincusta mais ou menos o mesmo que antes (+6 %). Para os abertos entre 4 e 24 horas, custa 22 % menos. Para os mais longos, ainda é cedo. - Os testes instáveis aparecem. 9 % das entradas da fila falharam e saíram da fila.
É uma manchete menor que "cortamos o CI pela metade", e é a honesta. O motivo aparece no gráfico: a execução da fila é um custo fixo por pull request, enquanto as atualizações que ela substitui crescem com o tempo que o pull request fica aberto e com a frequência com que o main muda. Para um pull request aberto menos de quatro horas, há pouco para substituir. Para um aberto um dia ou mais, há muito.
A fila elimina a necessidade, não o hábito
O outro motivo de a economia ainda ser pequena: cerca de um terço dos pull requests novos continuou sendo atualizado com o main. Alguns eram conflitos reais. O resto era reflexo: pessoas clicando num botão que clicam há anos, e agentes que ainda tinham gh pr update-branch entre as suas permissões.
Então também mudamos as instruções. O arquivo que nossos agentes leem antes de trabalhar no repositório agora diz isso sem rodeios:
### Merging — Do Not Update The Branch With Main
`main` merges through a merge queue: GitHub tests every queued PR
on top of the current `main` and merges exactly the commit it tested.
- Do not merge `main` into a PR branch, rebase it, click "Update branch"
or run `gh pr update-branch` just to bring it up to date.
- Do update the branch for a real reason: a merge conflict, or a change
on `main` the PR needs.
- To merge, use "Merge when ready" (`gh pr merge --auto`).
E tirei gh pr update-branch da lista de comandos que meus agentes podem rodar sem perguntar. Se as atualizações só acontecerem por conflitos reais, manter-se em dia com o main passa a ser uma execução da fila por pull request, não importa quanto ele dure: pelos nossos próprios números, cerca de 16 % menos que antes para os pull requests curtos, mais ou menos metade para os abertos entre 4 e 24 horas e três quartos a menos para os abertos mais de um dia.
Se você está pensando em fazer isso
- Meça primeiro o imposto das atualizações. Conte as execuções de CI cujo commit é um merge do seu branch principal. Se forem uma parte grande dos seus minutos, a fila se paga; se os seus pull requests vivem uma hora e os merges são poucos, talvez não.
- Corrija a concorrência antes de tudo. Uma execução da fila não tem número de pull request. Tudo o que depende dele quebra sem avisar.
- Revise cada action que pergunta "o que mudou". Filtros por caminho, detecção de mudanças e ferramentas de projetos afetados precisam entender o evento da fila.
- Corrija os seus testes instáveis. Numa fila, um teste instável custa para todos que estão atrás do pull request, não só para o autor.
- Mude as instruções, não só a configuração. Se os seus agentes foram instruídos a manter os branches em dia, eles vão continuar fazendo isso, com muita diligência, quando o motivo já não existir.
Estes são números da primeira semana. Vamos voltar a eles quando houver algumas semanas de pull requests que viveram a vida inteira com a fila, incluindo os longos, que é onde deveria estar a maior parte da economia.