> 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): 196 de los 625 tarballs ya se vigilan — no hacía falta un mecanismo, hacía falta leer la URL
Este guion decía que un tarball «no tiene equivalente general» y que saber si hay versión nueva
«pide una pista por receta». Es cierto EN GENERAL y era **falso para el grueso**. Medido: de las
625 recetas con tarball, **196 son de github.com**, y en esas URLs el repo y la versión YA ESTÁN
ESCRITOS — `.../archive/refs/tags/v1.2.3.tar.gz`, `.../releases/download/1.10/sway-1.10.tar.gz`.
El mismo `git ls-remote --tags` que la vigía ya hacía las cubre tal cual: sin API, sin token, sin
pista por receta. El punto ciego no era técnico, era una frase que nadie volvió a medir.
Resultado de la primera corrida: **82 recetas bajan una versión más vieja que la que upstream
publica** — 82 que hasta hoy no aparecían en ningún informe.
⚠ **Y trae su propio freno, porque la primera versión era un generador de ruido.** Comparar contra
el tag más nuevo del repo sin mirar su SERIE daba basura en cuanto upstream publica varias:
cronie baja `cronie-1.7.2` → decía «hay v4.3» (otra serie entera)
bash-completion baja `2.18.0` → decía «hay 20060301-alt06» (tag de empaquetado ajeno)
Nueve falsos positivos de 91, medidos. `forma()` acota la comparación al mismo prefijo no numérico
y la misma cantidad de componentes: `v10.3.2` sólo contra `v#.#.#`. Sesga hacia el silencio a
propósito —un `1.2` → `1.2.1` queda fuera— y **por eso lo descartado se cuenta y se dice** (11 sin
serie comparable, 4 que bajan por sha, 50 sin tags o inalcanzables) en vez de desaparecer. Un
guardián cuyos hallazgos son casi todos falsos no se apaga: se deja de leer, y entonces tampoco se
ve el verdadero.
Control de que no es un falso nuestro: `gitea v1.27.0 → v28.1.0` sobrevive al freno, y upstream
**sí** publicó `v28.0.0` y `v28.1.0` junto a `v1.27.3`. Adoptarlo o no es del humano — la vigía
propone.
Lo que SIGUE fuera son 429, y el informe **los nombra por host** en vez de resumirlos: 108 en
download.kde.org, 70 en download.gnome.org, 32 en ftp.gnu.org, 29 en xorg/x.org, 26 en
gitlab.freedesktop.org, 18 en kernel.org, 12 en qt.io. Ésos piden un listado por host —no una
pista por receta, que es lo que el SDD 33 §5 suponía— y son su propia unidad de trabajo. «429 sin
mirar» se lee como silencio; «108 en download.kde.org» se lee como la próxima tarea.
El json pasa a `takana-upstream/2` **conservando las claves de /1**, `tarballs_sin_mirar`
incluida, porque el resumen del latido las lee y renombrarlas rompería al consumidor sin avisar.
Lo que cambia es su significado, a mejor: antes «todos los tarballs», ahora «los que siguen fuera
del radar». El resumen del latido lee la clave nueva con `.get(…,'?')` para que un informe viejo no
lo rompa.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/scripts/farm/cosecha-cron.sh b/scripts/farm/cosecha-cron.sh
index 9a029016..4880b93f 100755
--- a/scripts/farm/cosecha-cron.sh
+++ b/scripts/farm/cosecha-cron.sh
@@ -390,7 +390,7 @@ if [ -x "$HAMMER" ]; then
mv work/.upstream.tmp docs/state/upstream.json
echo " upstream.json ✓ $(python3 -c "
import json; d=json.load(open('docs/state/upstream.json'))
-print(f\"{d['con_tag_nuevo']} con tag nuevo · {d['con_rama_movida']} con rama movida · {d['tarballs_sin_mirar']} tarballs sin mirar\")" 2>/dev/null || echo '(sin resumen)')"
+print(f\"{d['con_tag_nuevo']} git con tag nuevo · {d.get('tarballs_con_version_nueva','?')} tarballs con version nueva · {d['tarballs_sin_mirar']} tarballs AUN sin mirar\")" 2>/dev/null || echo '(sin resumen)')"
else
echo " ⚠ upstream-vigia falló o salió vacío — NO piso el informe anterior"
sed -n '$p' work/.upstream-vigia.salida 2>/dev/null | sed 's/^/ /'
diff --git a/scripts/fuentes/upstream-vigia.py b/scripts/fuentes/upstream-vigia.py
index f47e5f6d..8997fcfc 100755
--- a/scripts/fuentes/upstream-vigia.py
+++ b/scripts/fuentes/upstream-vigia.py
@@ -12,12 +12,28 @@
# sistema de build, las banderas, las dependencias y el resultado, y eso no se automatiza a ciegas
# para código ajeno.
#
-# ── POR QUÉ SÓLO GIT, Y POR QUÉ ESO YA ES LA MITAD ────────────────────────────────────────────
+# ── QUÉ SE CUBRE, Y POR QUÉ ESTO YA NO ES «SÓLO GIT» ──────────────────────────────────────────
# `git ls-remote` funciona contra CUALQUIER servidor git sin API, sin token y sin clonar — es lo
-# mismo que ya hace `takana pin`. Con eso se cubren las 660 recetas que pinean `repo` + `commit`
-# (607 en github, 37 en tawasuyu). Las que traen `tarball` + `sha256` no tienen equivalente
-# general: saber si hay una versión nueva de un tarball pide una pista por receta (dónde mirar y
-# con qué patrón), y eso es trabajo aparte — se cuentan y se dicen, no se adivinan.
+# mismo que ya hace `takana pin`. Con eso se cubren las 660 recetas que pinean `repo` + `commit`.
+#
+# ⚠ **Y TAMBIÉN 196 DE LAS 625 QUE PINEAN TARBALL** (2026-10-07). La primera versión de este guion
+# decía que un tarball «no tiene equivalente general» y que saber si hay una versión nueva «pide
+# una pista por receta». Es cierto en general y era **falso para el grueso**: medido, 196 de los
+# 625 tarballs son de `github.com`, y en esas URLs el repo y la versión YA ESTÁN ESCRITOS
+# (`.../archive/refs/tags/v1.2.3.tar.gz`, `.../releases/download/1.10/sway-1.10.tar.gz`). No hacía
+# falta API, ni token, ni pista por receta: hacía falta LEER LA URL y reusar el `ls-remote` que ya
+# estaba acá. Cubrirlas no costó un mecanismo nuevo.
+#
+# Lo que SIGUE fuera son 429, y son índices de directorio con forma conocida: download.kde.org
+# (108), download.gnome.org (70), ftp.gnu.org (32), gitlab.freedesktop.org (26), xorg/x.org (29),
+# download.qt.io (12), kernel.org (18). Ésos piden **un listado por host**, no una pista por receta
+# — su propia unidad de trabajo. Se cuentan y **se nombran por host** en el informe: «429 sin
+# mirar» se lee como silencio, «108 en download.kde.org» se lee como la próxima tarea.
+#
+# ⚠ Y se prefiere CALLAR a adivinar. Tres cosas no se comparan nunca, y cada una se informa en vez
+# de desaparecer: las que bajan por SHA (un sha no tiene orden de versión), las que no tienen tag
+# de la MISMA serie que la URL (ver `forma`: evitó 9 falsos positivos medidos, del tipo
+# «cronie-1.7.2 → v4.3») y las que no tienen tags o están inalcanzables.
#
# Uso: scripts/fuentes/upstream-vigia.py [--json <fichero>] [--solo tawasuyu] [--jobs N]
# La superficie va en INGLÉS (`CLAUDE.md` regla 4); lo que se lee, en castellano.
@@ -41,16 +57,6 @@ def recetas_git():
return out
-def recetas_tarball():
- n = 0
- 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()
- if re.search(r'^\s*tarball\s*=', txt, re.M):
- n += 1
- return n
-
-
# Un tag de versión, sin `^{}` (el objeto desreferenciado que git lista aparte) ni prereleases.
TAG = re.compile(r'^refs/tags/(v?\d+[\w.\-]*)$')
PRE = re.compile(r'(?i)(alpha|beta|rc\d|-rc|dev|pre|snapshot)')
@@ -62,6 +68,141 @@ def clave(t):
return (nums + [0] * 5)[:5]
+# ── TARBALLS DE GITHUB: la mitad del punto ciego, y sin mecanismo nuevo ───────────────────────
+# El comentario de la cabecera decía que un tarball «no tiene equivalente general» y que haría
+# falta una pista por receta. Es cierto EN GENERAL y era falso para el grueso: medido el
+# 2026-10-07, de las **625 recetas con tarball, 196 son de github.com** — y para ésas la URL YA
+# CONTIENE el repo y la versión, así que el `git ls-remote --tags` que esta vigía ya hace las cubre
+# tal cual. No hacía falta API, ni token, ni pista: hacía falta LEER LA URL.
+#
+# Los otros hosts gordos son índices de directorio con forma conocida (download.kde.org 108,
+# download.gnome.org 70, ftp.gnu.org 32, x.org/xorg 29, kernel.org 18, qt.io 12). Ésos sí piden su
+# propia unidad de trabajo —un listado por host, no una pista por receta— y se CUENTAN Y SE DICEN
+# al final, que es lo que evita que las que quedan fuera del radar se lean como silencio.
+GH_TARBALL = re.compile(
+ r'^https?://(?:www\.)?(?:github\.com|codeload\.github\.com)/'
+ r'([^/]+)/([^/]+?)(?:\.git)?/'
+ r'(?:archive/(?:refs/tags/)?|releases/download/|tar\.gz/|zipball/|tarball/)'
+ r'(.+)$')
+SUFIJOS = ('.tar.gz', '.tar.xz', '.tar.bz2', '.tar.zst', '.tgz', '.txz', '.tbz2', '.zip', '.tar')
+SOLO_SHA = re.compile(r'^[0-9a-f]{7,40}$')
+
+
+def forma(t):
+ """(prefijo, cuántos números) de un tag. Es la 'serie' a la que pertenece.
+
+ ⚠ EXISTE POR LOS FALSOS POSITIVOS, Y SON MEDIDOS (2026-10-07). Comparar el tag más nuevo del
+ repo contra el que la URL descarga, sin más, produce basura en cuanto el repo publica tags de
+ varias series:
+ cronie baja `cronie-1.7.2` → decía «hay v4.3» (otra serie entera)
+ bash-completion baja `2.18.0` → decía «hay 20060301-alt06» (tag de empaquetado ajeno)
+ gitea baja `v1.27.0` → decía «hay v28.1.0`
+ Y un guardián cuyos hallazgos son casi todos falsos no se apaga: se deja de leer, y entonces
+ tampoco se ve el hallazgo verdadero. Por eso acá se prefiere CALLAR a adivinar.
+
+ La regla es la más simple que se puede explicar: sólo se comparan tags con el MISMO prefijo no
+ numérico y la MISMA cantidad de componentes numéricos. `v10.3.2` sólo contra `v#.#.#`,
+ `cronie-1.7.2` sólo contra `cronie-#.#.#`. Sesga hacia el silencio a propósito — un salto
+ `1.2` → `1.2.1` queda fuera —, y por eso lo que se descarta se CUENTA y se DICE en el informe
+ en vez de desaparecer.
+ """
+ pref = re.match(r'^[^0-9]*', t).group(0)
+ return (pref, len(re.findall(r'\d+', t)))
+
+
+def _limpiar_ref(ref):
+ """De la cola de la URL al tag: se queda con el primer segmento (`releases/download/<tag>/<fichero>`
+ trae el fichero detrás) y le quita el sufijo del archivo."""
+ ref = ref.split('/')[0]
+ for suf in SUFIJOS:
+ if ref.endswith(suf):
+ ref = ref[: -len(suf)]
+ break
+ return ref
+
+
+def recetas_tarball():
+ """(total, [(receta, repo_derivado, ref_en_url, version)] de github, {host: n} de las que no)."""
+ total, gh, otros = 0, [], {}
+ 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()
+ m = re.search(r'^\s*tarball\s*=\s*"([^"]+)"', txt, re.M)
+ if not m:
+ continue
+ total += 1
+ url, rel = m.group(1), os.path.relpath(r, RAIZ)
+ mv = re.search(r'^\s*version\s*=\s*"([^"]+)"', txt, re.M)
+ g = GH_TARBALL.match(url)
+ if g:
+ gh.append((rel, 'https://github.com/%s/%s' % (g.group(1), g.group(2)),
+ _limpiar_ref(g.group(3)), mv.group(1) if mv else None))
+ else:
+ host = url.split('/')[2] if '://' in url else '(sin host)'
+ otros[host] = otros.get(host, 0) + 1
+ return total, gh, otros
+
+
+def mirar_tarball(item, timeout):
+ """Para un tarball de github: ¿hay un tag más nuevo que el que la URL descarga?"""
+ receta, repo, ref_url, version = item
+ env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/true')
+ r = {'receta': receta, 'repo': repo, 'ref_en_url': ref_url, 'version': version,
+ 'fuente': 'tarball-github'}
+ try:
+ p = subprocess.run(['git', 'ls-remote', '--tags', '--refs', repo],
+ capture_output=True, text=True, timeout=timeout, env=env)
+ except subprocess.TimeoutExpired:
+ r['estado'] = 'timeout'
+ return r
+ if p.returncode != 0:
+ r['estado'] = 'inalcanzable'
+ err = (p.stderr or '').strip().splitlines()
+ r['error'] = err[-1][:160] if err else None
+ return r
+ tags = {}
+ for linea in p.stdout.splitlines():
+ try:
+ sha, refname = linea.split('\t', 1)
+ except ValueError:
+ continue
+ mt = TAG.match(refname)
+ if mt:
+ tags[mt.group(1)] = sha
+ estables = {t: v for t, v in tags.items() if not PRE.search(t)}
+ usar = estables or tags
+ r['tags'] = len(tags)
+ if not usar:
+ # Sin tags no hay con qué comparar, y decir «al día» sería inventarlo.
+ r['estado'] = 'sin-tags'
+ return r
+ # ⚠ Si la URL descarga por SHA en vez de por tag, NO se compara: un sha no tiene orden de
+ # versión, así que afirmar que está atrasado —o al día— sería adivinar.
+ if SOLO_SHA.match(ref_url):
+ r['ultimo_tag'] = max(usar, key=clave)
+ r['estado'] = 'pin-por-sha'
+ return r
+ # Sólo la MISMA SERIE (ver `forma`): comparar contra el tag más nuevo del repo sin mirar su
+ # serie daba «cronie-1.7.2 → v4.3» y «2.18.0 → 20060301-alt06». Lo descartado se informa.
+ misma = {t: v for t, v in usar.items() if forma(t) == forma(ref_url)}
+ r['tags_de_la_serie'] = len(misma)
+ _pref, _n = forma(ref_url)
+ r['serie'] = "prefijo '%s' · %d numero(s)" % (_pref, _n)
+ if not misma:
+ # El repo no publica NINGÚN tag con la forma que esta receta descarga. Puede ser que la
+ # receta use un tag raro, o que upstream cambiara de esquema. No se afirma nada.
+ r['ultimo_tag_global'] = max(usar, key=clave)
+ r['estado'] = 'sin-serie-comparable'
+ return r
+ ult = max(misma, key=clave)
+ r['ultimo_tag'] = ult
+ # Comparación NUMÉRICA, no textual: `v1.2.10` vs `v1.2.9` miente alfabéticamente. Es el mismo
+ # motivo por el que `clave()` existe para los tags del bloque de arriba.
+ r['al_dia'] = clave(ref_url) >= clave(ult)
+ r['estado'] = 'ok'
+ return r
+
+
def mirar(item, timeout):
receta, repo, commit, version = item
env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/true')
@@ -123,11 +264,17 @@ def main():
items = recetas_git()
if a.only:
items = [i for i in items if a.only in i[1]]
+ tb_total, tb_gh, tb_otros = recetas_tarball()
+ if a.only:
+ tb_gh = [i for i in tb_gh if a.only in i[1]]
t0 = time.time()
res = []
with cf.ThreadPoolExecutor(max_workers=a.jobs) as ex:
for r in ex.map(lambda i: mirar(i, a.timeout), items):
res.append(r)
+ # Los tarballs de github van por el MISMO pool y el mismo `ls-remote`: son la misma
+ # pregunta con la pista leída de la URL en vez del campo `repo`.
+ res_tb = list(ex.map(lambda i: mirar_tarball(i, a.timeout), tb_gh))
seg = int(time.time() - t0)
ok = [r for r in res if r['estado'] == 'ok']
@@ -140,9 +287,40 @@ def main():
print(' %d con la rama movida desde el pin' % len(atras_rama))
if inalcanzables:
print(' ⚠ %d no se pudieron consultar' % len(inalcanzables))
- tb = recetas_tarball()
- print(' · %d recetas pinean un TARBALL y esta vigía no las mira: no hay forma general de' % tb)
- print(' preguntarle a un tarball si hay una versión nueva (hace falta una pista por receta)')
+ tb_ok = [r for r in res_tb if r['estado'] == 'ok']
+ tb_atras = [r for r in tb_ok if not r.get('al_dia')]
+ tb_sha = [r for r in res_tb if r['estado'] == 'pin-por-sha']
+ tb_mudos = [r for r in res_tb if r['estado'] in ('sin-tags', 'inalcanzable', 'timeout')]
+ tb_sin_serie = [r for r in res_tb if r['estado'] == 'sin-serie-comparable']
+ # ⚠ Lo que queda fuera se cuenta por lo NO CLASIFICADO, no restando lo consultado: con
+ # `--only` activo la resta daba 625 mientras el desglose por host sumaba 429, y un total que
+ # no cuadra con su propio desglose hace dudar del informe entero.
+ tb_fuera = sum(tb_otros.values())
+ print('\n== tarballs: %d receta(s) con tarball ==' % tb_total)
+ print(' %d de github CONSULTADAS (la URL trae el repo y la versión ⇒ sirve el mismo ls-remote)'
+ % len(res_tb))
+ print(' %d con una versión más nueva que la que descargan' % len(tb_atras))
+ if tb_sha:
+ print(' %d pinean por SHA ⇒ no se comparan: un sha no tiene orden de versión' % len(tb_sha))
+ if tb_sin_serie:
+ # Se dice en vez de desaparecer: la regla de la serie sesga hacia el silencio, y un sesgo
+ # que no se cuenta se lee como «todo comprobado».
+ print(' %d sin tag de la MISMA serie que la URL ⇒ no se comparan (ver `forma`)'
+ % len(tb_sin_serie))
+ if tb_mudos:
+ print(' %d sin tags o inalcanzables ⇒ no se afirma nada' % len(tb_mudos))
+ # ⚠ Lo que queda FUERA se nombra por host, no se resume en un número: «400 sin mirar» se lee
+ # como silencio, y «108 en download.kde.org» se lee como la próxima unidad de trabajo.
+ print(' %d FUERA del radar todavía, por host:' % tb_fuera)
+ for host, n in sorted(tb_otros.items(), key=lambda kv: -kv[1])[:8]:
+ print(' %4d %s' % (n, host))
+ resto = sum(n for _h, n in sorted(tb_otros.items(), key=lambda kv: -kv[1])[8:])
+ if resto:
+ print(' %4d (en otros %d hosts)' % (resto, max(0, len(tb_otros) - 8)))
+ if tb_atras:
+ print('\n tarballs con versión nueva (los 20 primeros):')
+ for r in sorted(tb_atras, key=lambda x: x['receta'])[:20]:
+ print(' %-42s baja %-14s hay %s' % (r['receta'], r['ref_en_url'], r['ultimo_tag']))
# ⚠ NO se imprime «version → tag», y es a propósito: en un MONOREPO (tawasuyu son 37 recetas
# sobre el mismo repo) el tag es del repositorio y el `version` es el de la caja concreta, así
@@ -159,12 +337,24 @@ def main():
if a.json:
with open(a.json, 'w', encoding='utf-8') as f:
- json.dump({'schema': 'takana-upstream/1',
+ # schema /2: se AÑADEN las claves de tarball y se conservan las de /1 —
+ # `tarballs_sin_mirar` incluido— porque el resumen de `cosecha-cron.sh` las lee y
+ # renombrarlas rompería al consumidor sin avisar. Lo que cambia es su significado a
+ # mejor: antes era «todos los tarballs», ahora es «los que siguen fuera del radar».
+ json.dump({'schema': 'takana-upstream/2',
'total': len(res), 'segundos': seg,
'con_tag_nuevo': len(atras_tag), 'con_rama_movida': len(atras_rama),
'inalcanzables': len(inalcanzables),
- 'tarballs_sin_mirar': tb,
- 'detalle': sorted(res, key=lambda x: x['receta'])}, f,
+ 'tarballs_total': tb_total,
+ 'tarballs_consultados': len(res_tb),
+ 'tarballs_con_version_nueva': len(tb_atras),
+ 'tarballs_sin_serie_comparable': len(tb_sin_serie),
+ 'tarballs_pin_por_sha': len(tb_sha),
+ 'tarballs_mudos': len(tb_mudos),
+ 'tarballs_sin_mirar': tb_fuera,
+ 'tarballs_fuera_por_host': dict(sorted(tb_otros.items(), key=lambda kv: -kv[1])),
+ 'detalle': sorted(res, key=lambda x: x['receta']),
+ 'detalle_tarballs': sorted(res_tb, key=lambda x: x['receta'])}, f,
ensure_ascii=False, indent=1)
print('\n informe: %s' % a.json)
# Sale 0 siempre: esto INFORMA. Un cron que falle porque upstream sacó una versión nueva sería