sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
fix(granja): el publicador avisaba 48 veces al día de algo que no pasaba
1#!/usr/bin/env bash
2# publicar-estado.sh — publica `docs/state/` en origin/main SIN depender del árbol ni de la rama local.
3#
4# ══ POR QUÉ EXISTE ════════════════════════════════════════════════════════════════════════════
5# El latido commiteaba en la rama local y empujaba. Eso ata la publicación a DOS estados que el
6# latido no controla y que en una caja compartida se ensucian solos:
7#
8# 1. **el árbol de trabajo** — `git pull --rebase` (el plan B de `git-sincronizar.sh`) se NIEGA
9# con un fichero modificado, aunque no tenga nada que ver con `docs/state/`.
10# 2. **la rama local** — una vez divergida, `git pull --ff-only` no puede volver nunca, y el
11# `--ff-only` es a propósito (regla 2 ter: acá un `pull --rebase` se llevó un commit).
12#
13# MEDIDO el 2026-10-07, que es lo que lo motiva: `/opt/takana` llevaba **580 commits locales y
14# 12 días sin publicar**. Causa: un `scripts/servidor/claude-caja.sh` modificado —y que resultó
15# ser BYTE A BYTE lo que ya estaba en origin, o sea trabajo duplicado de otro clon— más la rama
16# divergida. El latido latía cada 30 min, decía «NO pude publicar … queda local», y nadie lo leía.
17# Los 580 commits contenían, en total, **cuatro líneas de timestamp**.
18#
19# ⇒ Este guion no commitea en la rama local y no lee el árbol a través de git: toma los ficheros
20# del disco, los mete en un índice TEMPORAL sobre el `origin/main` recién traído, y empuja el
21# commit resultante directo a `refs/heads/main`. No hay árbol que ensuciar ni rama que divergir.
22# El índice compartido tampoco se toca (regla 2: `git commit` commitea el índice ENTERO).
23#
24# ⚠ **No borra la huella del timestamp, y es deliberado.** Los ficheros de vigía llevan
25# `medido <ISO>` en la primera línea para que cada ciclo deje rastro en el `git log`: con el frente
26# en verde el texto es constante, y un fichero que nadie refresca envejece hacia el optimismo. Esa
27# decisión está razonada en `cosecha-cron.sh` y acá se respeta — lo que se arregla es el mecanismo
28# de publicar, no la huella.
29#
30# Uso: scripts/farm/publicar-estado.sh --message "<texto>" [--dry-run] [--remote origin] [-- <ficheros>]
31# Sin `-- <ficheros>` usa la lista por defecto de más abajo.
32# La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano.
33set -uo pipefail
34
35ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
36cd "$ROOT"
37
38REMOTE="origin"
39MENSAJE=""
40DRY=0
41INTENTOS="${INTENTOS:-5}"
42
43# Lista por defecto: los ficheros de estado que el latido regenera cada ciclo. Vive acá y no en el
44# llamador para que publicar a mano empuje EXACTAMENTE lo mismo que el cron.
45FICHEROS=(
46 docs/state/subcomandos.txt
47 docs/state/raices.txt
48 docs/state/sonames.txt
49 docs/state/servicios.txt
50 docs/state/build-state.json
51 docs/state/build-state-kde.json
52 docs/state/build-state-gnome.json
53 docs/state/build-state-cosmic.json
54 docs/state/build-state-wlr.json
55 docs/state/drenaje.json
56 docs/state/fuentes-vigia.json
57 docs/state/upstream.json
58 docs/state/kernel-contract.txt
59 docs/state/static-audit.txt
60 docs/state/keystones.json
61 docs/state/duplicados.json
62)
63
64while [ $# -gt 0 ]; do
65 case "$1" in
66 --message) MENSAJE="${2:-}"; shift 2 ;;
67 --remote) REMOTE="${2:-origin}"; shift 2 ;;
68 --dry-run) DRY=1; shift ;;
69 --) shift; FICHEROS=("$@"); break ;;
70 *) echo "publicar-estado: opción desconocida '$1'" >&2; exit 2 ;;
71 esac
72done
73[ -n "$MENSAJE" ] || { echo "publicar-estado: falta --message" >&2; exit 2; }
74
75# ── El empujón, en plumbing ───────────────────────────────────────────────────────────────────
76# Se reintenta porque el único modo de falla esperable es que OTRO empuje entre el `fetch` y el
77# `push`. Eso no es un error: es la carrera normal de un repo compartido, y la respuesta correcta
78# es reconstruir el commit sobre la cabeza nueva, no forzar. `--force` nunca aparece acá: pisaría
79# el trabajo del otro, que es justo lo que el latido no debe poder hacer ni por accidente.
80intento=0
81while :; do
82 intento=$((intento + 1))
83
84 # El refspec explícito `+main:refs/remotes/<remoto>/main` no es adorno: con `git fetch <remoto>
85 # main` a secas el ref de SEGUIMIENTO puede quedar rancio, y entonces el `git log origin/main..HEAD`
86 # que este guion le sugiere a un humano miente justo cuando se va a usar para diagnosticar. El `+`
87 # fuerza el ref de seguimiento, que no es trabajo local: se puede pisar sin perder nada.
88 git fetch -q "$REMOTE" "+main:refs/remotes/$REMOTE/main" \
89 || { echo "!! no pude traer $REMOTE/main"; exit 1; }
90 base="$(git rev-parse "refs/remotes/$REMOTE/main")"
91
92 # Índice temporal sembrado con el árbol de origin/main. `GIT_INDEX_FILE` lo aísla del índice
93 # compartido del repo, que es el que arrastra el trabajo a medias de otro agente.
94 idx="$(mktemp "$ROOT/work/.publicar-estado.idx.XXXXXX")"
95 trap 'rm -f "$idx"' EXIT
96 GIT_INDEX_FILE="$idx" git read-tree "$base" || { echo "!! read-tree falló"; exit 1; }
97
98 puestos=0
99 for f in "${FICHEROS[@]}"; do
100 # Un fichero AUSENTE se salta en silencio, pero un fichero VACÍO también: regla 3 de CLAUDE.md
101 # —un vacío llega hasta el final diciendo que todo fue bien—. Si un vigía murió a mitad y dejó
102 # 0 bytes, publicar eso borraría el veredicto anterior y el khipu diría «verde» por un hueco.
103 [ -s "$f" ] || { [ -e "$f" ] && echo " ⚠ $f existe pero está VACÍO — no lo publico"; continue; }
104 modo=100644
105 [ -x "$f" ] && modo=100755
106 blob="$(git hash-object -w -- "$f")" || { echo "!! hash-object falló en $f"; exit 1; }
107 GIT_INDEX_FILE="$idx" git update-index --add --cacheinfo "$modo,$blob,$f" \
108 || { echo "!! update-index falló en $f"; exit 1; }
109 puestos=$((puestos + 1))
110 done
111 [ "$puestos" -gt 0 ] || { echo "==> no hay ni un fichero de estado con contenido — nada que publicar"; exit 0; }
112
113 tree="$(GIT_INDEX_FILE="$idx" git write-tree)" || { echo "!! write-tree falló"; exit 1; }
114 rm -f "$idx"; trap - EXIT
115
116 if [ "$tree" = "$(git rev-parse "$base^{tree}")" ]; then
117 echo "==> sin novedad: el estado del disco es idéntico a $REMOTE/main"
118 exit 0
119 fi
120
121 commit="$(git commit-tree "$tree" -p "$base" -m "$MENSAJE")" \
122 || { echo "!! commit-tree falló"; exit 1; }
123
124 if [ "$DRY" = 1 ]; then
125 echo "==> [dry-run] publicaría ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
126 git diff --stat "$base" "$commit" | sed 's/^/ /'
127 exit 0
128 fi
129
130 # `<sha>:refs/heads/main` con el padre siendo la cabeza recién traída ⇒ es un fast-forward para
131 # el remoto. Si el remoto se movió, lo RECHAZA (no hay lease que valga: el rechazo ES la señal).
132 #
133 # Se empuja a la URL CANÓNICA explícitamente, no a `$REMOTE` a secas, y los espejos van aparte en
134 # un segundo paso. `git push <remoto>` con varias `pushurl` las empuja TODAS y devuelve != 0 si
135 # cualquiera falla: con eso un espejo caído se ve igual que una publicación fallida, el reintento
136 # vuelve a traer (ya con el canónico al día), calcula «sin novedad» y SALE 0. O sea que el fallo
137 # del espejo quedaría tapado por un éxito. Separarlos es lo que permite decir qué lado quedó atrás
138 # — que es la regla de este repo: comprobar los dos lados, no la salida del push.
139 CANON="$(git config --get remote.$REMOTE.url)"
140 if git push -q "$CANON" "$commit:refs/heads/main" 2>/dev/null; then
141 echo "==> estado publicado: ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
142 # El ref de seguimiento al día CON LO QUE ACABAMOS DE EMPUJAR. Sin esto queda un ciclo atrás por
143 # construcción —se trae antes de empujar— y el `git log origin/main..HEAD` del final mentiría
144 # siempre. Es un ref de seguimiento, no trabajo local: se escribe sin riesgo.
145 git update-ref "refs/remotes/$REMOTE/main" "$commit" 2>/dev/null || true
146 # ── ESPEJOS ───────────────────────────────────────────────────────────────────────────────
147 # Un espejo que falla NO invalida lo publicado, así que no cambia el código de salida; pero se
148 # grita, porque el modo de falla conocido es que el espejo DERIVE EN SILENCIO durante días
149 # (CLAUDE.md regla 2). Si no hay ninguno configurado se dice también: un espejo ausente y un
150 # espejo roto se ven igual desde el log, y no son lo mismo.
151 espejos=0
152 for url in $(git remote get-url --push --all "$REMOTE" 2>/dev/null); do
153 [ "$url" = "$CANON" ] && continue
154 espejos=$((espejos + 1))
155 git push -q "$url" "$commit:refs/heads/main" 2>/dev/null \
156 && echo " espejo al día: $url" \
157 || echo " ⚠ EL ESPEJO QUEDÓ ATRÁS: $url — lo publicado está en el canónico, el espejo no"
158 done
159 [ "$espejos" -gt 0 ] || echo " (este clon no tiene espejo configurado — ver scripts/espejo-setup.sh)"
160 # ── ADELANTAR LA RAMA LOCAL ───────────────────────────────────────────────────────────────
161 # Lo publicado NO depende de esto: si no se puede, no es un fallo. Pero tampoco se avisa a lo
162 # bruto, porque este guion corre 48 veces al día y un ⚠ en cada ciclo deja de leerse — la misma
163 # lección que `espejo-setup.sh` acababa de aprender gritando «gh NO autenticado» con el push
164 # funcionando.
165 #
166 # ⚠ EL CASO NORMAL ES QUE EL `merge --ff-only` FALLE, Y NO INDICA NADA MALO. El generador acaba
167 # de reescribir `docs/state/` en el árbol y eso es justo lo que se publicó, así que el árbol ya
168 # tiene EL MISMO CONTENIDO que el commit nuevo. git igual se niega («local changes would be
169 # overwritten») porque su comprobación mira ÍNDICE contra HEAD, no árbol contra destino.
170 if ! git merge-base --is-ancestor HEAD "$commit" 2>/dev/null; then
171 echo " ⚠ la rama local está DIVERGIDA de $REMOTE/main — publicado igual; mirá: git log $REMOTE/main..HEAD"
172 elif git merge --ff-only -q "$commit" 2>/dev/null; then
173 echo " rama local al día"
174 # Si el árbol YA coincide con el destino en todo lo que el ff tocaría, el adelanto es un no-op
175 # en disco y se puede hacer moviendo la rama. Se hace con `reset --mixed`, que deja el árbol
176 # intacto y reescribe el índice —y por eso va GUARDADO por «el índice no tiene nada»: el índice
177 # es estado COMPARTIDO (regla 2) y pisar lo que otro agente dejó en `git add` es exactamente el
178 # daño que este repo ya documentó. Si hay algo stageado, no se toca y se dice.
179 elif git diff --cached --quiet HEAD 2>/dev/null \
180 && git diff --quiet "$commit" -- $(git diff --name-only HEAD "$commit") 2>/dev/null; then
181 git reset --mixed -q "$commit" \
182 && echo " rama local al día (el árbol ya tenía el contenido publicado)" \
183 || echo " la rama local queda detrás — lo publicado NO depende de eso"
184 else
185 echo " la rama local queda detrás (hay cambios propios en el árbol) — lo publicado NO depende de eso"
186 fi
187 exit 0
188 fi
189
190 if [ "$intento" -ge "$INTENTOS" ]; then
191 echo "!! $INTENTOS intentos y el push sigue rechazado — alguien empuja más rápido de lo que reconstruyo"
192 exit 1
193 fi
194 echo " otro empujó primero (intento $intento/$INTENTOS) — reconstruyo sobre la cabeza nueva"
195 sleep $((intento * 2))
196done