Skip to content

El impuesto de CI que nadie presupuestó: por qué pasamos a una merge queue cuando la IA empezó a escribir nuestro código

· 12 min de lectura

tags: dev-tools, ai-agents, architecture

En Selenios, durante mucho tiempo la regla de nuestro repositorio principal fue la que tienen casi todos los equipos: un pull request solo se puede mergear si su rama está al día con main y el CI pasó sobre ese estado exacto. Es una buena regla. Garantiza que lo que llega a main se probó tal como va a quedar ahí.

También tiene un costo que nadie pone en un presupuesto, y este año ese costo cambió de escala. Buena parte de nuestro código hoy se escribe junto con agentes de programación. Escriben mucho código y, a su favor, escriben muchos tests. Cada vez que algo se mergeaba, todos los demás pull requests abiertos quedaban atrasados y había que actualizarlos, y cada actualización volvía a correr la suite entera. Cuando lo medimos, cerca de la mitad de los minutos de CI de cada pull request se iban en eso: no en probar el cambio, sino en volver a probarlo porque main se había movido.

Esto es lo que medimos, por qué ese costo crece más rápido que el código, cómo pasamos a la merge queue de GitHub y qué cambió en la primera semana. La versión corta: la cola elimina la necesidad de actualizar una rama. No elimina la costumbre.

Más código, y muchísimos más tests

Entre enero y septiembre de 2026, los merges a main por mes crecieron 3,3 veces. Los casos de test del repositorio crecieron 74 veces.

Crecimiento desde enero de 2026, como múltiplo de enero Escala logarítmica. Los casos de test del repositorio crecieron 74 veces entre enero y septiembre de 2026. Los merges a main por mes crecieron 3,3 veces. ×1 ×3 ×10 ×30 ×100 Ene Feb Mar Abr May Jun Jul Ago Sep Ene: ×1.0 Feb: ×2.2 Mar: ×3.4 Abr: ×6.1 May: ×8.1 Jun: ×26.2 Jul: ×33.0 Ago: ×51.2 Sep: ×74.1 Casos de test ×74 Ene: ×1.0 Feb: ×0.8 Mar: ×1.0 Abr: ×1.1 May: ×1.2 Jun: ×1.2 Jul: ×1.5 Ago: ×2.6 Sep: ×3.3 Merges / mes ×3,3
Escala logarítmica, enero = ×1. Los casos de test son los bloques it/test del repositorio a fin de cada mes; los merges son los commits squash en main. El salto de junio es una campaña deliberada de cobertura de tests en todo el código.

La segunda línea es la que importa para el CI. Los agentes son buenos escribiendo tests. Cuando les pedís un fix, escriben un test que reproduce el bug. Cuando les pedís una funcionalidad, cubren los casos borde que una persona dejaría para después. Es exactamente lo que queremos, y hace que cada corrida de CI sea más larga que la anterior. Una suite que corre una vez por cambio es una inversión. Una suite que corre cada vez que un vecino mergea es un impuesto.

El impuesto: mantenerse al día con main

Con la regla de "exigir ramas al día", un pull request en verde deja de estarlo en el momento en que se mergea cualquier otra cosa. Alguien, o algún agente, aprieta "Update branch", la rama recibe un commit de merge desde main y todo el pipeline vuelve a correr: lint, tipos, build, los tests unitarios, las suites que usan base de datos y, en nuestro caso, un entorno de preview que se vuelve a desplegar para ese PR.

Miramos los pull requests abiertos después de nuestra última limpieza de CI (los drafts solo corren los chequeos rápidos) y mergeados antes de la cola:

  • El 92 % se actualizó con main al menos una vez, y en promedio casi tres veces.
  • Esas actualizaciones eran el 51 % de los minutos de CI de un pull request.
  • Cada actualización también volvía a desplegar el entorno de preview del PR: cerca de cuatro de cada diez deploys de preview los disparaba una actualización, no un cambio en el código.
En qué se iban los minutos de CI de un pull request antes de la merge queue Para pull requests abiertos menos de 4 horas, entre 4 y 24 horas y más de un día, cerca de la mitad de los minutos de CI fueron corridas disparadas por actualizar la rama con main. Abierto < 4 h Corridas por commits del PR: 50 Corridas por actualizar con main: 50 100 50 % Abierto 4–24 h Corridas por commits del PR: 75 Corridas por actualizar con main: 94 169 55 % Abierto > 24 h Corridas por commits del PR: 154 Corridas por actualizar con main: 170 324 52 % Corridas por commits del PR Corridas por actualizar con main
Minutos de CI por pull request, indexados para que un PR abierto menos de 4 horas cueste 100. Pull requests abiertos después de nuestro cambio de CI para drafts y mergeados antes de la cola. Cualquiera sea la duración, cerca de la mitad de la cuenta era volver a testear main.

