Un repositorio. Varios agentes de código. Una branch que tiene que quedar siempre lista para deploy.
Ese es todo el setup, y es más raro de lo que suena. Los agentes corren en worktrees paralelos — uno por pull request — y cada uno puede commitear, firmar, pushear, rebasear y, si decide que los checks locales le estorban, saltearlos. Son rápidos. Aciertan casi siempre. Y el “casi” es el problema entero: la falla para la que diseñás no es el agente que se cae, es el que está equivocado con toda confianza a las 3 de la mañana, sin nadie mirando.
Entonces vas a los git hooks
. El pre-commit lintea. El pre-push corre el build. El commit-msg exige el formato. Por un rato se siente como gobernanza. No lo es. Es consejo — y cualquier cosa que corra git puede rechazar un consejo.
El hook que podés saltear no es un gate
La descripción honesta de un git hook: un script que el cliente corre en tu nombre, cuando tiene ganas. --no-verify saltea pre-commit y commit-msg con un solo flag. LEFTHOOK_EXCLUDE=lint apaga un comando puntual. Borrá .git/hooks/ y desaparece todo hook; el repositorio ni se entera. Un agente al que le dijeron una sola vez que “el eval del pre-push es lento” encuentra --no-verify por su cuenta. Un gate que el actor puede descartar con un gesto no es un gate — es una sugerencia con buenas intenciones.
Las restricciones del dev solo afilan el borde. No hay un segundo humano que revise: no podés aprobar tu propio pull request, y no hay nadie más que lo haga. El loop local tiene presupuesto de latencia — si el barrido de pre-commit cuesta diez segundos, los agentes lo esquivan, y un check que nadie corre no protege nada. Y lo que está bajo guardia es una branch que se deploya: un push malo es un evento de producción, no un orgullo lastimado. Vi la falla exacta: un fix estructural varado en un cambio de branch, descartado en silencio, el build siguiente sin él y sin avisar. Ningún error. Desapareció, nomás.
El Y-statement que firmé:
En el contexto de un dev solo corriendo varios agentes de código contra una única branch que se deploya, enfrentando actores capaces de saltear cualquier check local y sin un segundo humano que ataje el error, decidimos partir la imposición en hooks client-side consultivos por latencia y un ruleset server-side vinculante por integridad, para lograr una branch siempre lista para release sin frenar el inner loop, aceptando que los hooks son teatro salvo que el servidor imponga los mismos invariantes por debajo.
Dos capas, dos trabajos
La división no es redundancia. Es división del trabajo: los hooks compran velocidad, el ruleset compra la garantía. Ninguno hace el trabajo del otro, y es confundiéndolos que un repositorio termina “protegido” por un script que cada contribuidor ya aprendió a saltear.
Del lado del cliente, los hooks son un consejo que de verdad querés seguir. lefthook
despliega el barrido de pre-commit en paralelo — forma de conventional commit, escaneo de secretos, formateador, el lint propio del proyecto — y limpia un árbol de 258 archivos en 0.19s, un commit staged de uno a tres archivos en sub-50ms. Lo bastante rápido para que nadie lo resienta, que es la única razón por la que sobrevive. Un hook de pre-push corre el check caro — un eval cross-host, 5-8s en caliente, 30-60s después de un bump de dependencias — y rechaza los dos movimientos que más cuestan deshacer: el push directo a main y el force-push que no es descendiente fast-forward. Cuando la historia sí se reescribe, un hook de post-commit ya espejó el tip viejo a refs/backup/<host>/<branch>, y el reflog retiene los commits descartados por 180d. Consejo, con red debajo.
Del lado del servidor, el ruleset es la ley. No “debería”, no “por favor” — el servidor rechaza el push. Un ruleset de repositorio de GitHub
codifica los invariantes que no se doblan: required_status_checks con strict up-to-date, required_linear_history, required_signatures
, non_fast_forward y prohibición de borrado, resolución de threads de review y — la línea que se lee como sentencia — bypass_actors: []. Nadie la saltea. Admins incluidos.
La fitness function no es el ruleset. Es el chequeo de que el ruleset sigue vigente:
# fitness gate — the law must still be enforced, not merely written down.
# Run it in CI: a ruleset that quietly flips to disabled is a hook nobody runs,
# except this failure mode you can actually detect.
gh api "repos/$REPO/rulesets/$RULESET_ID" --jq '
(.enforcement == "active")
and (.bypass_actors | length == 0) # nobody bypasses, admins included
and (.rules | map(.type) | contains([
"required_signatures",
"non_fast_forward",
"required_linear_history",
"pull_request",
"required_status_checks"
]))
' | grep -qx true || { echo "ruleset drifted — main is unguarded"; exit 1; }Una salvedad que la doc entierra: el required_status_checks del ruleset solo puede nombrar jobs que tu CI realmente produce. Listá un check que nunca corre y el ruleset deja a main trabada detrás de un status pendiente para siempre — impusiste un deadlock, no una política.
Las decisiones, con su precio
Hooks consultivos, nunca obligatorios. Costo: se pueden saltear, por construcción. Beneficio: nunca te dejan afuera de tu propio repositorio en medio de un incidente, y se mantienen lo bastante rápidos como para conservarlos. Reconsiderar: nunca — mientras el servidor imponga la misma regla. En el instante en que un hook es lo único entre un agente y main, ya falló; solo que todavía no te llegó la factura.
Un ruleset, no las branch-protection rules legadas. Los rulesets se apilan, se reutilizan a lo largo de la org y hacen explícita la lista de bypass. bypass_actors: [] es prosa auditable. La UI vieja de branch protection escondía el “los admins pueden pasar” en un checkbox que nadie vuelve a leer — exactamente ahí vive el próximo incidente.
Cero aprobaciones requeridas. Suena mal hasta que lo decís en voz alta: un dev solo no puede aprobar su propio pull request, así que una regla que exige una aprobación humana es una regla que exige que no mergees nunca. El revisor pasa a ser el required status check, no una persona — 0 aprobaciones antes que 1. Costo: sin firma humana. Beneficio: el merge ocurre de verdad, custodiado por un check que no duerme, no se toma vacaciones ni sella en automático a las 3 de la mañana.
El anti-patrón cruza las tres decisiones, y tiene nombre: cultura del --no-verify — tratar el hook local como el gate y después sorprenderse cuando algo lo rodea. Su primo es el bypass de admin, esa entrada en bypass_actors que agregás “solo por esta vez” durante un incidente y no sacás nunca. Las dos corren la frontera real del servidor hacia un hábito, en silencio. Un hábito no sobrevive una noche de cansancio; un rechazo del lado del servidor, sí.
Lo que el hook no puede prometer
Los hooks mienten, estructuralmente — no por malicia, sino porque corren en el cliente, y el cliente es justo lo que estás tratando de restringir. Un commit firmado prueba quién lo escribió. No prueba que el pre-commit corrió. Solo el servidor, rechazando el push, prueba algo que el autor no podría haber falsificado — y esa es la razón entera por la que el servidor tiene que sostener los invariantes que importan.
Que es también el motivo por el que la imposición que pelea contra el operario termina borrada. Una vez corrí un guard de pre-tool que bloqueaba a un agente de cambiar de branch dentro de un worktree; atajaba errores reales y también bloqueaba trabajo legítimo, así que se fue, y la disciplina que imponía pasó a regla escrita. Esa es la lección de verdad, y corta contra el reflejo de agregar más hooks: un guard local lo bastante hostil como para que valga la pena saltearlo va a ser salteado — o removido por la persona a la que frena, que sos vos. Poné en el servidor los invariantes que no pueden doblarse, donde doblarlos no está en el menú, y mantené la capa local lo bastante útil como para que nadie quiera apagarla.
¿Cuándo es demasiado? Un repositorio con un humano cuidadoso y cero agentes no necesita la ceremonia — un hook de pre-commit y una branch protection simple alcanzan. La defense-in-depth se paga exactamente cuando el actor del que te cuidás es rápido, numeroso y cada tanto se equivoca a una hora en la que ningún revisor está despierto para atajarlo. Ese es el cuarto en el que te mete una flota de agentes.
Los hooks hacen rápido el camino bueno. El ruleset hace imposible el camino malo. Solo uno de los dos es negociable — y no es el que vive en el servidor.
Stack
- Hooks locales: lefthook
con fan-out paralelo del pre-commit + un puñado de git hooks declarativos (guard de pre-push, espejo de backup en
post-commit,prepare-commit-msg) - Formato de commit: Conventional Commits — obligado por
commit-msgen local y re-chequeado como required status check en CI, para que el pase local nunca cargue el peso - Firma: commits firmados por SSH, impuestos del lado del servidor vía
required_signatures - Ley del servidor: ruleset de repositorio de GitHub —
enforcement: active,bypass_actors: [], required status checks (strict up-to-date), historia lineal requerida, sin force-push, sin borrado, resolución de threads de review,0aprobaciones requeridas - Recuperación: espejo
refs/backup/<host>/<branch>enpost-commit+ retención de reflog (180d/90dpara objetos inalcanzables) - CI: los required checks que son el gate de merge de verdad — lint y formato, salud de dependencias, eval por host