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 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ 534014c3ec69daea789a1d0523e1d00a69c6ed65 scripts/fuentes/bump-repo.py Python · 247 líneas · 13.0 KB Historia Quién tocó qué crudo

feat(fuentes): bump-repo — todas las recetas de un repo al MISMO commit, o ninguna c5488a42f · Sergio ·

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())