Nombramos la rama. El commit acabó en main.
En un repositorio GitOps, main es producción. Una noche de octubre llegaron allí manifiestos que nadie había aprobado, con una etiqueta de imagen que no existía, sin que nadie hubiera escrito «main». Dos ajustes de Git, razonables cada uno, y un comando correcto en cualquier otro sitio.
En un repositorio GitOps no hay botón de «desplegar». Argo CD lee la rama main de k3s-stack, nuestro repositorio de infraestructura (privado), y hace converger el clúster hacia lo que contiene. Empujar a main es poner en producción. Ese es todo el interés del modelo, y por eso todo pasa por una rama, se revisa y luego se fusiona.
El 19 de octubre a la 01:00, un commit llegó directamente a main: los manifiestos del pequeño servicio que muestra al lector el intercambio de claves que su navegador acaba de negociar. Nadie los había aprobado. Y su imagen llevaba una etiqueta provisional, escrita a la espera de que la CI construyera la real:
images:
- name: ghcr.io/mesa-black/handshake
newTag: TO-BE-SET-AFTER-THE-FIRST-BUILD
Argo CD hizo exactamente lo que se le pide: desplegó. El pod se quedó en ImagePullBackOff nueve minutos, hasta que la CI escribió la etiqueta real a las 23:09 UTC. Nada grave esta vez: un servicio nuevo al que nadie llamaba todavía. Pero nadie había escrito git push origin main.
El comando que se escribió
La rama se creó en un worktree, a partir de la rama remota, y luego se empujó nombrándola explícitamente. En esencia:
git worktree add -b feat/handshake ../k3s-handshake origin/HEAD
# … commit …
git push -u origin feat/handshake
Lo escribió el agente de código con el que trabajamos, y es el comando correcto en casi todas las configuraciones. No lo era en la nuestra, porque se cruzan dos ajustes.
El primero es un valor por defecto de Git. Una rama creada a partir de una rama remota (origin/main, u origin/HEAD, que apunta a ella) la sigue automáticamente: es branch.autoSetupMerge, activado por defecto. Nuestra feat/handshake tenía, pues, main como rama de seguimiento, sin que nadie lo pidiera.
El segundo es una elección, en nuestro ~/.gitconfig:
[push]
default = upstream
Con upstream, git push envía la rama actual a su rama de seguimiento. Es un ajuste legítimo, pensado para flujos en los que una rama local tiene otro nombre que la rama a la que alimenta. Con una rama que sigue a main, empuja a main.
Hasta aquí, nada sorprendente: es lo que dice la documentación para git push sin argumentos. La sorpresa está en otro sitio.
Incluso nombrando la rama
Esperábamos que nombrar la rama, git push -u origin feat/handshake, se impusiera al ajuste. No es así. Lo reprodujimos en un repositorio desechable, con Git 2.54, creando cada vez una rama desde origin/main:
push.default |
git push |
git push -u origin mi-rama |
|---|---|---|
simple, el valor por defecto de Git |
se niega, y explica por qué | crea mi-rama |
current |
crea la rama | crea mi-rama |
upstream |
empuja a main | empuja a main |
Con upstream, un destino ausente del comando se sustituye por la rama de seguimiento, incluso cuando se ha escrito el nombre de la rama de origen. El nombre escrito indica qué se envía, no adónde llega. Solo un destino escrito por completo cambia el resultado:
git push -u origin HEAD:refs/heads/feat/handshake # sí crea la rama
El script, para comprobarlo sin nosotros:
#!/bin/sh
# Una rama creada desde origin/main, empujada nombrándola.
set -e
d=$(mktemp -d) && cd "$d"
git init -q --bare -b main remote.git
git clone -q remote.git clone 2>/dev/null && cd clone
git commit -q --allow-empty -m init && git push -q origin main
git switch -q -c mi-rama origin/main
git commit -q --allow-empty -m "mi-rama"
git -c push.default=upstream push -q -u origin mi-rama
git --git-dir=../remote.git for-each-ref --format='%(refname:short)' refs/heads
# muestra «main» sola: la rama no existe, el commit está en main
Por qué es peor en un repositorio GitOps
En un repositorio de aplicación, este push habría puesto un commit sin revisar en main. Molesto, reparable, y la CI probablemente habría protestado antes de llegar a producción.
En un repositorio GitOps no hay ningún paso intermedio. El commit es el despliegue. Sin revisión, sin fusión, y en nuestro caso ni siquiera imagen: el controlador aplicó un manifiesto que hacía referencia a una etiqueta que aún no existía. El error no se ve en Git, donde todo parece normal. Se ve en el clúster.
Y tampoco se ve en el terminal. El push tiene éxito, sin aviso. La salida sí menciona -> main, en una línea que nadie lee tras un push que ha funcionado.
Lo que cambiamos
Tres reglas, ahora escritas en las instrucciones del agente tanto como en las nuestras:
- Crear las ramas desde
mainlocal, no desdeorigin/main. Una rama creada desde una rama local no sigue nada:git worktree add -b feat/x ../x main, o--no-tracksi se parte de todos modos de la remota. - Empujar con un destino explícito:
git push -u origin HEAD:refs/heads/feat/x. Es más largo, y es la única forma que da el mismo resultado diga lo que digapush.default. - Comprobar
origin/maindespués de cada push de rama:git log origin/main -1. Si el último commit es el nuestro, ya está en producción.
No cambiamos push.default. El ajuste sirve en otros sitios, y cambiarlo solo habría arreglado nuestro equipo. La regla protege también al agente, que comparte la misma configuración.
Lo que todavía falta
La verdadera barrera no está en el puesto de trabajo. Está en el servidor: una regla de GitHub que rechace los push directos a main y exija una pull request, con una excepción para la clave de despliegue de la CI, que escribe las etiquetas de imagen.
Hoy, la rama main de k3s-stack no está protegida: ninguna regla sobre la rama. Todo lo anterior depende, pues, de la disciplina de quien empuja, humano o agente. Es lo próximo que hay que corregir, y lo escribimos aquí para que no se quede en una intención.
Lo que dio de sí
- Un push a
main, a la 01:00, que ningún comando había pedido en esos términos. - Nueve minutos de un pod en
ImagePullBackOffen producción, en un servicio al que nadie llamaba todavía. - Dos ajustes implicados:
branch.autoSetupMerge, por defecto, ypush.default = upstream, por elección. - Un descubrimiento: con
upstream, nombrar la rama no cambia el destino. - Tres reglas de trabajo, y una protección de rama todavía por poner.
Buenas prácticas
- En un repositorio donde
maines producción, protegermainen el servidor antes que nada. Una consigna protege a quien la conoce; una regla en el servidor protege a todos. - Escribir el destino de un push completo (
HEAD:refs/heads/…) en los scripts y en las instrucciones de un agente: es la única forma independiente de la configuración local. - Crear las ramas de trabajo desde la rama local, para que no sigan nada por accidente.
- Después de un push, leer la línea que dice adónde llegó, o comprobar la rama remota.
Puntos de atención
- Un comando correcto solo lo es en una configuración.
git push -u origin mi-ramaes correcto casi en todas partes. Un agente que lo aprendió en otro sitio lo escribirá en tu equipo, con tu~/.gitconfig. - Un ajuste heredado no aparece en ninguna revisión.
push.default = upstreamvive en la configuración global, no en el repositorio. Nada lo señala hasta que ha producido su efecto. - Que un push tenga éxito no dice adónde llegó. Git no avisa cuando el destino difiere del nombre escrito. La única huella es la línea
-> main. - Quien responde es quien tiene la configuración. El agente escribió el comando, pero fue nuestro equipo el que lo envió a
main. Lo escribíamos hace unos días: un agente puede hacerlo todo, no puede responder de lo que hizo.