gioser

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…

TOML 1463Shell 152Markdown 90Rust 853028 Sutichaykuna

⎇ cb2013f54f20960c9eed8a6114cf840002fc0857 scripts/farm/publicar-estado.sh Shell · 169 siq'ikuna · 9.1 KB Ñawpa willay ch'uya

fix(granja): un espejo caído se veía igual que una publicación fallida cb2013f54 · 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/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  git fetch -q "$REMOTE" main || { echo "!! no pude traer $REMOTE/main"; exit 1; }
85  base="$(git rev-parse FETCH_HEAD)"
86
87  # Índice temporal sembrado con el árbol de origin/main. `GIT_INDEX_FILE` lo aísla del índice
88  # compartido del repo, que es el que arrastra el trabajo a medias de otro agente.
89  idx="$(mktemp "$ROOT/work/.publicar-estado.idx.XXXXXX")"
90  trap 'rm -f "$idx"' EXIT
91  GIT_INDEX_FILE="$idx" git read-tree "$base" || { echo "!! read-tree falló"; exit 1; }
92
93  puestos=0
94  for f in "${FICHEROS[@]}"; do
95    # Un fichero AUSENTE se salta en silencio, pero un fichero VACÍO también: regla 3 de CLAUDE.md
96    # —un vacío llega hasta el final diciendo que todo fue bien—. Si un vigía murió a mitad y dejó
97    # 0 bytes, publicar eso borraría el veredicto anterior y el khipu diría «verde» por un hueco.
98    [ -s "$f" ] || { [ -e "$f" ] && echo "   ⚠ $f existe pero está VACÍO — no lo publico"; continue; }
99    modo=100644
100    [ -x "$f" ] && modo=100755
101    blob="$(git hash-object -w -- "$f")" || { echo "!! hash-object falló en $f"; exit 1; }
102    GIT_INDEX_FILE="$idx" git update-index --add --cacheinfo "$modo,$blob,$f" \
103      || { echo "!! update-index falló en $f"; exit 1; }
104    puestos=$((puestos + 1))
105  done
106  [ "$puestos" -gt 0 ] || { echo "==> no hay ni un fichero de estado con contenido — nada que publicar"; exit 0; }
107
108  tree="$(GIT_INDEX_FILE="$idx" git write-tree)" || { echo "!! write-tree falló"; exit 1; }
109  rm -f "$idx"; trap - EXIT
110
111  if [ "$tree" = "$(git rev-parse "$base^{tree}")" ]; then
112    echo "==> sin novedad: el estado del disco es idéntico a $REMOTE/main"
113    exit 0
114  fi
115
116  commit="$(git commit-tree "$tree" -p "$base" -m "$MENSAJE")" \
117    || { echo "!! commit-tree falló"; exit 1; }
118
119  if [ "$DRY" = 1 ]; then
120    echo "==> [dry-run] publicaría ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
121    git diff --stat "$base" "$commit" | sed 's/^/    /'
122    exit 0
123  fi
124
125  # `<sha>:refs/heads/main` con el padre siendo la cabeza recién traída ⇒ es un fast-forward para
126  # el remoto. Si el remoto se movió, lo RECHAZA (no hay lease que valga: el rechazo ES la señal).
127  #
128  # Se empuja a la URL CANÓNICA explícitamente, no a `$REMOTE` a secas, y los espejos van aparte en
129  # un segundo paso. `git push <remoto>` con varias `pushurl` las empuja TODAS y devuelve != 0 si
130  # cualquiera falla: con eso un espejo caído se ve igual que una publicación fallida, el reintento
131  # vuelve a traer (ya con el canónico al día), calcula «sin novedad» y SALE 0. O sea que el fallo
132  # del espejo quedaría tapado por un éxito. Separarlos es lo que permite decir qué lado quedó atrás
133  # — que es la regla de este repo: comprobar los dos lados, no la salida del push.
134  CANON="$(git config --get remote.$REMOTE.url)"
135  if git push -q "$CANON" "$commit:refs/heads/main" 2>/dev/null; then
136    echo "==> estado publicado: ${commit:0:9} sobre ${base:0:9} ($puestos fichero(s))"
137    # ── ESPEJOS ───────────────────────────────────────────────────────────────────────────────
138    # Un espejo que falla NO invalida lo publicado, así que no cambia el código de salida; pero se
139    # grita, porque el modo de falla conocido es que el espejo DERIVE EN SILENCIO durante días
140    # (CLAUDE.md regla 2). Si no hay ninguno configurado se dice también: un espejo ausente y un
141    # espejo roto se ven igual desde el log, y no son lo mismo.
142    espejos=0
143    for url in $(git remote get-url --push --all "$REMOTE" 2>/dev/null); do
144      [ "$url" = "$CANON" ] && continue
145      espejos=$((espejos + 1))
146      git push -q "$url" "$commit:refs/heads/main" 2>/dev/null \
147        && echo "   espejo al día: $url" \
148        || echo "   ⚠ EL ESPEJO QUEDÓ ATRÁS: $url — lo publicado está en el canónico, el espejo no"
149    done
150    [ "$espejos" -gt 0 ] || echo "   (este clon no tiene espejo configurado — ver scripts/espejo-setup.sh)"
151    # Adelantar la rama local sólo si es un fast-forward limpio y el árbol no estorba. Si no se
152    # puede, NO es un fallo: lo publicado ya está, que es lo que importa. Se dice y se sigue.
153    if git merge-base --is-ancestor HEAD "$commit" 2>/dev/null; then
154      git merge --ff-only -q "$commit" 2>/dev/null \
155        && echo "   rama local al día" \
156        || echo "   ⚠ la rama local no pudo adelantar (árbol con cambios) — lo publicado NO depende de eso"
157    else
158      echo "   ⚠ la rama local está DIVERGIDA de $REMOTE/main — publicado igual; mirá: git log $REMOTE/main..HEAD"
159    fi
160    exit 0
161  fi
162
163  if [ "$intento" -ge "$INTENTOS" ]; then
164    echo "!! $INTENTOS intentos y el push sigue rechazado — alguien empuja más rápido de lo que reconstruyo"
165    exit 1
166  fi
167  echo "   otro empujó primero (intento $intento/$INTENTOS) — reconstruyo sobre la cabeza nueva"
168  sleep $((intento * 2))
169done