bsanchez@codesan.dev:~/blog$ _
EN
~$ cat qa-composicion-desechable.md

QA dejó de ser una rama: el ambiente de pruebas como archivo de texto

Llegué a un proyecto donde la pregunta "¿qué hay en QA ahora mismo?" no tenía respuesta. No una respuesta incompleta: no tenía respuesta. Había que leer el historial de una rama con cientos de merges y adivinar.

Lo que sigue es cómo salimos de ahí. Adelanto la parte contraintuitiva: la solución no fue más disciplina, ni un documento de proceso, ni un tablero mejor cuidado. Fue quitarle a QA la propiedad de ser una rama.

El síntoma: main había dejado de ser producción

El contexto, en lo que importa para el problema: una aplicación web con un equipo de varias personas, releases frecuentes y un cliente que aprueba los cambios antes de que salgan. Tres entornos: producción, un QA que ve el cliente, y las ramas de cada quien.

El esquema era el de siempre, el que todos hemos usado: una rama qa de larga vida. Mergeas tu feature ahí, el cliente la revisa, y cuando aprueba la mergeas a main. Simple y perfectamente razonable.

Salvo que, después de un año, el inventario real era este:

  • Features aprobadas que llevaban seis meses en QA sin llegar a producción, porque el cliente las aprobó pero nadie cerró el ciclo.
  • Features rechazadas que seguían en QA, porque sacarlas significaba revertir un merge en medio de otros cincuenta.
  • Conflictos entre dos features resueltos dentro de QA. Esa resolución no existía en ninguna otra parte: no estaba revisada, no estaba en ningún PR, y no iba a llegar a producción con las features.
  • Y lo que lo volvió urgente: main ya no era producción. Cosas que estaban en producción no estaban en main, y cosas en main no estaban desplegadas. Hubo que revertir main al estado estable de dos semanas atrás para recuperar una referencia confiable.
EL VERDADERO COSTO

Cuando main deja de ser igual a producción, pierdes la capacidad de responder la única pregunta que importa en un incidente: ¿qué está corriendo ahora? Todo lo demás — rollbacks, hotfixes, auditoría — se construye encima de esa respuesta. Sin ella, cada despliegue es una apuesta.

Por qué pasó: una rama de larga vida es un log, no un estado

Tardé en ver que no era un problema de gente descuidada. Era un problema estructural, y se resume así:

Una rama de larga vida registra decisiones; no representa un estado. Cada merge a QA es una línea añadida a un log de solo-escritura. La rama no dice "estas cinco features están en pruebas": dice "esto fue pasando, en este orden, durante catorce meses". Para saber el estado actual hay que replicar mentalmente el log completo.

De ahí sale todo lo demás. Sacar una feature de QA es caro — hay que revertir un commit enterrado — así que nadie lo hace, así que QA solo crece. Y al crecer, divergir de producción deja de ser un accidente: es el comportamiento por defecto del diseño.

Visto así, pedirle disciplina al equipo era pedirle que compensara a mano un sistema que empujaba en la dirección contraria. Eso nunca gana.

La inversión: QA no se mergea, se reconstruye

El cambio de fondo es una sola frase:

LA REGLA QUE CAMBIA TODO

La rama de QA es desechable. No se le hace push nunca. En cada cambio se reconstruye desde cero como main más el merge de una lista de ramas. Lo que se commitea ahí se pierde, por diseño.

El algoritmo completo cabe en cinco líneas:

# reconstruir QA (en un worktree temporal, no en tu copia)
git switch --detach origin/main
for cada rama en la lista:
    git merge --no-ff origin/$rama
git push --force origin HEAD:qa

Parece violento y es exactamente lo contrario: es lo que vuelve QA barato. Fíjate en lo que acaba de pasar con los problemas de arriba:

  • Sacar una feature de QA ya no es revertir un merge. Es borrar una línea de una lista. La siguiente reconstrucción simplemente no la incluye.
  • QA no puede divergir de producción. Arranca desde origin/main cada vez. La deriva es matemáticamente imposible, no "desaconsejada".
  • El inventario de QA es la lista. La pregunta que no tenía respuesta ahora es un cat.
  • main vuelve a ser producción, porque ya nada se mergea "hacia" QA: QA se deriva de main, nunca al revés.

La lista vive en git, y eso no es un detalle