La proporción rondaba la mitad sin importar cuánto tiempo quedaba abierto el pull request, y esa es la parte incómoda. Un pull request abierto más de un día cuesta más del triple que uno abierto menos de cuatro horas, y la mitad de eso sigue siendo volver a testear main.

Por qué la IA hace que esto crezca más rápido que el código

La cantidad de actualizaciones que necesita un pull request es, más o menos, la cantidad de merges que entran mientras está abierto. Ahí la cuenta se pone en contra:

  • Mergear tres veces más seguido significa que cada pull request abierto se atrasa tres veces más seguido.
  • Además, hay tres veces más pull requests abiertos que se pueden atrasar.
  • Entonces las corridas por actualización crecen más o menos con el cuadrado del ritmo de merges, no de forma lineal.
  • Y cada una de esas corridas es más larga, porque la suite no para de crecer.

Hay un efecto más, y es propio de los agentes: siguen instrucciones. Si un flujo dice "actualizá la rama antes de mergear", un agente lo va a hacer siempre, a las tres de la mañana, para una rama que nadie está esperando. En nuestro repositorio, gh pr update-branch estaba en la lista de comandos que los agentes podían correr sin preguntar.

Qué hace una merge queue en cambio

Una merge queue da vuelta el problema. En lugar de que cada pull request se mantenga al día por su cuenta, el pull request avisa "estoy listo" y GitHub hace la prueba sobre main en el momento del merge:

Cómo prueba los pull requests una merge queue Cada entrada de la cola se arma sobre main más todas las entradas que tiene adelante, el CI corre una vez sobre esa combinación y lo que se mergea es el commit probado. main Entrada 1 main + A CI corre una vez Entrada 2 main + A + B CI corre una vez Entrada 3 main + A + B + C CI corre una vez Cada entrada se arma sobre main más las entradas que tiene adelante. Lo que se mergea es exactamente el commit que se probó.
Se prueban hasta tres entradas en paralelo. Si una falla, sale de la cola y las de atrás se rearman sin ella.
  • Cada entrada de la cola es una rama temporal: main, más las entradas que tiene adelante, más este pull request.
  • El CI corre una vez sobre esa combinación. Si pasa, ese commit exacto es el que se mergea.
  • Varias entradas se prueban en paralelo, cada una suponiendo que las de adelante van a pasar. Si una falla, sale de la cola y las de atrás se rearman sin ella.

La garantía es la misma de antes, "lo que llega a main se probó tal como queda ahí", pero el costo es una corrida por pull request en lugar de una corrida por cada merge que pasó mientras estaba abierto.

La migración

El cambio en la configuración de GitHub es chico. El trabajo está en que el CI se comporte bien en una corrida que no tiene un pull request asociado. Estos fueron nuestros cambios.

1. Escuchar el evento de la cola y arreglar la concurrencia. Las corridas de la cola llegan como eventos merge_group. Nuestro grupo de concurrencia usaba el número de PR, que en una corrida de la cola está vacío: todas las entradas habrían caído en el mismo grupo y cada una habría cancelado a la de adelante.

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. Que "qué cambió" funcione en la cola. Salteamos los builds del frontend cuando un PR solo toca el backend, con un filtro por rutas. La versión que teníamos fijada no sabía qué cambiaba una entrada de la cola. La versión 4 compara la entrada contra su base (merge_group.base_sha..head_sha), así que cada entrada solo construye lo que toca.

- uses: dorny/[email protected]
  id: filter
  with:
    filters: |
      backend:
        - 'backend/**'
        - 'pnpm-lock.yaml'
      frontend:
        - 'frontend/**'
        - 'pnpm-lock.yaml'

3. Decidir qué corre la cola. En nuestro CI, los pull requests en draft solo corren los chequeos rápidos. Una corrida de la cola no tiene la marca de draft, así que la condición que usamos da "no es draft" y la cola siempre corre la suite completa. Es lo que queremos al mergear. Los deploys de preview, en cambio, no escuchan la cola: una rama temporal de la cola no necesita preview.

4. Activarla. Pasamos el requisito a un ruleset del repositorio: el check que resume el CI es obligatorio, "las ramas tienen que estar al día" queda apagado y la cola, encendida.

{
  "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
  }
}

Mantuvimos la protección de rama clásica, que es la que sostiene la evidencia de nuestros controles de compliance (revisión de code owners, sin bypass), y solo le apagamos el requisito de "al día".

Antes de activarla en main la ensayamos en una rama descartable, con su propio ruleset y algunos pull requests de prueba. Dos cosas de ese ensayo valían la pena saberlas:

  • Cuando una entrada falla, GitHub rearma las de atrás en menos de un minuto, pero no cancela sus corridas viejas. Esas siguen hasta terminar, y las pagás.
  • Entonces un test inestable deja de ser solo molesto: saca un pull request de la cola y le cuesta una ronda de corridas huérfanas a todos los de atrás. Arreglamos los dos tests más inestables que conocíamos en el mismo momento en que activamos la cola.

