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:
mainya no era producción. Cosas que estaban en producción no estaban enmain, y cosas enmainno estaban desplegadas. Hubo que revertirmainal estado estable de dos semanas atrás para recuperar una referencia confiable.
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 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/maincada 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. -
mainvuelve a ser producción, porque ya nada se mergea "hacia" QA: QA se deriva demain, 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:
# 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.