La primera versión que pensé guardaba la lista en variables del repositorio, editables desde la interfaz. Lo descarté por algo que resultó ser la mejor decisión del diseño: una lista editable desde una UI no tiene historial. No sabes quién metió esa feature, ni cuándo, ni por qué. Y si el ambiente de QA se rompe, no puedes hacer git log sobre la causa.

Así que la lista es un archivo de texto plano, en una rama huérfana — sin historia compartida con main, sin una línea de código del producto. Un archivo por ambiente, con el nombre del ambiente:

compose-config:qa

# QA del cliente. Una rama por línea.

integration/checkout+catalogo
feature/reportes-csv
feature/inestable@a1b2c3d  # fijada a un commit

El formato se explica solo, que era el punto. Una rama por línea, # para comentarios, líneas en blanco ignoradas. Dos decisiones que sí vale la pena justificar:

Un archivo sin entradas significa ambiente libre, y resetea la rama a main. Liberar un ambiente es borrar líneas, no un comando especial. El estado "vacío" es representable, que es justo lo que le faltaba al esquema anterior.

El pin @sha congela una feature en un commit. Nació de un caso muy concreto: una rama inestable que se quería tener en QA pero cuyos pushes rompían el ambiente para todos los demás. Con el pin, un push a esa rama no dispara reconstrucción.

Y como la lista es git, el disparador es el push. No hay botón, no hay cron, no hay sincronización que se pueda quedar atrás. Editar el archivo sin pushear no hace nada, y eso es bueno: el único momento en que el ambiente cambia es un evento auditable con autor y fecha.

Las features que chocan no se arreglan en QA

Queda un caso que el esquema anterior resolvía mal y que merece regla propia: dos features que conflictúan entre sí.

Antes se resolvía dentro de QA, y ya vimos el resultado: la resolución queda en una rama desechable, sin revisar, y nunca llega a producción. Con el esquema nuevo ni siquiera es posible — la reconstrucción fallaría en el merge y punto.

La regla es: el conflicto se resuelve en una rama integration/a+b, y esa rama se lista en QA en lugar de las dos que chocan.

# mal: la reconstrucción falla
feature/checkout
feature/catalogo

# bien: el conflicto ya viene resuelto, y es revisable
integration/checkout+catalogo

El beneficio de fondo es que la resolución del conflicto pasa a ser un artefacto de primera clase: tiene rama, tiene autor, tiene diff, se puede revisar en un PR y llega a producción junto con las features. Deja de ser un merge commit anónimo dentro de una rama que se va a borrar.


Cuatro cosas que no eran obvias

El diseño es simple. Implementarlo tuvo cuatro sorpresas que me costaron tiempo real, y son la parte de este artículo que más me hubiera gustado leer antes.

1. Un push ejecuta los workflows de la rama pusheada

El primer push a la rama de listas disparó cero ejecuciones. No falló: no pasó nada.

La razón, obvia en retrospectiva: en un evento push, GitHub Actions ejecuta los workflows que existen en la rama que se pusheó, no los de main. Mi workflow vivía en main, y la rama huérfana no tenía ni carpeta .github/. Un archivo de lista no puede dispararse solo.

La solución es poner un workflow mínimo en esa rama, que no duplique lógica: hace checkout de main y ejecuta el mismo script. La misma regla explica un segundo tropiezo — una referencia relativa a ./.github/workflows/x.yml dentro de un workflow llamado desde otra rama se resuelve contra esa otra rama, así que hay que referenciar los workflows reutilizables con su ruta absoluta y su ref: org/repo/.github/workflows/x.yml@main.

2. Un push con GITHUB_TOKEN no dispara otros workflows

Esta es política de GitHub y existe para evitar bucles infinitos: lo que empuja un workflow autenticado con el GITHUB_TOKEN por defecto no genera eventos para otros workflows.

Consecuencia práctica: la reconstrucción hace force-push a la rama de QA, pero eso no va a disparar el workflow de despliegue por más bien escrito que esté. Hay que llamarlo explícitamente desde el mismo run. Si vienes de una pipeline encadenada por eventos, este es el que te va a morder.

Del mismo set de sorpresas administrativas: si los permisos por defecto del repositorio están en read, cualquier workflow que haga push o cree un release necesita declarar permissions: contents: write. Falla al final, no al principio.

3. Compara árboles, no SHAs