La primera semana

Comparamos pull requests de duración parecida, porque los que se abrieron desde que arrancó la cola son, por definición, recientes y cortos.

  • Menos actualizaciones. Los pull requests abiertos menos de 4 horas que se actualizaron con main pasaron del 82 % al 27 %. Los abiertos entre 4 y 24 horas, del 98 % al 40 %.
  • Menos redeploys de preview. Los previews disparados por una actualización bajaron entre un 55 % y un 62 % por pull request.
  • La cola cuesta algo. Cada pull request ahora paga una corrida en la cola, y siempre es una corrida completa de toda la suite.
  • El neto, por ahora. Para pull requests abiertos menos de 4 horas, mantenerse al día con main cuesta más o menos lo mismo que antes (+6 %). Para los abiertos entre 4 y 24 horas, cuesta 22 % menos. Para los más largos, todavía es pronto.
  • Los tests inestables se notan. El 9 % de las entradas de la cola fallaron y salieron de la cola.
Minutos para mantener un pull request al día con main, antes y después de la cola Antes de la cola, mantenerse al día eran las actualizaciones de la rama. Después, son las actualizaciones que quedan más una corrida de la cola. Los PRs cortos cuestan más o menos lo mismo; los abiertos entre 4 y 24 horas gastan 22 % menos. Todavía no hay datos de PRs abiertos más de un día. Abierto < 4 h Antes: actualizaciones: 100 100 Después: actualizaciones: 22 Después: corrida de la cola: 84 106 (+6 %) Abierto 4–24 h Antes: actualizaciones: 190 190 Después: actualizaciones: 62 Después: corrida de la cola: 87 149 (−22 %) Abierto > 24 h Antes: actualizaciones: 342 342 Después: todavía es pronto para medir Antes: actualizaciones Después: actualizaciones Después: corrida de la cola
Índice: minutos de actualización de un PR abierto menos de 4 horas antes de la cola = 100. "Después" son pull requests abiertos y mergeados en los primeros días de la cola, una muestra chica. La corrida de la cola cuesta más o menos lo mismo para cada PR; las actualizaciones que reemplaza crecen con el tiempo que el PR queda abierto.

Es un titular más chico que "cortamos el CI a la mitad", y es el honesto. La razón se ve en el gráfico: la corrida de la cola es un costo fijo por pull request, mientras que las actualizaciones que reemplaza crecen con el tiempo que el pull request queda abierto y con lo seguido que se mueve main. Para un pull request abierto menos de cuatro horas hay poco para reemplazar. Para uno abierto un día o más, hay mucho.

La cola elimina la necesidad, no la costumbre

La otra razón por la que el ahorro todavía es chico: cerca de un tercio de los pull requests nuevos se siguieron actualizando con main. Algunos eran conflictos reales. El resto era reflejo: personas apretando un botón que aprietan hace años, y agentes que todavía tenían gh pr update-branch entre sus permisos.

Así que también cambiamos las instrucciones. El archivo que leen nuestros agentes antes de trabajar en el repositorio ahora lo dice sin vueltas:

### 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`).

Y saqué gh pr update-branch de la lista de comandos que mis agentes pueden correr sin preguntar. Si las actualizaciones solo pasan por conflictos reales, mantenerse al día con main pasa a ser una corrida de la cola por pull request, sin importar cuánto dure: con nuestros propios números, cerca de un 16 % menos que antes para los pull requests cortos, más o menos la mitad para los abiertos entre 4 y 24 horas y tres cuartos menos para los abiertos más de un día.

Si estás pensando en hacerlo

  • Medí primero el impuesto de las actualizaciones. Contá las corridas de CI cuyo commit es un merge desde tu rama principal. Si son una parte grande de tus minutos, la cola se paga sola; si tus pull requests viven una hora y los merges son pocos, puede que no.
  • Arreglá la concurrencia antes que nada. Una corrida de la cola no tiene número de pull request. Todo lo que dependa de él se rompe sin avisar.
  • Revisá cada acción que pregunta "qué cambió". Los filtros por rutas, la detección de cambios y las herramientas de proyectos afectados tienen que entender el evento de la cola.
  • Arreglá tus tests inestables. En una cola, un test inestable le cuesta a todos los que están detrás del pull request, no solo a su autor.
  • Cambiá las instrucciones, no solo la configuración. Si a tus agentes les dijeron que mantengan las ramas al día, lo van a seguir haciendo, con mucha diligencia, cuando la razón ya no exista.

Estos son números de la primera semana. Vamos a volver sobre ellos cuando haya algunas semanas de pull requests que vivieron toda su vida con la cola, incluidos los largos, que es donde debería estar la mayor parte del ahorro.