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…
feat(fuentes): bump-repo — todas las recetas de un repo al MISMO commit, o ninguna
1#!/usr/bin/env python3
2# bump-repo.py — mover TODAS las recetas de un mismo repo al MISMO commit, o ninguna.
3#
4# ══ POR QUÉ ES POR REPO Y NO POR RECETA ═══════════════════════════════════════════════════════
5# El SDD 33 §3.1 lo dice y es la razón de que esta herramienta exista en vez de un bucle sobre
6# `takana pin`: **37 recetas comparten el monorepo de tawasuyu**. Bumpear 37 pins de a uno da una
7# imagen con 37 commits distintos del mismo árbol — una mezcla de versiones que nunca existió junta
8# y que nadie probó. El bump tiene que ser ATÓMICO POR REPOSITORIO: todas las recetas que comparten
9# `repo` saltan al mismo commit, o no salta ninguna.
10#
11# Medido el 2026-10-07, que es el estado que lo motiva: de las 37 recetas de tawasuyu, **35 estaban
12# clavadas en 3bfb9799b8 y 36 de 37 por detrás de la cabeza** (b3eb2e4e37). Ya había deriva: dos
13# recetas pineaban commits distintos de las otras 35.
14#
15# ══ QUÉ SE AUTOMATIZA Y QUÉ NO ════════════════════════════════════════════════════════════════
16# > Se automatiza el pin de lo NUESTRO. No se automatiza el de nadie más. (SDD 33 §3)
17#
18# Por eso esta herramienta **se niega** a bumpear un repo de terceros sin `--third-party`: la
19# política no vive sólo en la prosa de un documento, vive en el comando. Una versión nueva de
20# upstream puede cambiar el sistema de build, las banderas, las deps y el resultado; el kernel
21# 6.16 → 7.3 es el ejemplo del SDD 33 §3.2 y es una unidad de trabajo, no una línea de cron.
22#
23# ⚠ **`version` NO entra en el ArtifactHash; `commit` SÍ** (`Recipe::hash_inputs` ⇒ `git:{commit}`).
24# O sea que lo que fuerza la reconstrucción es el pin, y `version` es una ETIQUETA que se actualiza
25# para que un humano lea algo cierto — no es el hecho ([[la-etiqueta-no-es-el-hecho]]). Por eso se
26# toca sólo con `--set-version` y se dice en el informe.
27#
28# ⚠ **Esto NO construye y NO publica.** Deja las recetas movidas y un fichero de REVERSIÓN con los
29# pins viejos, para que volver sea un comando y no una arqueología. Un bump que compila no es un
30# bump que arranca (SDD 33 §3.1).
31#
32# Uso: scripts/fuentes/bump-repo.py --list-repos
33# scripts/fuentes/bump-repo.py --repo tawasuyu # informe, no toca nada
34# scripts/fuentes/bump-repo.py --repo tawasuyu --apply # mueve a la cabeza
35# scripts/fuentes/bump-repo.py --repo tawasuyu --to v0.3.2 --apply # mueve a un tag
36# scripts/fuentes/bump-repo.py --revert work/.bump-tawasuyu.json # vuelve atrás
37# La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano.
38import argparse, glob, json, os, re, subprocess, sys
39
40RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
41
42# Los hosts que son NUESTROS. Fuera de esta lista, el bump pide `--third-party` a propósito.
43PROPIOS = ('git.tawasuyu.net', 'git.gioser.net')
44
45RE_REPO = re.compile(r'^(\s*repo\s*=\s*")([^"]+)(")', re.M)
46RE_COMMIT = re.compile(r'^(\s*commit\s*=\s*")([0-9a-fA-F]{7,40}|[^"]+)(")', re.M)
47RE_VERSION = re.compile(r'^(\s*version\s*=\s*")([^"]+)(")', re.M)
48
49
50def recetas():
51 """[(ruta, texto, repo, commit, version)] de cada receta que pinea un repo git."""
52 out = []
53 for r in sorted(set(glob.glob(os.path.join(RAIZ, 'recipes/*.toml'))
54 + glob.glob(os.path.join(RAIZ, 'recipes/*/*.toml')))):
55 txt = open(r, encoding='utf-8', errors='replace').read()
56 mr, mc = RE_REPO.search(txt), RE_COMMIT.search(txt)
57 if not (mr and mc):
58 continue
59 mv = RE_VERSION.search(txt)
60 out.append((os.path.relpath(r, RAIZ), txt, mr.group(2), mc.group(2),
61 mv.group(2) if mv else None))
62 return out
63
64
65def ls_remote(repo, patron=None):
66 cmd = ['git', 'ls-remote', repo] + ([patron] if patron else [])
67 p = subprocess.run(cmd, capture_output=True, text=True, timeout=120,
68 env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'})
69 if p.returncode != 0:
70 return None, (p.stderr.strip().splitlines() or ['(sin stderr)'])[-1]
71 refs = {}
72 for ln in p.stdout.splitlines():
73 parte = ln.split('\t')
74 if len(parte) == 2:
75 refs[parte[1]] = parte[0]
76 return refs, None
77
78
79def resolver(repo, destino):
80 """(sha, cómo) del destino: None ⇒ la cabeza de HEAD; un tag/rama ⇒ su sha; un sha ⇒ él mismo."""
81 if destino and re.fullmatch(r'[0-9a-f]{40}', destino):
82 return destino, 'sha dado a mano (no se comprueba que exista: `git ls-remote` no lista commits sueltos)'
83 refs, err = ls_remote(repo)
84 if refs is None:
85 return None, f'no pude consultar el repo: {err}'
86 if not destino:
87 sha = refs.get('HEAD')
88 if not sha:
89 return None, 'el repo no publica HEAD'
90 rama = [r for r, s in refs.items() if s == sha and r.startswith('refs/heads/')]
91 return sha, f'cabeza de {rama[0].split("/")[-1] if rama else "HEAD"}'
92 for cand in (f'refs/tags/{destino}', f'refs/heads/{destino}'):
93 if cand in refs:
94 # Un tag anotado desreferencia a `^{}`; el commit es ése, no el objeto tag.
95 return refs.get(cand + '^{}', refs[cand]), f'{cand}'
96 return None, f'no existe el tag ni la rama «{destino}» en el repo'
97
98
99def main():
100 ap = argparse.ArgumentParser(add_help=True)
101 ap.add_argument('--repo', help='URL del repo, o un trozo que la identifique (p.ej. "tawasuyu")')
102 ap.add_argument('--to', help='tag, rama o sha de 40. Sin esto, la cabeza del repo')
103 ap.add_argument('--apply', action='store_true', help='escribir. Sin esto, sólo informa')
104 ap.add_argument('--set-version', help='además, poner este `version` en todas (es una ETIQUETA, no entra al hash)')
105 ap.add_argument('--third-party', action='store_true',
106 help='permitir bumpear un repo que NO es nuestro (SDD 33 §3.2: no se automatiza)')
107 ap.add_argument('--list-repos', action='store_true', help='repos del catálogo y cuántas recetas pinea cada uno')
108 ap.add_argument('--revert', metavar='FICHERO', help='volver a los pins de un informe de bump anterior')
109 a = ap.parse_args()
110
111 todas = recetas()
112
113 if a.revert:
114 prev = json.load(open(a.revert, encoding='utf-8'))
115 n = 0
116 for it in prev['recetas']:
117 ruta = os.path.join(RAIZ, it['receta'])
118 txt = open(ruta, encoding='utf-8').read()
119 nuevo = RE_COMMIT.sub(lambda m: m.group(1) + it['commit_antes'] + m.group(3), txt, count=1)
120 if it.get('version_antes') is not None:
121 nuevo = RE_VERSION.sub(lambda m: m.group(1) + it['version_antes'] + m.group(3), nuevo, count=1)
122 if nuevo != txt:
123 open(ruta, 'w', encoding='utf-8').write(nuevo)
124 n += 1
125 print(f'↩ revertidas {n} receta(s) a los pins de {prev["cuando"]}')
126 return 0
127
128 if a.list_repos or not a.repo:
129 por = {}
130 for ruta, _t, repo, com, _v in todas:
131 por.setdefault(repo, []).append((ruta, com))
132 print(f'{len(todas)} recetas pinean un repo git · {len(por)} repos distintos')
133 print('\nrepos con MÁS DE UNA receta (los que exigen bump atómico):')
134 for repo, rs in sorted(por.items(), key=lambda kv: -len(kv[1])):
135 if len(rs) < 2:
136 continue
137 pins = {c for _r, c in rs}
138 marca = ' ⚠ YA DERIVADO: pins distintos' if len(pins) > 1 else ''
139 propio = 'propio' if any(h in repo for h in PROPIOS) else 'ajeno '
140 print(f' {len(rs):3} recetas · {propio} · {repo}{marca}')
141 if not a.repo:
142 print('\n(sin --repo no se bumpea nada)')
143 return 0
144
145 # ── seleccionar el repo, exigiendo que la selección sea UNA sola ───────────────────────────
146 candidatos = sorted({r for _ruta, _t, r, _c, _v in todas if a.repo == r or a.repo in r})
147 if not candidatos:
148 print(f'!! ningún repo del catálogo coincide con «{a.repo}»', file=sys.stderr)
149 return 1
150 if len(candidatos) > 1:
151 print(f'!! «{a.repo}» coincide con {len(candidatos)} repos — ambiguo, y un bump ambiguo es '
152 f'exactamente lo que esto evita:', file=sys.stderr)
153 for c in candidatos:
154 print(f' {c}', file=sys.stderr)
155 return 1
156 repo = candidatos[0]
157 mias = [(ruta, txt, com, ver) for ruta, txt, r, com, ver in todas if r == repo]
158
159 es_propio = any(h in repo for h in PROPIOS)
160 print(f'repo: {repo} ({"NUESTRO" if es_propio else "DE TERCEROS"})')
161 print(f'recetas que lo pinean: {len(mias)}')
162 pins = {}
163 for ruta, _t, com, _v in mias:
164 pins.setdefault(com, []).append(ruta)
165 for com, rs in sorted(pins.items(), key=lambda kv: -len(kv[1])):
166 print(f' pin {com[:10]}… · {len(rs)} receta(s)' + ('' if len(pins) == 1 else f' (p.ej. {rs[0]})'))
167 if len(pins) > 1:
168 print(f' ⚠ YA HAY DERIVA: {len(pins)} commits distintos del MISMO árbol. Es justo la mezcla '
169 f'que nadie probó junta, y el bump la cierra.')
170
171 sha, como = resolver(repo, a.to)
172 if not sha:
173 print(f'!! {como}', file=sys.stderr)
174 return 1
175 print(f'destino: {sha[:10]}… ({como})')
176
177 quedan = [m for m in mias if m[2].lower() != sha.lower()]
178 if not quedan:
179 print('==> ya están TODAS en ese commit — nada que hacer')
180 return 0
181 print(f'a mover: {len(quedan)} de {len(mias)}')
182
183 if not es_propio and not a.third_party:
184 print('\n!! ME NIEGO: este repo NO es nuestro, y el SDD 33 §3 dice que el pin de terceros no '
185 'se automatiza.\n'
186 ' Una versión nueva puede cambiar el sistema de build, las banderas, las deps y el '
187 'resultado:\n'
188 ' aplicarla sola es cambiar la receta sin leerla. Si lo decidiste leyéndola, '
189 '`--third-party` lo permite.', file=sys.stderr)
190 return 2
191
192 # ── el informe de reversión, SIEMPRE, y antes de escribir ──────────────────────────────────
193 informe = {
194 'schema': 'takana-bump-repo/1',
195 'repo': repo, 'destino': sha, 'como': como,
196 'cuando': subprocess.run(['date', '-u', '+%FT%TZ'], capture_output=True, text=True).stdout.strip(),
197 'recetas': [{'receta': ruta, 'commit_antes': com, 'commit_despues': sha,
198 'version_antes': ver, 'version_despues': a.set_version or ver}
199 for ruta, _t, com, ver in quedan],
200 }
201
202 if not a.apply:
203 print('\n[dry-run] se moverían:')
204 for it in informe['recetas'][:12]:
205 print(f' {it["receta"]:<48} {it["commit_antes"][:10]}… → {sha[:10]}…')
206 if len(informe['recetas']) > 12:
207 print(f' … y {len(informe["recetas"]) - 12} más')
208 print('\n(con --apply se escriben; el bump es atómico: o todas, o ninguna)')
209 return 0
210
211 # ── ATÓMICO: se reescribe TODO en memoria y se comprueba ANTES de tocar el disco ───────────
212 # Si una sola receta no se puede reescribir, no se escribe NINGUNA. Media tanda escrita es el
213 # estado que esta herramienta existe para evitar: deja el repo en la mezcla que nadie probó.
214 pendiente = []
215 for ruta, txt, com, ver in quedan:
216 nuevo, n = RE_COMMIT.subn(lambda m: m.group(1) + sha + m.group(3), txt, count=1)
217 if n != 1:
218 print(f'!! no pude reescribir el commit de {ruta} — NO escribo nada', file=sys.stderr)
219 return 1
220 if a.set_version:
221 nuevo, nv = RE_VERSION.subn(lambda m: m.group(1) + a.set_version + m.group(3), nuevo, count=1)
222 if nv != 1:
223 print(f'!! {ruta} no tiene `version` que cambiar — NO escribo nada', file=sys.stderr)
224 return 1
225 # Control de que no se escapó nada más: el diff tiene que ser de UNA o DOS líneas.
226 cambiadas = sum(1 for x, y in zip(txt.splitlines(), nuevo.splitlines()) if x != y)
227 if cambiadas > (2 if a.set_version else 1):
228 print(f'!! el cambio en {ruta} toca {cambiadas} líneas, esperaba 1 — NO escribo nada',
229 file=sys.stderr)
230 return 1
231 pendiente.append((ruta, nuevo))
232
233 rev = os.path.join(RAIZ, 'work', f'.bump-{re.sub(r"[^a-z0-9]+", "-", repo.lower()).strip("-")}.json')
234 os.makedirs(os.path.dirname(rev), exist_ok=True)
235 with open(rev, 'w', encoding='utf-8') as fh:
236 json.dump(informe, fh, indent=2, ensure_ascii=False)
237
238 for ruta, nuevo in pendiente:
239 open(os.path.join(RAIZ, ruta), 'w', encoding='utf-8').write(nuevo)
240 print(f'\n✓ {len(pendiente)} receta(s) movidas a {sha[:10]}…, todas al MISMO commit')
241 print(f' reversión: scripts/fuentes/bump-repo.py --revert {os.path.relpath(rev, RAIZ)}')
242 print(' ⚠ esto NO construyó nada. Un bump que compila no es un bump que arranca.')
243 return 0
244
245
246if __name__ == '__main__':
247 sys.exit(main())