Skip to content

O imposto de CI que ninguém orçou: por que passamos para uma merge queue quando a IA começou a escrever nosso código

· 12 min de leitura

tags: dev-tools, ai-agents, architecture

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.

Crescimento desde janeiro de 2026, como múltiplo de janeiro Escala logarítmica. Os casos de teste do repositório cresceram 74 vezes entre janeiro e setembro de 2026. Os merges no main por mês cresceram 3,3 vezes. ×1 ×3 ×10 ×30 ×100 Jan Fev Mar Abr Mai Jun Jul Ago Set Jan: ×1.0 Fev: ×2.2 Mar: ×3.4 Abr: ×6.1 Mai: ×8.1 Jun: ×26.2 Jul: ×33.0 Ago: ×51.2 Set: ×74.1 Casos de teste ×74 Jan: ×1.0 Fev: ×0.8 Mar: ×1.0 Abr: ×1.1 Mai: ×1.2 Jun: ×1.2 Jul: ×1.5 Ago: ×2.6 Set: ×3.3 Merges / mês ×3,3
Escala logarítmica, janeiro = ×1. Casos de teste são os blocos it/test do repositório no fim de cada mês; merges são os commits squash no main. O salto de junho é uma campanha deliberada de cobertura de testes em todo o código.

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 main pelo 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.
Para onde iam os minutos de CI de um pull request antes da merge queue Para pull requests abertos menos de 4 horas, entre 4 e 24 horas e mais de um dia, cerca de metade dos minutos de CI foram execuções disparadas por atualizar o branch com o main. Aberto < 4 h Execuções por commits do PR: 50 Execuções por atualizar com o main: 50 100 50 % Aberto 4–24 h Execuções por commits do PR: 75 Execuções por atualizar com o main: 94 169 55 % Aberto > 24 h Execuções por commits do PR: 154 Execuções por atualizar com o main: 170 324 52 % Execuções por commits do PR Execuções por atualizar com o main
Minutos de CI por pull request, indexados para que um PR aberto menos de 4 horas custe 100. Pull requests abertos depois da nossa mudança de CI para drafts e mergeados antes da fila. Qualquer que fosse a duração, cerca de metade da conta era testar o main de novo.

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:

Como uma merge queue testa os pull requests Cada entrada da fila é montada sobre o main mais todas as entradas à frente dela, o CI roda uma vez sobre essa combinação e o que é mergeado é o commit testado. main Entrada 1 main + A CI roda uma vez Entrada 2 main + A + B CI roda uma vez Entrada 3 main + A + B + C CI roda uma vez Cada entrada é montada sobre o main mais as entradas à frente dela. O que é mergeado é exatamente o commit que foi testado.
Até três entradas são testadas em paralelo. Se uma falha, ela sai da fila e as de trás são remontadas sem ela.
  • 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 main passaram 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 main custa 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.
Minutos para manter um pull request em dia com o main, antes e depois da fila Antes da fila, manter-se em dia eram as atualizações do branch. Depois, são as atualizações que restam mais uma execução da fila. PRs curtos custam mais ou menos o mesmo; os abertos entre 4 e 24 horas gastam 22 % menos. Ainda não há dados de PRs abertos mais de um dia. Aberto < 4 h Antes: atualizações: 100 100 Depois: atualizações: 22 Depois: execução da fila: 84 106 (+6 %) Aberto 4–24 h Antes: atualizações: 190 190 Depois: atualizações: 62 Depois: execução da fila: 87 149 (−22 %) Aberto > 24 h Antes: atualizações: 342 342 Depois: ainda é cedo para medir Antes: atualizações Depois: atualizações Depois: execução da fila
Índice: minutos de atualização de um PR aberto menos de 4 horas antes da fila = 100. "Depois" são pull requests abertos e mergeados nos primeiros dias da fila, uma amostra pequena. A execução da fila custa mais ou menos o mesmo para cada PR; as atualizações que ela substitui crescem com o tempo que o PR fica aberto.

É 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.