tawasuyu

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…

⎇ main scripts/farm/publicar-estado.sh Shell · 240 lines · 14.5 KB History raw

fix(granja): el clon de la granja podía quedarse atrás para siempre, y eso ya pasó d6a94e160 · Sergio ·

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/cierres.json
59  docs/state/kernel-contract.txt
60  docs/state/static-audit.txt
61  docs/state/keystones.json
62  docs/state/duplicados.json
63)
64
65while [ $# -gt 0 ]; do
66  case "$1" in
67    --message) MENSAJE="${2:-}"; shift 2 ;;
68    --remote)  REMOTE="${2:-origin}"; shift 2 ;;
69    --dry-run) DRY=1; shift ;;
70    --) shift; FICHEROS=("$@"); break ;;
71    *) echo "publicar-estado: opción desconocida '$1'" >&2; exit 2 ;;
72  esac
73done
74[ -n "$MENSAJE" ] || { echo "publicar-estado: falta --message" >&2; exit 2; }
75
76# ── El empujón, en plumbing ───────────────────────────────────────────────────────────────────
77# Se reintenta porque el único modo de falla esperable es que OTRO empuje entre el `fetch` y el
78# `push`. Eso no es un error: es la carrera normal de un repo compartido, y la respuesta correcta
79# es reconstruir el commit sobre la cabeza nueva, no forzar. `--force` nunca aparece acá: pisaría
80# el trabajo del otro, que es justo lo que el latido no debe poder hacer ni por accidente.
81# ¿El árbol de trabajo ya tiene, byte a byte, lo que el commit destino pone en cada ruta que el
82# fast-forward tocaría? Se compara `git hash-object` del fichero EN DISCO contra el blob del commit,
83# que funciona igual para rutas seguidas y sin seguir — y una ruta que el commit tiene y el disco no
84# cuenta como diferencia, que es lo correcto: ahí el ff sí traería algo nuevo.
85_arbol_coincide_con() {
86  local destino="$1" ruta blob disco
87  for ruta in $(git diff --name-only HEAD "$destino" 2>/dev/null); do
88    blob="$(git rev-parse "$destino:$ruta" 2>/dev/null)" || return 1
89    [ -f "$ruta" ] || return 1
90    disco="$(git hash-object -- "$ruta" 2>/dev/null)" || return 1
91    [ "$blob" = "$disco" ] || return 1
92  done
93  return 0
94}
95
96intento=0
97while :; do
98  intento=$((intento + 1))
99
100  # El refspec explícito `+main:refs/remotes/<remoto>/main` no es adorno: con `git fetch <remoto>
101  # main` a secas el ref de SEGUIMIENTO puede quedar rancio, y entonces el `git log origin/main..HEAD`
102  # que este guion le sugiere a un humano miente justo cuando se va a usar para diagnosticar. El `+`
103  # fuerza el ref de seguimiento, que no es trabajo local: se puede pisar sin perder nada.
104  git fetch -q "$REMOTE" "+main:refs/remotes/$REMOTE/main" \
105    || { echo "!! no pude traer $REMOTE/main"; exit 1; }
106  base="$(git rev-parse "refs/remotes/$REMOTE/main")"
107
108  # Índice temporal sembrado con el árbol de origin/main. `GIT_INDEX_FILE` lo aísla del índice
109  # compartido del repo, que es el que arrastra el trabajo a medias de otro agente.
110  idx="$(mktemp "$ROOT/work/.publicar-estado.idx.XXXXXX")"
111  trap 'rm -f "$idx"' EXIT
112  GIT_INDEX_FILE="$idx" git read-tree "$base" || { echo "!! read-tree falló"; exit 1; }
113
114  puestos=0
115  for f in "${FICHEROS[@]}"; do
116    # Un fichero AUSENTE se salta en silencio, pero un fichero VACÍO también: regla 3 de CLAUDE.md
117    # —un vacío llega hasta el final diciendo que todo fue bien—. Si un vigía murió a mitad y dejó
118    # 0 bytes, publicar eso borraría el veredicto anterior y el khipu diría «verde» por un hueco.
119    [ -s "$f" ] || { [ -e "$f" ] && echo "   ⚠ $f existe pero está VACÍO — no lo publico"; continue; }
120    modo=100644
121    [ -x "$f" ] && modo=100755
122    blob="$(git hash-object -w -- "$f")" || { echo "!! hash-object falló en $f"; exit 1; }
123    GIT_INDEX_FILE="$idx" git update-index --add --cacheinfo "$modo,$blob,$f" \
124      || { echo "!! update-index falló en $f"; exit 1; }
125    puestos=$((puestos + 1))
126  done
127  [ "$puestos" -gt 0 ] || { echo "==> no hay ni un fichero de estado con contenido — nada que publicar"; exit 0; }
128
129  tree="$(GIT_INDEX_FILE="$idx" git write-tree)" || { echo "!! write-tree falló"; exit 1; }
130  rm -f "$idx"; trap - EXIT
131
132  if [ "$tree" = "$(git rev-parse "$base^{tree}")" ]; then
133    echo "==> sin novedad: el estado del disco es idéntico a $REMOTE/main"
134    exit 0
135  fi
136
137  commit="$(git commit-tree "$tree" -p "$base" -m "$MENSAJE")" \
138    || { echo "!! commit-tree falló"; exit 1; }
139
140  if [ "$DRY" = 1 ]; then
141    echo "==> [dry-run] publicaría ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
142    git diff --stat "$base" "$commit" | sed 's/^/    /'
143    exit 0
144  fi
145
146  # `<sha>:refs/heads/main` con el padre siendo la cabeza recién traída ⇒ es un fast-forward para
147  # el remoto. Si el remoto se movió, lo RECHAZA (no hay lease que valga: el rechazo ES la señal).
148  #
149  # Se empuja a la URL CANÓNICA explícitamente, no a `$REMOTE` a secas, y los espejos van aparte en
150  # un segundo paso. `git push <remoto>` con varias `pushurl` las empuja TODAS y devuelve != 0 si
151  # cualquiera falla: con eso un espejo caído se ve igual que una publicación fallida, el reintento
152  # vuelve a traer (ya con el canónico al día), calcula «sin novedad» y SALE 0. O sea que el fallo
153  # del espejo quedaría tapado por un éxito. Separarlos es lo que permite decir qué lado quedó atrás
154  # — que es la regla de este repo: comprobar los dos lados, no la salida del push.
155  CANON="$(git config --get remote.$REMOTE.url)"
156  if git push -q "$CANON" "$commit:refs/heads/main" 2>/dev/null; then
157    echo "==> estado publicado: ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
158    # El ref de seguimiento al día CON LO QUE ACABAMOS DE EMPUJAR. Sin esto queda un ciclo atrás por
159    # construcción —se trae antes de empujar— y el `git log origin/main..HEAD` del final mentiría
160    # siempre. Es un ref de seguimiento, no trabajo local: se escribe sin riesgo.
161    git update-ref "refs/remotes/$REMOTE/main" "$commit" 2>/dev/null || true
162    # ── ESPEJOS ───────────────────────────────────────────────────────────────────────────────
163    # Un espejo que falla NO invalida lo publicado, así que no cambia el código de salida; pero se
164    # grita, porque el modo de falla conocido es que el espejo DERIVE EN SILENCIO durante días
165    # (CLAUDE.md regla 2). Si no hay ninguno configurado se dice también: un espejo ausente y un
166    # espejo roto se ven igual desde el log, y no son lo mismo.
167    espejos=0
168    for url in $(git remote get-url --push --all "$REMOTE" 2>/dev/null); do
169      [ "$url" = "$CANON" ] && continue
170      espejos=$((espejos + 1))
171      git push -q "$url" "$commit:refs/heads/main" 2>/dev/null \
172        && echo "   espejo al día: $url" \
173        || echo "   ⚠ EL ESPEJO QUEDÓ ATRÁS: $url — lo publicado está en el canónico, el espejo no"
174    done
175    [ "$espejos" -gt 0 ] || echo "   (este clon no tiene espejo configurado — ver scripts/espejo-setup.sh)"
176    # ── ADELANTAR LA RAMA LOCAL ───────────────────────────────────────────────────────────────
177    # Lo publicado NO depende de esto: si no se puede, no es un fallo. Pero tampoco se avisa a lo
178    # bruto, porque este guion corre 48 veces al día y un ⚠ en cada ciclo deja de leerse — la misma
179    # lección que `espejo-setup.sh` acababa de aprender gritando «gh NO autenticado» con el push
180    # funcionando.
181    #
182    # ⚠ EL CASO NORMAL ES QUE EL `merge --ff-only` FALLE, Y NO INDICA NADA MALO. El generador acaba
183    # de reescribir `docs/state/` en el árbol y eso es justo lo que se publicó, así que el árbol ya
184    # tiene EL MISMO CONTENIDO que el commit nuevo. git igual se niega («local changes would be
185    # overwritten») porque su comprobación mira ÍNDICE contra HEAD, no árbol contra destino.
186    if ! git merge-base --is-ancestor HEAD "$commit" 2>/dev/null; then
187      echo "   ⚠ la rama local está DIVERGIDA de $REMOTE/main — publicado igual; mirá: git log $REMOTE/main..HEAD"
188    elif git merge --ff-only -q "$commit" 2>/dev/null; then
189      echo "   rama local al día"
190    # Si el árbol YA coincide con el destino en todo lo que el ff tocaría, el adelanto es un no-op
191    # en disco y se puede hacer moviendo la rama. Se hace con `reset --mixed`, que deja el árbol
192    # intacto y reescribe el índice —y por eso va GUARDADO por «el índice no tiene nada»: el índice
193    # es estado COMPARTIDO (regla 2) y pisar lo que otro agente dejó en `git add` es exactamente el
194    # daño que este repo ya documentó. Si hay algo stageado, no se toca y se dice.
195    # ⚠ La comprobación «el árbol ya coincide con el destino» se hace COMPARANDO EL CONTENIDO DEL
196    # DISCO, no con `git diff <commit> -- <ruta>`. Y es un bloqueo circular medido (2026-10-10, al
197    # añadir `cierres.json`): `git diff` **ignora las rutas sin seguir**, así que un fichero de
198    # estado NUEVO aparece eternamente como «difiere» —el commit lo tiene, el índice no— y la rama
199    # no adelanta nunca; pero volverlo seguido es justamente lo que el adelanto haría. El síntoma
200    # era «la rama local queda detrás» en cada ciclo, para siempre, por un fichero que en el disco
201    # era byte a byte el publicado.
202    elif git diff --cached --quiet HEAD 2>/dev/null \
203         && _arbol_coincide_con "$commit"; then
204      git reset --mixed -q "$commit" \
205        && echo "   rama local al día (el árbol ya tenía el contenido publicado)" \
206        || echo "   la rama local queda detrás — lo publicado NO depende de eso"
207    # ── ÚLTIMO RECURSO: SOLTAR NUESTRA PROPIA SUCIEDAD Y VOLVER A INTENTAR EL FF ────────────────
208    # Los dos caminos de arriba se bloquean juntos en cuanto OTRO agente empuja código: el `ff` no
209    # puede porque `docs/state/` está modificado, y el `reset --mixed` tampoco porque exige que el
210    # árbol ya tenga TODO lo que el ff tocaría —y el código del otro no lo tiene—. Resultado medido
211    # el 2026-10-15: el clon que siembra al worker se quedó **106 commits y dos días** atrás, y la
212    # granja construyó recetas que no estaban en ningún commit. Esto es el eslabón que faltaba.
213    #
214    # `docs/state/` es salida NUESTRA y acabamos de publicarla: en este punto el commit remoto
215    # contiene exactamente lo que hay en el disco, así que descartar esas rutas es un no-op de
216    # contenido —vuelven en el mismo ff, idénticas— y desbloquea el adelanto. Se descarta SÓLO
217    # `docs/state/`, nunca recetas ni código: lo de otro agente no se toca ni para desatascarse.
218    # Guardado por el índice vacío por lo mismo que el `reset --mixed`: es estado compartido.
219    elif git diff --cached --quiet HEAD 2>/dev/null \
220         && [ -n "$(git diff --name-only HEAD -- docs/state 2>/dev/null)" ]; then
221      git checkout -- docs/state 2>/dev/null
222      if git merge --ff-only -q "$commit" 2>/dev/null; then
223        echo "   rama local al día (soltando docs/state, que es salida propia ya publicada)"
224      else
225        echo "   la rama local queda detrás — lo publicado NO depende de eso, pero el ÁRBOL QUE SE"
226        echo "     SIEMBRA sí: mirá \`git status\` en $ROOT antes de que la granja muela algo viejo"
227      fi
228    else
229      echo "   la rama local queda detrás (hay cambios propios en el árbol) — lo publicado NO depende de eso"
230    fi
231    exit 0
232  fi
233
234  if [ "$intento" -ge "$INTENTOS" ]; then
235    echo "!! $INTENTOS intentos y el push sigue rechazado — alguien empuja más rápido de lo que reconstruyo"
236    exit 1
237  fi
238  echo "   otro empujó primero (intento $intento/$INTENTOS) — reconstruyo sobre la cabeza nueva"
239  sleep $((intento * 2))
240done