#!/usr/bin/env python3 # bump-repo.py — mover TODAS las recetas de un mismo repo al MISMO commit, o ninguna. # # ══ POR QUÉ ES POR REPO Y NO POR RECETA ═══════════════════════════════════════════════════════ # El SDD 33 §3.1 lo dice y es la razón de que esta herramienta exista en vez de un bucle sobre # `takana pin`: **37 recetas comparten el monorepo de tawasuyu**. Bumpear 37 pins de a uno da una # imagen con 37 commits distintos del mismo árbol — una mezcla de versiones que nunca existió junta # y que nadie probó. El bump tiene que ser ATÓMICO POR REPOSITORIO: todas las recetas que comparten # `repo` saltan al mismo commit, o no salta ninguna. # # Medido el 2026-10-07, que es el estado que lo motiva: de las 37 recetas de tawasuyu, **35 estaban # clavadas en 3bfb9799b8 y 36 de 37 por detrás de la cabeza** (b3eb2e4e37). Ya había deriva: dos # recetas pineaban commits distintos de las otras 35. # # ══ QUÉ SE AUTOMATIZA Y QUÉ NO ════════════════════════════════════════════════════════════════ # > Se automatiza el pin de lo NUESTRO. No se automatiza el de nadie más. (SDD 33 §3) # # Por eso esta herramienta **se niega** a bumpear un repo de terceros sin `--third-party`: la # política no vive sólo en la prosa de un documento, vive en el comando. Una versión nueva de # upstream puede cambiar el sistema de build, las banderas, las deps y el resultado; el kernel # 6.16 → 7.3 es el ejemplo del SDD 33 §3.2 y es una unidad de trabajo, no una línea de cron. # # ⚠ **`version` NO entra en el ArtifactHash; `commit` SÍ** (`Recipe::hash_inputs` ⇒ `git:{commit}`). # O sea que lo que fuerza la reconstrucción es el pin, y `version` es una ETIQUETA que se actualiza # para que un humano lea algo cierto — no es el hecho ([[la-etiqueta-no-es-el-hecho]]). Por eso se # toca sólo con `--set-version` y se dice en el informe. # # ⚠ **Esto NO construye y NO publica.** Deja las recetas movidas y un fichero de REVERSIÓN con los # pins viejos, para que volver sea un comando y no una arqueología. Un bump que compila no es un # bump que arranca (SDD 33 §3.1). # # Uso: scripts/fuentes/bump-repo.py --list-repos # scripts/fuentes/bump-repo.py --repo tawasuyu # informe, no toca nada # scripts/fuentes/bump-repo.py --repo tawasuyu --apply # mueve a la cabeza # scripts/fuentes/bump-repo.py --repo tawasuyu --to v0.3.2 --apply # mueve a un tag # scripts/fuentes/bump-repo.py --revert work/.bump-tawasuyu.json # vuelve atrás # La superficie va en INGLÉS (CLAUDE.md regla 4); lo que se lee, en castellano. import argparse, glob, json, os, re, subprocess, sys RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Los hosts que son NUESTROS. Fuera de esta lista, el bump pide `--third-party` a propósito. PROPIOS = ('git.tawasuyu.net', 'git.gioser.net') RE_REPO = re.compile(r'^(\s*repo\s*=\s*")([^"]+)(")', re.M) RE_COMMIT = re.compile(r'^(\s*commit\s*=\s*")([0-9a-fA-F]{7,40}|[^"]+)(")', re.M) RE_VERSION = re.compile(r'^(\s*version\s*=\s*")([^"]+)(")', re.M) def recetas(): """[(ruta, texto, repo, commit, version)] de cada receta que pinea un repo git.""" out = [] for r in sorted(set(glob.glob(os.path.join(RAIZ, 'recipes/*.toml')) + glob.glob(os.path.join(RAIZ, 'recipes/*/*.toml')))): txt = open(r, encoding='utf-8', errors='replace').read() mr, mc = RE_REPO.search(txt), RE_COMMIT.search(txt) if not (mr and mc): continue mv = RE_VERSION.search(txt) out.append((os.path.relpath(r, RAIZ), txt, mr.group(2), mc.group(2), mv.group(2) if mv else None)) return out def ls_remote(repo, patron=None): cmd = ['git', 'ls-remote', repo] + ([patron] if patron else []) p = subprocess.run(cmd, capture_output=True, text=True, timeout=120, env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'}) if p.returncode != 0: return None, (p.stderr.strip().splitlines() or ['(sin stderr)'])[-1] refs = {} for ln in p.stdout.splitlines(): parte = ln.split('\t') if len(parte) == 2: refs[parte[1]] = parte[0] return refs, None def resolver(repo, destino): """(sha, cómo) del destino: None ⇒ la cabeza de HEAD; un tag/rama ⇒ su sha; un sha ⇒ él mismo.""" if destino and re.fullmatch(r'[0-9a-f]{40}', destino): return destino, 'sha dado a mano (no se comprueba que exista: `git ls-remote` no lista commits sueltos)' refs, err = ls_remote(repo) if refs is None: return None, f'no pude consultar el repo: {err}' if not destino: sha = refs.get('HEAD') if not sha: return None, 'el repo no publica HEAD' rama = [r for r, s in refs.items() if s == sha and r.startswith('refs/heads/')] return sha, f'cabeza de {rama[0].split("/")[-1] if rama else "HEAD"}' for cand in (f'refs/tags/{destino}', f'refs/heads/{destino}'): if cand in refs: # Un tag anotado desreferencia a `^{}`; el commit es ése, no el objeto tag. return refs.get(cand + '^{}', refs[cand]), f'{cand}' return None, f'no existe el tag ni la rama «{destino}» en el repo' def main(): ap = argparse.ArgumentParser(add_help=True) ap.add_argument('--repo', help='URL del repo, o un trozo que la identifique (p.ej. "tawasuyu")') ap.add_argument('--to', help='tag, rama o sha de 40. Sin esto, la cabeza del repo') ap.add_argument('--apply', action='store_true', help='escribir. Sin esto, sólo informa') ap.add_argument('--set-version', help='además, poner este `version` en todas (es una ETIQUETA, no entra al hash)') ap.add_argument('--third-party', action='store_true', help='permitir bumpear un repo que NO es nuestro (SDD 33 §3.2: no se automatiza)') ap.add_argument('--list-repos', action='store_true', help='repos del catálogo y cuántas recetas pinea cada uno') ap.add_argument('--revert', metavar='FICHERO', help='volver a los pins de un informe de bump anterior') a = ap.parse_args() todas = recetas() if a.revert: prev = json.load(open(a.revert, encoding='utf-8')) n = 0 for it in prev['recetas']: ruta = os.path.join(RAIZ, it['receta']) txt = open(ruta, encoding='utf-8').read() nuevo = RE_COMMIT.sub(lambda m: m.group(1) + it['commit_antes'] + m.group(3), txt, count=1) if it.get('version_antes') is not None: nuevo = RE_VERSION.sub(lambda m: m.group(1) + it['version_antes'] + m.group(3), nuevo, count=1) if nuevo != txt: open(ruta, 'w', encoding='utf-8').write(nuevo) n += 1 print(f'↩ revertidas {n} receta(s) a los pins de {prev["cuando"]}') return 0 if a.list_repos or not a.repo: por = {} for ruta, _t, repo, com, _v in todas: por.setdefault(repo, []).append((ruta, com)) print(f'{len(todas)} recetas pinean un repo git · {len(por)} repos distintos') print('\nrepos con MÁS DE UNA receta (los que exigen bump atómico):') for repo, rs in sorted(por.items(), key=lambda kv: -len(kv[1])): if len(rs) < 2: continue pins = {c for _r, c in rs} marca = ' ⚠ YA DERIVADO: pins distintos' if len(pins) > 1 else '' propio = 'propio' if any(h in repo for h in PROPIOS) else 'ajeno ' print(f' {len(rs):3} recetas · {propio} · {repo}{marca}') if not a.repo: print('\n(sin --repo no se bumpea nada)') return 0 # ── seleccionar el repo, exigiendo que la selección sea UNA sola ─────────────────────────── candidatos = sorted({r for _ruta, _t, r, _c, _v in todas if a.repo == r or a.repo in r}) if not candidatos: print(f'!! ningún repo del catálogo coincide con «{a.repo}»', file=sys.stderr) return 1 if len(candidatos) > 1: print(f'!! «{a.repo}» coincide con {len(candidatos)} repos — ambiguo, y un bump ambiguo es ' f'exactamente lo que esto evita:', file=sys.stderr) for c in candidatos: print(f' {c}', file=sys.stderr) return 1 repo = candidatos[0] mias = [(ruta, txt, com, ver) for ruta, txt, r, com, ver in todas if r == repo] es_propio = any(h in repo for h in PROPIOS) print(f'repo: {repo} ({"NUESTRO" if es_propio else "DE TERCEROS"})') print(f'recetas que lo pinean: {len(mias)}') pins = {} for ruta, _t, com, _v in mias: pins.setdefault(com, []).append(ruta) for com, rs in sorted(pins.items(), key=lambda kv: -len(kv[1])): print(f' pin {com[:10]}… · {len(rs)} receta(s)' + ('' if len(pins) == 1 else f' (p.ej. {rs[0]})')) if len(pins) > 1: print(f' ⚠ YA HAY DERIVA: {len(pins)} commits distintos del MISMO árbol. Es justo la mezcla ' f'que nadie probó junta, y el bump la cierra.') sha, como = resolver(repo, a.to) if not sha: print(f'!! {como}', file=sys.stderr) return 1 print(f'destino: {sha[:10]}… ({como})') quedan = [m for m in mias if m[2].lower() != sha.lower()] if not quedan: print('==> ya están TODAS en ese commit — nada que hacer') return 0 print(f'a mover: {len(quedan)} de {len(mias)}') if not es_propio and not a.third_party: print('\n!! ME NIEGO: este repo NO es nuestro, y el SDD 33 §3 dice que el pin de terceros no ' 'se automatiza.\n' ' Una versión nueva puede cambiar el sistema de build, las banderas, las deps y el ' 'resultado:\n' ' aplicarla sola es cambiar la receta sin leerla. Si lo decidiste leyéndola, ' '`--third-party` lo permite.', file=sys.stderr) return 2 # ── el informe de reversión, SIEMPRE, y antes de escribir ────────────────────────────────── informe = { 'schema': 'takana-bump-repo/1', 'repo': repo, 'destino': sha, 'como': como, 'cuando': subprocess.run(['date', '-u', '+%FT%TZ'], capture_output=True, text=True).stdout.strip(), 'recetas': [{'receta': ruta, 'commit_antes': com, 'commit_despues': sha, 'version_antes': ver, 'version_despues': a.set_version or ver} for ruta, _t, com, ver in quedan], } if not a.apply: print('\n[dry-run] se moverían:') for it in informe['recetas'][:12]: print(f' {it["receta"]:<48} {it["commit_antes"][:10]}… → {sha[:10]}…') if len(informe['recetas']) > 12: print(f' … y {len(informe["recetas"]) - 12} más') print('\n(con --apply se escriben; el bump es atómico: o todas, o ninguna)') return 0 # ── ATÓMICO: se reescribe TODO en memoria y se comprueba ANTES de tocar el disco ─────────── # Si una sola receta no se puede reescribir, no se escribe NINGUNA. Media tanda escrita es el # estado que esta herramienta existe para evitar: deja el repo en la mezcla que nadie probó. pendiente = [] for ruta, txt, com, ver in quedan: nuevo, n = RE_COMMIT.subn(lambda m: m.group(1) + sha + m.group(3), txt, count=1) if n != 1: print(f'!! no pude reescribir el commit de {ruta} — NO escribo nada', file=sys.stderr) return 1 if a.set_version: nuevo, nv = RE_VERSION.subn(lambda m: m.group(1) + a.set_version + m.group(3), nuevo, count=1) if nv != 1: print(f'!! {ruta} no tiene `version` que cambiar — NO escribo nada', file=sys.stderr) return 1 # Control de que no se escapó nada más: el diff tiene que ser de UNA o DOS líneas. cambiadas = sum(1 for x, y in zip(txt.splitlines(), nuevo.splitlines()) if x != y) if cambiadas > (2 if a.set_version else 1): print(f'!! el cambio en {ruta} toca {cambiadas} líneas, esperaba 1 — NO escribo nada', file=sys.stderr) return 1 pendiente.append((ruta, nuevo)) rev = os.path.join(RAIZ, 'work', f'.bump-{re.sub(r"[^a-z0-9]+", "-", repo.lower()).strip("-")}.json') os.makedirs(os.path.dirname(rev), exist_ok=True) with open(rev, 'w', encoding='utf-8') as fh: json.dump(informe, fh, indent=2, ensure_ascii=False) for ruta, nuevo in pendiente: open(os.path.join(RAIZ, ruta), 'w', encoding='utf-8').write(nuevo) print(f'\n✓ {len(pendiente)} receta(s) movidas a {sha[:10]}…, todas al MISMO commit') print(f' reversión: scripts/fuentes/bump-repo.py --revert {os.path.relpath(rev, RAIZ)}') print(' ⚠ esto NO construyó nada. Un bump que compila no es un bump que arranca.') return 0 if __name__ == '__main__': sys.exit(main())