Mi favorita, porque es un bug de concepto y no de código.

Después de reconstruir, quieres desplegar solo si algo cambió. El reflejo es comparar el SHA nuevo contra el anterior. No funciona: una reconstrucción produce SHAs nuevos siempre. Los merge commits son nuevos objetos, con timestamp nuevo, aunque el contenido resultante sea idéntico byte a byte.

Comparando SHAs, cada reconstrucción reporta "cambió" y despliega. Cada push a main paga un build remoto de cinco minutos para publicar exactamente lo mismo que ya estaba.

Lo que quieres comparar es el árbol: el objeto que representa el contenido, no la historia que llevó a él.

# siempre distinto: incluye historia y timestamps
git rev-parse HEAD

# igual si el contenido es igual, venga de donde venga
git rev-parse HEAD^{tree}

Dos líneas. Elimina la mayoría de los despliegues inútiles. Y es un recordatorio sano de que en git el contenido y la historia son cosas distintas, aunque uno pase el día tratándolas como una sola.

4. El lockfile se rompe tarde, en el build remoto

Mergear varias ramas que tocaron dependencias puede dejar un lockfile inconsistente con el package.json. En local no te enteras: el gestor de paquetes lo arregla solo al instalar.

Pero los builds remotos corren con CI=1, y en modo CI las instalaciones son inmutables: si el lockfile no cuadra, el build falla. No en el merge, que es donde tendría sentido — al final, cinco minutos después, en el build, con un error que no menciona la palabra "merge".

Vale la pena validar la consistencia del lockfile justo después de los merges y abortar ahí, con un mensaje que diga qué pasó. Mover un fallo de "cinco minutos y un error raro" a "diez segundos y un mensaje claro" es de las cosas más rentables que puedes hacerle a una pipeline.


Antes y después

Operación Rama de larga vida Composición desechable
Saber qué hay en QA Leer el historial y adivinar Leer un archivo
Meter una feature Merge Una línea
Sacar una feature Revertir un merge enterrado Borrar una línea
Deriva contra producción Inevitable Imposible
Conflicto entre dos features Se resuelve y se pierde Rama propia, revisable
Auditoría de cambios de ambiente Ninguna git log
¿main es producción? No Sí

Del lado de producción el esquema se vuelve casi aburrido, que es el objetivo: main es exactamente lo que está desplegado, el historial es lineal (solo squash), y a producción se llega únicamente creando un tag. Un rollback son dos pasos — revertir el despliegue y un git revert con su release de parche — porque si solo haces el primero, main vuelve a mentir y el siguiente despliegue resucita el error.

Qué me llevo

Uno: los ambientes son estados, no historiales. Este es el aprendizaje de verdad. Una rama de larga vida modela un ambiente como la suma de todo lo que pasó; un manifiesto lo modela como lo que es ahora. La segunda representación responde "¿qué hay?" en tiempo constante y permite quitar cosas sin arqueología. Si te suena a infraestructura declarativa es porque es exactamente eso, aplicado a ramas en vez de a servidores.

Dos: cuando el proceso depende de disciplina, el problema es el diseño. Pasé un rato pensando en cómo lograr que el equipo recordara sacar las features de QA. La pregunta correcta era por qué sacarlas costaba tanto. Hacer lo correcto tiene que ser lo más fácil, no lo más virtuoso; si solo funciona con todos atentos, va a fallar.

Tres: lo desechable es más confiable que lo cuidado. Contraintuitivo y cierto. La rama de QA que se destruye y se rehace en cada cambio no puede acumular deriva, estado huérfano ni arreglos sin revisar. Las mismas razones por las que preferimos contenedores inmutables a servidores con mascotas aplican tal cual a una rama de git.

Cuatro: las sorpresas estaban todas en la plataforma, no en el diseño. El esquema lo escribí en una tarde. Los días se fueron en descubrir que un push ejecuta los workflows de la rama pusheada, que un GITHUB_TOKEN no encadena eventos, y que comparar SHAs es una forma elegante de desplegar lo mismo cuarenta veces. Ninguna de las tres sale en el diagrama de arquitectura — y las tres habrían hundido la implementación en silencio.

Si tienes una rama de QA que nadie se atreve a mirar, no necesitas un proceso mejor para cuidarla. Probablemente necesitas dejar de cuidarla y aprender a tirarla a la basura todos los días.

~$ cd ..