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…
⎇ 9aa43deb69f12b369c1f2f904abe3f55a397ab2f
scripts/fuentes/upstream-vigia.py
History
Who touched what
raw
1#!/usr/bin/env python3
2# upstream-vigia.py — ¿qué se movió RÍO ARRIBA desde que pineamos?
3#
4# ══ QUÉ CONTESTA, Y QUÉ NO ════════════════════════════════════════════════════════════════════
5# `fuentes-vigia.sh` pregunta si la fuente SIGUE VIVA (¿responde la URL?). Ésta pregunta otra cosa:
6# **¿hay algo más nuevo que lo que tenemos clavado?** Son las dos mitades del mismo problema y se
7# confunden: una fuente viva puede llevar dos años sin tocarse, y una que se movió ayer puede estar
8# muerta mañana.
9#
10# **No modifica ninguna receta.** Informa. Quién decide aplicar un salto de versión —y con qué
11# pruebas— es la política del SDD 33, no este guion: una versión nueva de upstream puede cambiar el
12# sistema de build, las banderas, las dependencias y el resultado, y eso no se automatiza a ciegas
13# para código ajeno.
14#
15# ── QUÉ SE CUBRE, Y POR QUÉ ESTO YA NO ES «SÓLO GIT» ──────────────────────────────────────────
16# `git ls-remote` funciona contra CUALQUIER servidor git sin API, sin token y sin clonar — es lo
17# mismo que ya hace `takana pin`. Con eso se cubren las 660 recetas que pinean `repo` + `commit`.
18#
19# ⚠ **Y TAMBIÉN 196 DE LAS 625 QUE PINEAN TARBALL** (2026-10-07). La primera versión de este guion
20# decía que un tarball «no tiene equivalente general» y que saber si hay una versión nueva «pide
21# una pista por receta». Es cierto en general y era **falso para el grueso**: medido, 196 de los
22# 625 tarballs son de `github.com`, y en esas URLs el repo y la versión YA ESTÁN ESCRITOS
23# (`.../archive/refs/tags/v1.2.3.tar.gz`, `.../releases/download/1.10/sway-1.10.tar.gz`). No hacía
24# falta API, ni token, ni pista por receta: hacía falta LEER LA URL y reusar el `ls-remote` que ya
25# estaba acá. Cubrirlas no costó un mecanismo nuevo.
26#
27# Lo que SIGUE fuera son 429, y son índices de directorio con forma conocida: download.kde.org
28# (108), download.gnome.org (70), ftp.gnu.org (32), gitlab.freedesktop.org (26), xorg/x.org (29),
29# download.qt.io (12), kernel.org (18). Ésos piden **un listado por host**, no una pista por receta
30# — su propia unidad de trabajo. Se cuentan y **se nombran por host** en el informe: «429 sin
31# mirar» se lee como silencio, «108 en download.kde.org» se lee como la próxima tarea.
32#
33# ⚠ Y se prefiere CALLAR a adivinar. Tres cosas no se comparan nunca, y cada una se informa en vez
34# de desaparecer: las que bajan por SHA (un sha no tiene orden de versión), las que no tienen tag
35# de la MISMA serie que la URL (ver `forma`: evitó 9 falsos positivos medidos, del tipo
36# «cronie-1.7.2 → v4.3») y las que no tienen tags o están inalcanzables.
37#
38# Uso: scripts/fuentes/upstream-vigia.py [--json <fichero>] [--solo tawasuyu] [--jobs N]
39# La superficie va en INGLÉS (`CLAUDE.md` regla 4); lo que se lee, en castellano.
40import argparse, concurrent.futures as cf, datetime, glob, json, os, re, ssl, subprocess, sys
41import threading, time, urllib.parse, urllib.request
42
43RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
44
45
46def recetas_git():
47 """(receta, repo, commit, version) de cada receta que pinea un repo git."""
48 out = []
49 for r in sorted(set(glob.glob(os.path.join(RAIZ, 'recipes/*.toml'))
50 + glob.glob(os.path.join(RAIZ, 'recipes/*/*.toml')))):
51 txt = open(r, encoding='utf-8', errors='replace').read()
52 repo = re.search(r'^\s*repo\s*=\s*"([^"]+)"', txt, re.M)
53 com = re.search(r'^\s*commit\s*=\s*"([^"]+)"', txt, re.M)
54 ver = re.search(r'^\s*version\s*=\s*"([^"]+)"', txt, re.M)
55 if repo and com:
56 out.append((os.path.relpath(r, RAIZ), repo.group(1), com.group(1),
57 ver.group(1) if ver else None))
58 return out
59
60
61# Un tag de versión, sin `^{}` (el objeto desreferenciado que git lista aparte) ni prereleases.
62TAG = re.compile(r'^refs/tags/(v?\d+[\w.\-]*)$')
63PRE = re.compile(r'(?i)(alpha|beta|rc\d|-rc|dev|pre|snapshot)')
64
65
66def clave(t):
67 """Orden por versión: (1,2,10) va DESPUÉS de (1,2,9), que es donde el orden alfabético miente."""
68 nums = [int(x) for x in re.findall(r'\d+', t)[:5]]
69 return (nums + [0] * 5)[:5]
70
71
72# ── TARBALLS DE GITHUB: la mitad del punto ciego, y sin mecanismo nuevo ───────────────────────
73# El comentario de la cabecera decía que un tarball «no tiene equivalente general» y que haría
74# falta una pista por receta. Es cierto EN GENERAL y era falso para el grueso: medido el
75# 2026-10-07, de las **625 recetas con tarball, 196 son de github.com** — y para ésas la URL YA
76# CONTIENE el repo y la versión, así que el `git ls-remote --tags` que esta vigía ya hace las cubre
77# tal cual. No hacía falta API, ni token, ni pista: hacía falta LEER LA URL.
78#
79# Los otros hosts gordos son índices de directorio con forma conocida (download.kde.org 108,
80# download.gnome.org 70, ftp.gnu.org 32, x.org/xorg 29, kernel.org 18, qt.io 12). Ésos sí piden su
81# propia unidad de trabajo —un listado por host, no una pista por receta— y se CUENTAN Y SE DICEN
82# al final, que es lo que evita que las que quedan fuera del radar se lean como silencio.
83# ── TARBALLS DE FORJA: github Y gitlab, con el ls-remote que ya estaba ────────────────────────
84# El comentario de la cabecera decía que un tarball «no tiene equivalente general» y que haría
85# falta una pista por receta. Es cierto EN GENERAL y era falso para el grueso: de las 625 recetas
86# con tarball, **196 son de github.com** y **26 de gitlab.freedesktop.org**, y en esas URLs el
87# repo y la versión YA ESTÁN ESCRITOS. El `git ls-remote --tags` que esta vigía ya hacía las cubre
88# tal cual: sin API, sin token, sin pista. No hacía falta un mecanismo; hacía falta LEER LA URL.
89#
90# GitLab se reconoce por el `/-/`, que es inequívoco: todo lo que va antes es la ruta del proyecto,
91# así que vale para cualquier instancia (freedesktop, gnome, salsa, gitlab.com), no sólo las que
92# hoy aparecen en el catálogo.
93GH_TARBALL = re.compile(
94 r'^https?://(?:www\.)?(?:github\.com|codeload\.github\.com)/'
95 r'([^/]+)/([^/]+?)(?:\.git)?/'
96 r'(?:archive/(?:refs/tags/)?|releases/download/|tar\.gz/|zipball/|tarball/)'
97 r'(.+)$')
98GL_TARBALL = re.compile(
99 r'^(https?://[^/]+)/(.+?)/-/'
100 r'(?:archive/|releases/)'
101 r'(.+)$')
102SUFIJOS = ('.tar.gz', '.tar.xz', '.tar.bz2', '.tar.zst', '.tgz', '.txz', '.tbz2', '.zip', '.tar')
103SOLO_SHA = re.compile(r'^[0-9a-f]{7,40}$')
104
105
106def _forja(url):
107 """(repo, ref) si la URL es un tarball de forja git; None si no."""
108 g = GH_TARBALL.match(url)
109 if g:
110 return 'https://github.com/%s/%s' % (g.group(1), g.group(2)), _limpiar_ref(g.group(3))
111 g = GL_TARBALL.match(url)
112 if g:
113 return '%s/%s' % (g.group(1), g.group(2)), _limpiar_ref(g.group(3))
114 return None
115
116
117# ⚠ UN SEGUNDO REGEX DE TAG, MÁS LAXO, Y SÓLO PARA EL CAMINO DE LOS TARBALLS (2026-10-09).
118# `TAG` exige que el tag empiece por dígito (con `v` opcional), y eso descarta los esquemas más
119# comunes del mundo real: `llvmorg-18.1.8`, `fuse-3.16.2`, `R_2_6_0`, `release-74-2`. Medido: **53
120# recetas de tarball salían «sin-tags» contra repos que tienen cientos** —llvm-project, libexpat,
121# icu, libfuse— porque el filtro las tiraba antes de mirarlas.
122#
123# Por qué laxo acá y estricto allá, que es la parte que importa: en el camino del tarball **la URL
124# nos dice la forma que buscamos** (`llvmorg-18.1.8`), así que `forma()` acota la comparación a esa
125# serie y un regex amplio no puede colar basura — sólo compite lo que tiene el mismo prefijo y la
126# misma cantidad de números. En el camino de `repo`+`commit` no hay URL que diga la forma, así que
127# ahí la estrechez de `TAG` es la protección y se deja como está: aflojarla dejaría que un tag raro
128# gane el `max()` y cambiaría `al_dia` en 660 recetas que hoy se miden bien.
129TAG_LIBRE = re.compile(r'^refs/tags/([A-Za-z_.\-]{0,24}\d[\w.\-]*)$')
130
131
132def forma(t):
133 """(prefijo, cuántos números) de un tag. Es la 'serie' a la que pertenece.
134
135 ⚠ EXISTE POR LOS FALSOS POSITIVOS, Y SON MEDIDOS (2026-10-07). Comparar el tag más nuevo del
136 repo contra el que la URL descarga, sin más, produce basura en cuanto el repo publica tags de
137 varias series:
138 cronie baja `cronie-1.7.2` → decía «hay v4.3» (otra serie entera)
139 bash-completion baja `2.18.0` → decía «hay 20060301-alt06» (tag de empaquetado ajeno)
140 gitea baja `v1.27.0` → decía «hay v28.1.0`
141 Y un guardián cuyos hallazgos son casi todos falsos no se apaga: se deja de leer, y entonces
142 tampoco se ve el hallazgo verdadero. Por eso acá se prefiere CALLAR a adivinar.
143
144 La regla es la más simple que se puede explicar: sólo se comparan tags con el MISMO prefijo no
145 numérico y la MISMA cantidad de componentes numéricos. `v10.3.2` sólo contra `v#.#.#`,
146 `cronie-1.7.2` sólo contra `cronie-#.#.#`. Sesga hacia el silencio a propósito — un salto
147 `1.2` → `1.2.1` queda fuera —, y por eso lo que se descarta se CUENTA y se DICE en el informe
148 en vez de desaparecer.
149 """
150 pref = re.match(r'^[^0-9]*', t).group(0)
151 return (pref, len(re.findall(r'\d+', t)))
152
153
154def _limpiar_ref(ref):
155 """De la cola de la URL al tag: se queda con el primer segmento (`releases/download/<tag>/<fichero>`
156 trae el fichero detrás) y le quita el sufijo del archivo."""
157 ref = ref.split('/')[0]
158 for suf in SUFIJOS:
159 if ref.endswith(suf):
160 ref = ref[: -len(suf)]
161 break
162 return ref
163
164
165def recetas_tarball():
166 """(total, [(receta, repo, ref, version)] de forja, [(receta, url, version)] del resto,
167 {host: n} del resto). El `resto` NO se descarta: va al sondeador de índices."""
168 total, forja, resto, por_host = 0, [], [], {}
169 for r in sorted(set(glob.glob(os.path.join(RAIZ, 'recipes/*.toml'))
170 + glob.glob(os.path.join(RAIZ, 'recipes/*/*.toml')))):
171 txt = open(r, encoding='utf-8', errors='replace').read()
172 m = re.search(r'^\s*tarball\s*=\s*"([^"]+)"', txt, re.M)
173 if not m:
174 continue
175 total += 1
176 url, rel = m.group(1), os.path.relpath(r, RAIZ)
177 mv = re.search(r'^\s*version\s*=\s*"([^"]+)"', txt, re.M)
178 ver = mv.group(1) if mv else None
179 f = _forja(url)
180 if f:
181 forja.append((rel, f[0], f[1], ver))
182 else:
183 resto.append((rel, url, ver))
184 host = url.split('/')[2] if '://' in url else '(sin host)'
185 por_host[host] = por_host.get(host, 0) + 1
186 return total, forja, resto, por_host
187
188
189# ── RECETAS ANCLADAS POR SU PROPIO NOMBRE ─────────────────────────────────────────────────────
190# `clang18`, `llvm21`, `lld21`, `gtk3`, `python3`: el nombre LLEVA el major, y eso no es un
191# descuido — es la decisión de quedarse en esa rama. Decirle a `clang18` que existe
192# `llvmorg-23.1.3` es cierto y es **inaccionable**: si se adoptara, la receta debería llamarse
193# `clang23`, que es otra receta.
194#
195# Medido el 2026-10-09: sin esto, el frente llvm aportaba 5 hallazgos de salto de major sobre
196# recetas cuyo nombre dice explícitamente que no van a saltar. No se ocultan —siguen en el detalle
197# del json— pero se cuentan aparte, porque un informe que mezcla «hay que mirar esto» con «esto es
198# así a propósito» obliga a filtrar a mano cada vez, y lo que se filtra a mano se deja de leer.
199def anclada_por_nombre(receta, version_actual, version_nueva):
200 """¿La receta fija su major en el nombre, y el hallazgo lo cruza?"""
201 if not (version_actual and version_nueva):
202 return False
203 m = re.match(r'^(.*?)(\d+)$', os.path.basename(receta).rsplit('.toml', 1)[0])
204 if not m:
205 return False
206 nombre_major = m.group(2)
207 act = re.findall(r'\d+', version_actual)
208 nue = re.findall(r'\d+', version_nueva)
209 if not (act and nue):
210 return False
211 # El número del nombre tiene que ser el major que la receta usa HOY — si no, el nombre habla de
212 # otra cosa (p.ej. `libxcb2` no es un major) y esto no aplica.
213 return act[0] == nombre_major and nue[0] != act[0]
214
215
216def mirar_tarball(item, timeout):
217 """Para un tarball de github: ¿hay un tag más nuevo que el que la URL descarga?"""
218 receta, repo, ref_url, version = item
219 env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/true')
220 r = {'receta': receta, 'repo': repo, 'ref_en_url': ref_url, 'version': version,
221 'fuente': 'tarball-github'}
222 try:
223 p = subprocess.run(['git', 'ls-remote', '--tags', '--refs', repo],
224 capture_output=True, text=True, timeout=timeout, env=env)
225 except subprocess.TimeoutExpired:
226 r['estado'] = 'timeout'
227 return r
228 if p.returncode != 0:
229 r['estado'] = 'inalcanzable'
230 err = (p.stderr or '').strip().splitlines()
231 r['error'] = err[-1][:160] if err else None
232 return r
233 tags = {}
234 for linea in p.stdout.splitlines():
235 try:
236 sha, refname = linea.split('\t', 1)
237 except ValueError:
238 continue
239 mt = TAG_LIBRE.match(refname)
240 if mt:
241 tags[mt.group(1)] = sha
242 estables = {t: v for t, v in tags.items() if not PRE.search(t)}
243 usar = estables or tags
244 r['tags'] = len(tags)
245 if not usar:
246 # Sin tags no hay con qué comparar, y decir «al día» sería inventarlo.
247 r['estado'] = 'sin-tags'
248 return r
249 # ⚠ Si la URL descarga por SHA en vez de por tag, NO se compara: un sha no tiene orden de
250 # versión, así que afirmar que está atrasado —o al día— sería adivinar.
251 if SOLO_SHA.match(ref_url):
252 r['ultimo_tag'] = max(usar, key=clave)
253 r['estado'] = 'pin-por-sha'
254 return r
255 # Sólo la MISMA SERIE (ver `forma`): comparar contra el tag más nuevo del repo sin mirar su
256 # serie daba «cronie-1.7.2 → v4.3» y «2.18.0 → 20060301-alt06». Lo descartado se informa.
257 misma = {t: v for t, v in usar.items() if forma(t) == forma(ref_url)}
258 r['tags_de_la_serie'] = len(misma)
259 _pref, _n = forma(ref_url)
260 r['serie'] = "prefijo '%s' · %d numero(s)" % (_pref, _n)
261 if not misma:
262 # El repo no publica NINGÚN tag con la forma que esta receta descarga. Puede ser que la
263 # receta use un tag raro, o que upstream cambiara de esquema. No se afirma nada.
264 r['ultimo_tag_global'] = max(usar, key=clave)
265 r['estado'] = 'sin-serie-comparable'
266 return r
267 ult = max(misma, key=clave)
268 r['ultimo_tag'] = ult
269 # Comparación NUMÉRICA, no textual: `v1.2.10` vs `v1.2.9` miente alfabéticamente. Es el mismo
270 # motivo por el que `clave()` existe para los tags del bloque de arriba.
271 r['al_dia'] = clave(ref_url) >= clave(ult)
272 if not r['al_dia'] and anclada_por_nombre(receta, ref_url, ult):
273 r['anclada_por_nombre'] = True
274 r['estado'] = 'ok'
275 return r
276
277
278# ══ SEGUNDA MITAD DEL PUNTO CIEGO: LOS ÍNDICES DE DIRECTORIO (2026-10-09) ═════════════════════
279# Tras cubrir github, quedaban 429 tarballs, y la medición dijo que NO están dispersos: 108 en
280# download.kde.org, 70 en download.gnome.org, 32 en ftp.gnu.org, 29 en xorg/x.org, 26 en
281# gitlab.freedesktop.org, 18 en kernel.org, 12 en qt.io. Dos mecanismos los cubren casi todos, y
282# el primero no es nuevo:
283#
284# 1. **gitlab.freedesktop.org es una FORJA GIT.** Sus URLs `/-/archive/<tag>/<fichero>` llevan el
285# repo escrito igual que las de github ⇒ el mismo `ls-remote`. Cero mecanismo nuevo. (+26)
286# 2. **El resto son índices de directorio servidos por HTTP.** `https://ftp.gnu.org/gnu/bash/`
287# lista `bash-5.3.tar.gz`; `https://download.kde.org/stable/frameworks/` lista `6.26/ 6.27/`.
288# Un sondeador genérico los lee. (+~230)
289#
290# ⚠ **EL PYTHON DEL CORPUS NO ENCUENTRA EL BUNDLE DE CA, y falla con `CERTIFICATE_VERIFY_FAILED`**
291# (medido 2026-10-09 en la jaula). Es el mismo patrón que el `GIT_EXEC_PATH` de la regla 2 de
292# CLAUDE.md: el binario está bien, lo que falta es decirle dónde están sus cosas. En el ANFITRIÓN
293# —donde corre el latido— el python del sistema funciona sin tocar nada, así que esto se descubre
294# y sólo se fija si hace falta: cablear la ruta rompería en la otra caja.
295# ⚠ ══ EL LÍMITE DEL MÉTODO POR ÍNDICE, DICHO ANTES DE USARLO ═══════════════════════════════════
296# Un índice de directorio contesta **«¿hay un número mayor?»**, y eso NO es lo mismo que «hay una
297# release más nueva». La diferencia no es teórica, es el caso que la encontró (2026-10-09):
298#
299# `recipes/gtk3.toml` baja de `sources/gtk+/3.24/`, y ese directorio TODAVÍA guarda las series
300# `3.89`…`3.96` — las alfas de GTK4, de 2017. `3.94 > 3.24` es cierto numéricamente y falso
301# semánticamente: no hay ningún GTK3 más nuevo que 3.24.
302#
303# Y **ninguna regla numérica barata lo distingue**: 94 es par (así que el par/impar de GNOME no lo
304# toca), está dentro del mismo major, y el directorio `3.94/` contiene un `gtk+-3.94.0.tar.xz` de
305# verdad, así que verificar que el fichero existe tampoco ayuda.
306#
307# ⇒ Por eso los hallazgos por índice se informan como **INDICIO**, en su propio recuento y con su
308# propia palabra, y no se suman a los de forja como si fueran la misma cosa. Un tag con su filtro de
309# prereleases es evidencia; un directorio con número mayor es una pista que alguien tiene que mirar.
310# Sobrevalorarlo es la forma de que 160 indicios buenos se tiren a la basura junto con los malos.
311#
312# Lo que SÍ se filtra, porque es una convención documentada y no una corazonada: las series de
313# minor IMPAR de download.gnome.org son inestables (`glib 2.89` lo es; `2.90` no). Quita ruido real
314# sin inventar nada.
315#
316# La pista que queda sin usar, para quien siga: los índices de Apache traen la FECHA de cada
317# entrada, y `3.24/` está tocado hace meses mientras `3.94/` lleva ocho años quieto. Ordenar por
318# fecha en vez de por número resolvería justo este caso. No se hace acá porque pide parsear el
319# formato de cada índice, y eso es su propia unidad de trabajo.
320GNOME_INESTABLE = re.compile(r'^\d+\.(\d+)$')
321
322
323def _serie_descartable(host, nombre):
324 """¿Esta serie es inestable por convención del host? Sólo se afirma donde está documentado."""
325 if 'download.gnome.org' in host:
326 m = GNOME_INESTABLE.match(nombre)
327 if m and int(m.group(1)) % 2 == 1:
328 return True
329 return False
330
331
332CANDIDATOS_CA = ('/etc/ssl/certs/ca-certificates.crt', '/etc/ssl/cert.pem',
333 '/etc/ca-certificates/extracted/tls-ca-bundle.pem',
334 '/etc/pki/tls/certs/ca-bundle.crt')
335
336
337def _contexto_tls():
338 """Contexto TLS que verifica, con el bundle del sistema si el de serie no sirve.
339
340 NO se desactiva la verificación en ningún camino: un índice bajado sin verificar puede decir
341 que hay una versión nueva que no existe, y esta vigía sólo vale si lo que informa es cierto.
342 """
343 ctx = ssl.create_default_context()
344 try:
345 # Sonda baratísima contra el propio almacén: si no cargó NADA, no va a verificar nada.
346 if ctx.cert_store_stats().get('x509_ca', 0) > 0:
347 return ctx
348 except Exception:
349 pass
350 for c in CANDIDATOS_CA:
351 if os.path.exists(c):
352 try:
353 return ssl.create_default_context(cafile=c)
354 except Exception:
355 continue
356 return ctx # se devuelve el de serie: fallará ruidosamente, que es mejor que no verificar
357
358
359CTX = None # se crea una vez en main()
360IDX_CACHE = {}
361IDX_LOCK = threading.Lock()
362HREF = re.compile(r'(?i)href="([^"?#]+)"')
363# Un segmento de ruta que ES una versión: `6.27`, `25.04.3`, `1.7`, `v1.2`. No `src`, no `individual`.
364SEG_VER = re.compile(r'^v?\d+(?:\.\d+)*$')
365
366
367def _bajar_indice(url, timeout):
368 """El listado de un directorio, CACHEADO por URL — y la caché es lo que lo hace viable: las 108
369 recetas de KDE comparten 3 directorios y las 29 de xorg comparten uno. Sin caché serían 429
370 peticiones; con caché son unas pocas docenas."""
371 with IDX_LOCK:
372 if url in IDX_CACHE:
373 return IDX_CACHE[url]
374 datos = None
375 try:
376 req = urllib.request.Request(url, headers={'User-Agent': 'takana-upstream-vigia'})
377 with urllib.request.urlopen(req, timeout=timeout, context=CTX) as f:
378 datos = f.read(4_000_000).decode('utf-8', 'replace')
379 except Exception:
380 # No se distingue «no existe» de «no se pudo bajar», y decir cuál sería inventarlo.
381 datos = None
382 with IDX_LOCK:
383 IDX_CACHE[url] = datos
384 return datos
385
386
387def _nombres(html):
388 """Los nombres de entrada de un índice. Vale para los índices de Apache/nginx y para cualquier
389 página con enlaces: se queda con el último segmento del href y le quita la barra final."""
390 fuera = set()
391 for h in HREF.findall(html):
392 n = urllib.parse.unquote(h.rstrip('/')).split('/')[-1]
393 if n and n not in ('..', '.'):
394 fuera.add(n)
395 return fuera
396
397
398# ══ LA FECHA DEL ÍNDICE: CÓMO UN INDICIO SE VUELVE EVIDENCIA (2026-10-09) ═════════════════════
399# El sondeo por índice contestaba «¿hay un número mayor?», y eso no es «hay una release más nueva».
400# El caso que lo probaba era `gtk3`, que baja de `sources/gtk+/3.24/` mientras ese directorio aún
401# guarda las alfas de GTK4. **La fecha lo resuelve, y está en el índice todo el tiempo:**
402#
403# 3.24/ 2024-Jul-10 17:49 ← lo que usamos
404# 3.94/ 2018-Jun-26 11:58 ← seis años MÁS VIEJO: rama muerta, no versión nueva
405# 3.89/ 2017-Mar-14 00:36
406#
407# La regla, que es la que upstream no puede violar sin querer: **nadie vuelve a tocar una rama
408# abandonada.** Si lo que usamos hoy se modificó DESPUÉS que el candidato «más nuevo», el candidato
409# está muerto. Comprobado en el otro sentido con KDE, donde el hallazgo es real y la fecha lo
410# confirma: `6.27/` es de 2026-06-11 y `6.30/` de 2026-09-04.
411#
412# Tres formatos en tres servidores, y hay que cubrir los tres o la regla no se aplica donde más
413# falta: `2026-06-11 10:12` (KDE, ftp.gnu.org), `2024-Jul-10 17:49` (download.gnome.org),
414# `15-Sep-2025 10:23` (autoindex clásico de Apache).
415MESES = {m: i for i, m in enumerate(
416 ('jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'), 1)}
417FECHAS = (
418 re.compile(r'(\d{4})-(\d{2})-(\d{2})'), # 2026-06-11
419 re.compile(r'(?i)(\d{4})-([a-z]{3})-(\d{2})'), # 2024-Jul-10
420 re.compile(r'(?i)(\d{2})-([a-z]{3})-(\d{4})'), # 15-Sep-2025
421)
422
423
424def _fecha(txt):
425 """(año, mes, día) de la primera fecha reconocible, o None. Tupla y no `datetime` a propósito:
426 sólo se usa para ORDENAR, y un orden lexicográfico de enteros basta y no puede fallar por zona
427 horaria ni por un formato raro."""
428 m = FECHAS[0].search(txt)
429 if m:
430 return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
431 m = FECHAS[1].search(txt)
432 if m and m.group(2).lower() in MESES:
433 return (int(m.group(1)), MESES[m.group(2).lower()], int(m.group(3)))
434 m = FECHAS[2].search(txt)
435 if m and m.group(2).lower() in MESES:
436 return (int(m.group(3)), MESES[m.group(2).lower()], int(m.group(1)))
437 return None
438
439
440def _entradas(html):
441 """{nombre: fecha|None} de un índice. La fecha se busca en los 200 caracteres QUE SIGUEN al
442 href, que es donde la ponen los tres autoindex; no se parte por líneas porque hay servidores
443 que mandan el índice entero en una."""
444 fuera = {}
445 for m in HREF.finditer(html):
446 n = urllib.parse.unquote(m.group(1).rstrip('/')).split('/')[-1]
447 if not n or n in ('..', '.'):
448 continue
449 # El primer href gana: si el mismo nombre aparece dos veces (icono + enlace), la fecha
450 # buena es la de la fila, y la fila empieza en el primero.
451 if n not in fuera:
452 fuera[n] = _fecha(html[m.end(): m.end() + 200])
453 return fuera
454
455
456# ⚠ **«MÁS VIEJO» NO ALCANZA: TIENE QUE SER MUCHO MÁS VIEJO, Y ESTO ES UN FALSO NEGATIVO MEDIDO.**
457# La primera versión de esta regla descartaba el candidato en cuanto su fecha fuera ANTERIOR a la de
458# lo que usamos. Rompía así:
459#
460# glib: la serie `2.90/` salió el 2026-Oct-06 — es la nueva de verdad, con 2.90.0 y 2.90.1
461# dentro— y `2.88/` recibió un parche (2.88.4) el 2026-Oct-08, DOS DÍAS DESPUÉS.
462# ⇒ la regla estricta tiraba la serie nueva porque la de MANTENIMIENTO se tocó más tarde.
463#
464# Una rama que sigue recibiendo parches es normal y no debe tapar una serie nueva; una rama
465# ABANDONADA lleva años quieta (`gtk+/3.94/` es de 2018 contra `3.24/` de 2024). Entre dos días y
466# seis años hay sitio de sobra, así que el umbral no está calibrado contra ninguno de los dos casos
467# — que es justo lo que [[umbral-a-mano-envejece]] avisa de no hacer. Un año: lejos de los dos
468# bordes, y explicable en una frase.
469DIAS_ABANDONO = 365
470
471
472def _no_es_rama_muerta(fechas, actual, candidato):
473 """¿El candidato está vivo, o es una rama abandonada con número mayor?
474
475 Devuelve (veredicto, con_fecha). `con_fecha=False` significa que no se pudo comprobar: el
476 hallazgo sigue valiendo como INDICIO, pero no se asciende a evidencia. **Nunca se descarta por
477 falta de fecha**, que sería perder información real por no tener la corroboración.
478 """
479 fa, fc = fechas.get(actual), fechas.get(candidato)
480 if fa is None or fc is None:
481 return True, False
482 dia_a = datetime.date(*fa).toordinal()
483 dia_c = datetime.date(*fc).toordinal()
484 return (dia_a - dia_c) <= DIAS_ABANDONO, True
485
486
487def _partir(nombre):
488 """`bash-5.3.tar.gz` → ('bash', '5.3'). None si no tiene forma de <algo>-<versión><sufijo>."""
489 for suf in SUFIJOS:
490 if nombre.endswith(suf):
491 base = nombre[: -len(suf)]
492 m = re.match(r'^(.*?)[-_](v?\d[\w.]*?)$', base)
493 return (m.group(1), m.group(2)) if m else None
494 return None
495
496
497def mirar_indice(item, timeout):
498 """¿Hay algo más nuevo en el índice del que este tarball cuelga?
499
500 Dos sondeos, y se informa el mayor de los dos:
501 (a) EN SU PROPIO DIRECTORIO, un `<mismo-nombre>-<versión-mayor>`. Cubre ftp.gnu.org, xorg,
502 kernel.org: un directorio por paquete con todas sus versiones.
503 (b) UNA SERIE MÁS NUEVA. Cubre KDE y GNOME, donde la versión está en un SEGMENTO DE LA RUTA
504 (`stable/frameworks/6.27/`, `sources/libadwaita/1.7/`) y el directorio propio sólo
505 tiene la serie vieja — mirarlo diría «al día» para siempre.
506
507 Y los dos pasan por el filtro de FECHA (`_no_es_rama_muerta`): un número mayor con fecha
508 ANTERIOR a la que usamos es una rama abandonada, no una versión nueva. Si no hay fecha el
509 hallazgo no se descarta —sería perder información real— pero no se asciende a evidencia.
510 """
511 receta, url, version = item
512 r = {'receta': receta, 'tarball': url, 'version': version, 'fuente': 'indice'}
513 dir_url, _, fichero = url.rpartition('/')
514 partido = _partir(fichero)
515 if not partido:
516 # Sin `<nombre>-<versión>` en el fichero no hay con qué comparar. Pasa con
517 # files.pythonhosted.org, cuyas URLs llevan un hash en la ruta.
518 r['estado'] = 'fichero-no-versionado'
519 return r
520 stem, ver_fichero = partido
521 r['stem'], r['version_en_url'] = stem, ver_fichero
522 hallazgos = []
523 muertas = 0
524
525 # (a) mismo directorio
526 html = _bajar_indice(dir_url + '/', timeout)
527 if html:
528 ent = _entradas(html)
529 # Las fechas de los FICHEROS, indexadas por su versión, para poder comparar fecha a fecha.
530 fechas_ver, cand = {}, []
531 for n, f in ent.items():
532 pp = _partir(n)
533 if pp and pp[0] == stem and forma(pp[1]) == forma(ver_fichero):
534 cand.append(pp[1])
535 if pp[1] not in fechas_ver or f:
536 fechas_ver[pp[1]] = f
537 if cand:
538 mejor = max(cand, key=clave)
539 r['mejor_en_su_dir'] = mejor
540 if clave(mejor) > clave(ver_fichero):
541 vivo, con_fecha = _no_es_rama_muerta(fechas_ver, ver_fichero, mejor)
542 if vivo:
543 hallazgos.append(('fichero', mejor, con_fecha))
544 else:
545 muertas += 1
546
547 # (b) serie más nueva: el segmento de versión más EXTERNO de la ruta
548 partes = urllib.parse.urlparse(url)
549 segs = partes.path.split('/')
550 idx = next((i for i, s in enumerate(segs[:-1]) if SEG_VER.match(s)), None)
551 if idx is not None:
552 padre = '%s://%s%s/' % (partes.scheme, partes.netloc, '/'.join(segs[:idx]))
553 html2 = _bajar_indice(padre, timeout)
554 if html2:
555 ent2 = _entradas(html2)
556 series = [n for n in ent2
557 if SEG_VER.match(n) and forma(n) == forma(segs[idx])
558 and not _serie_descartable(partes.netloc, n)]
559 if series:
560 mejor_s = max(series, key=clave)
561 r['serie_en_url'] = segs[idx]
562 r['mejor_serie'] = mejor_s
563 if clave(mejor_s) > clave(segs[idx]):
564 vivo, con_fecha = _no_es_rama_muerta(ent2, segs[idx], mejor_s)
565 if vivo:
566 hallazgos.append(('serie', mejor_s, con_fecha))
567 else:
568 # ⚠ ESTE es el caso `gtk+`: `3.94/` existe, es un número mayor, tiene un
569 # tarball dentro… y es de 2018 mientras `3.24/` es de 2024.
570 muertas += 1
571 r['descartado_por_fecha'] = mejor_s
572
573 if muertas:
574 r['ramas_muertas_descartadas'] = muertas
575 if not hallazgos:
576 if 'mejor_en_su_dir' in r or 'mejor_serie' in r:
577 r['al_dia'] = True
578 r['estado'] = 'ok'
579 else:
580 # Se llegó al índice pero no había nada comparable, o no se llegó. No se afirma nada.
581 r['estado'] = 'sin-comparable' if (html or '') else 'inalcanzable'
582 return r
583 clase, valor, con_fecha = max(hallazgos, key=lambda h: clave(h[1]))
584 r['al_dia'] = False
585 r['nuevo'] = valor
586 r['nuevo_es'] = clase
587 r['con_fecha'] = con_fecha
588 actual = r.get('serie_en_url') if clase == 'serie' else ver_fichero
589 if anclada_por_nombre(receta, actual, valor):
590 r['anclada_por_nombre'] = True
591 r['estado'] = 'ok'
592 return r
593
594
595def mirar(item, timeout):
596 receta, repo, commit, version = item
597 env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/true')
598 r = {'receta': receta, 'repo': repo, 'commit': commit, 'version': version}
599 try:
600 p = subprocess.run(['git', 'ls-remote', '--tags', '--refs', '--heads', repo],
601 capture_output=True, text=True, timeout=timeout, env=env)
602 except subprocess.TimeoutExpired:
603 r['estado'] = 'timeout'
604 return r
605 except FileNotFoundError:
606 r['estado'] = 'sin-git'
607 return r
608 if p.returncode != 0:
609 # No se puede alcanzar: puede ser red, puede ser que el repo desapareció. No se distingue
610 # desde acá y decir cuál sería inventar.
611 r['estado'] = 'inalcanzable'
612 r['error'] = (p.stderr or '').strip().splitlines()[-1:] and (p.stderr.strip().splitlines()[-1])[:160]
613 return r
614 tags, cabezas = {}, {}
615 for linea in p.stdout.splitlines():
616 try:
617 sha, ref = linea.split('\t', 1)
618 except ValueError:
619 continue
620 m = TAG.match(ref)
621 if m:
622 tags[m.group(1)] = sha
623 elif ref.startswith('refs/heads/'):
624 cabezas[ref[len('refs/heads/'):]] = sha
625 estables = {t: s for t, s in tags.items() if not PRE.search(t)}
626 usar = estables or tags
627 r['tags'] = len(tags)
628 if usar:
629 ult = max(usar, key=clave)
630 r['ultimo_tag'] = ult
631 r['ultimo_tag_sha'] = usar[ult]
632 r['al_dia'] = usar[ult].startswith(commit) or commit.startswith(usar[ult][:12])
633 # La rama por defecto no se puede saber sin `HEAD`, pero con `main`/`master` se cubre casi todo;
634 # si no está, se informa el conjunto y no se elige uno al azar.
635 for nombre in ('main', 'master', 'trunk'):
636 if nombre in cabezas:
637 r['rama'] = nombre
638 r['cabeza'] = cabezas[nombre]
639 r['pin_es_la_cabeza'] = cabezas[nombre].startswith(commit) or commit.startswith(cabezas[nombre][:12])
640 break
641 r['estado'] = 'ok'
642 return r
643
644
645def main():
646 ap = argparse.ArgumentParser(description='¿qué se movió río arriba desde que pineamos?')
647 ap.add_argument('--json', help='escribir el informe en este fichero')
648 ap.add_argument('--only', default='', help='sólo las recetas cuyo repo contenga esta cadena')
649 ap.add_argument('--jobs', type=int, default=16)
650 ap.add_argument('--timeout', type=int, default=25)
651 ap.add_argument('--no-http', action='store_true',
652 help='sólo ls-remote: salta el sondeo de índices por HTTP')
653 a = ap.parse_args()
654
655 global CTX
656 CTX = _contexto_tls()
657 items = recetas_git()
658 tb_total, tb_forja, tb_resto, tb_por_host = recetas_tarball()
659 if a.only:
660 items = [i for i in items if a.only in i[1]]
661 tb_forja = [i for i in tb_forja if a.only in i[1]]
662 tb_resto = [i for i in tb_resto if a.only in i[1]]
663 if a.no_http:
664 tb_resto = []
665 t0 = time.time()
666 res = []
667 with cf.ThreadPoolExecutor(max_workers=a.jobs) as ex:
668 for r in ex.map(lambda i: mirar(i, a.timeout), items):
669 res.append(r)
670 # Los tarballs de forja van por el MISMO pool y el mismo `ls-remote`: son la misma
671 # pregunta con la pista leída de la URL en vez del campo `repo`.
672 res_tb = list(ex.map(lambda i: mirar_tarball(i, a.timeout), tb_forja))
673 # Y el resto por índice HTTP. Van DESPUÉS a propósito: el `ls-remote` no depende de la
674 # caché de índices, así que si el HTTP se atasca lo de arriba ya está contestado.
675 res_ix = list(ex.map(lambda i: mirar_indice(i, a.timeout), tb_resto))
676 seg = int(time.time() - t0)
677
678 ok = [r for r in res if r['estado'] == 'ok']
679 atras_tag = [r for r in ok if r.get('ultimo_tag') and not r.get('al_dia')]
680 atras_rama = [r for r in ok if 'pin_es_la_cabeza' in r and not r['pin_es_la_cabeza']]
681 inalcanzables = [r for r in res if r['estado'] != 'ok']
682
683 print('== upstream: %d receta(s) con repo git · %ds ==' % (len(res), seg))
684 print(' %d con un tag más nuevo que el pin' % len(atras_tag))
685 print(' %d con la rama movida desde el pin' % len(atras_rama))
686 if inalcanzables:
687 print(' ⚠ %d no se pudieron consultar' % len(inalcanzables))
688 tb_ok = [r for r in res_tb if r['estado'] == 'ok']
689 tb_atras = [r for r in tb_ok if not r.get('al_dia') and not r.get('anclada_por_nombre')]
690 tb_ancladas = [r for r in tb_ok if not r.get('al_dia') and r.get('anclada_por_nombre')]
691 tb_sha = [r for r in res_tb if r['estado'] == 'pin-por-sha']
692 tb_mudos = [r for r in res_tb if r['estado'] in ('sin-tags', 'inalcanzable', 'timeout')]
693 tb_sin_serie = [r for r in res_tb if r['estado'] == 'sin-serie-comparable']
694 ix_ok = [r for r in res_ix if r['estado'] == 'ok']
695 ix_atras = [r for r in ix_ok if not r.get('al_dia') and not r.get('anclada_por_nombre')]
696 ix_ancladas = [r for r in ix_ok if not r.get('al_dia') and r.get('anclada_por_nombre')]
697 ix_mudos = [r for r in res_ix if r['estado'] != 'ok']
698 # ⚠ «Fuera del radar» = aquello de lo que NO hay respuesta: total menos lo que contestó cada
699 # mecanismo. Se cuenta así, y no restando lo intentado, porque intentar no es contestar — un
700 # índice que no respondió deja la receta igual de a oscuras que no haberla mirado.
701 tb_fuera = tb_total - len(tb_ok) - len(ix_ok)
702 print('\n== tarballs: %d receta(s) con tarball ==' % tb_total)
703 print(' %d de FORJA git (github/gitlab) — la URL trae el repo ⇒ sirve el mismo ls-remote'
704 % len(res_tb))
705 print(' %d con una versión más nueva que la que descargan' % len(tb_atras))
706 if tb_ancladas or ix_ancladas:
707 print(' %d ANCLADAS POR SU NOMBRE (clang18, llvm21…): el salto de major es a propósito'
708 % (len(tb_ancladas) + len(ix_ancladas)))
709 if tb_sha:
710 print(' %d pinean por SHA ⇒ no se comparan: un sha no tiene orden de versión' % len(tb_sha))
711 if tb_sin_serie:
712 print(' %d sin tag de la MISMA serie que la URL ⇒ no se comparan (ver `forma`)'
713 % len(tb_sin_serie))
714 if tb_mudos:
715 print(' %d sin tags o inalcanzables ⇒ no se afirma nada' % len(tb_mudos))
716 print(' %d por ÍNDICE de directorio HTTP (%d peticiones distintas, el resto por caché)'
717 % (len(res_ix), len([k for k, v in IDX_CACHE.items() if v is not None])))
718 # Tres niveles de confianza, y nombrarlos es lo que hace usable el montón: un número mayor
719 # CON FECHA que lo respalda no es lo mismo que uno sin comprobar, y ninguno de los dos es un
720 # tag firmado. Mezclarlos haría que los 231 buenos se tiraran junto con los 30 dudosos.
721 ix_con_fecha = [r for r in ix_atras if r.get('con_fecha')]
722 ix_sin_fecha = [r for r in ix_atras if not r.get('con_fecha')]
723 ix_muertas = [r for r in res_ix if r.get('ramas_muertas_descartadas')]
724 print(' %d con un número MAYOR río arriba, de los cuales:' % len(ix_atras))
725 print(' %d con la FECHA del índice respaldándolo (el candidato no es rama abandonada)'
726 % len(ix_con_fecha))
727 if ix_sin_fecha:
728 print(' %d sin fecha comprobable ⇒ INDICIO a secas, pide una mirada' % len(ix_sin_fecha))
729 if ix_muertas:
730 print(' %d con una rama ABANDONADA descartada por fecha (p.ej. gtk+ 3.94, de 2018)'
731 % len(ix_muertas))
732 if ix_mudos:
733 # Se dice en vez de desaparecer: un índice que no se pudo leer NO es un «al día».
734 porqué = {}
735 for r in ix_mudos:
736 porqué[r['estado']] = porqué.get(r['estado'], 0) + 1
737 print(' %d sin respuesta útil: %s' % (len(ix_mudos),
738 ', '.join('%d %s' % (n, k) for k, n in sorted(porqué.items(), key=lambda kv: -kv[1]))))
739 if ix_atras:
740 print('\n por índice, con algo más nuevo (los 20 primeros):')
741 for r in sorted(ix_atras, key=lambda x: x['receta'])[:20]:
742 # ⚠ Se compara CONTRA LO MISMO. Si el hallazgo es una SERIE (la versión vive en un
743 # segmento de la ruta, como en KDE y GNOME), lo que tiene hoy es la serie de la URL, no
744 # la versión del fichero: imprimir «baja 6.27.0 · hay 6.30» sugiere que 6.27.0 y 6.30
745 # son la misma magnitud y no lo son. Mezclar las dos escalas en una línea es la forma
746 # barata de que un informe correcto se lea como un error.
747 if r['nuevo_es'] == 'serie':
748 tiene, etiq = r.get('serie_en_url', '?'), 'serie'
749 else:
750 tiene, etiq = r.get('version_en_url', '?'), 'fichero'
751 print(' %-46s %-8s %-12s → %s' % (r['receta'], etiq, tiene, r['nuevo']))
752 if tb_atras:
753 print('\n de forja, con versión nueva (los 20 primeros):')
754 for r in sorted(tb_atras, key=lambda x: x['receta'])[:20]:
755 print(' %-42s baja %-14s hay %s' % (r['receta'], r['ref_en_url'], r['ultimo_tag']))
756
757 if a.json:
758 with open(a.json, 'w', encoding='utf-8') as f:
759 # schema /2: se AÑADEN las claves de tarball y se conservan las de /1 —
760 # `tarballs_sin_mirar` incluido— porque el resumen de `cosecha-cron.sh` las lee y
761 # renombrarlas rompería al consumidor sin avisar. Lo que cambia es su significado a
762 # mejor: antes era «todos los tarballs», ahora es «los que siguen fuera del radar».
763 # schema /3: se AÑADEN las claves del sondeo por índice y se CONSERVAN las de /1 y
764 # /2 —`tarballs_sin_mirar` y `tarballs_con_version_nueva` incluidas— porque el resumen
765 # de `cosecha-cron.sh` las lee y renombrarlas rompería al consumidor sin avisar. Lo que
766 # cambia es su significado, a mejor: `tarballs_con_version_nueva` ahora suma los dos
767 # mecanismos, y `tarballs_sin_mirar` son los que NINGUNO pudo contestar.
768 json.dump({'schema': 'takana-upstream/3',
769 'total': len(res), 'segundos': seg,
770 'con_tag_nuevo': len(atras_tag), 'con_rama_movida': len(atras_rama),
771 'inalcanzables': len(inalcanzables),
772 'tarballs_total': tb_total,
773 'tarballs_forja': len(res_tb),
774 'tarballs_forja_contestados': len(tb_ok),
775 'tarballs_forja_con_version_nueva': len(tb_atras),
776 'tarballs_forja_sin_serie': len(tb_sin_serie),
777 'tarballs_forja_pin_por_sha': len(tb_sha),
778 'tarballs_forja_mudos': len(tb_mudos),
779 'tarballs_indice': len(res_ix),
780 'tarballs_indice_contestados': len(ix_ok),
781 'tarballs_indice_con_novedad': len(ix_atras),
782 'tarballs_indice_mudos': len(ix_mudos),
783 # ⚠ `tarballs_con_version_nueva` SIGUE SIENDO SÓLO LA DE FORJA, como en
784 # /2. Sumarle los indicios por índice habría cambiado el significado de una
785 # clave que el latido ya publica, y además mezcla evidencia con pista: 100
786 # tags verificados y 266 números mayores no son la misma afirmación.
787 'tarballs_con_version_nueva': len(tb_atras),
788 'tarballs_con_indicio': len(ix_atras),
789 'tarballs_ancladas_por_nombre': len(tb_ancladas) + len(ix_ancladas),
790 'tarballs_indicio_con_fecha': len(ix_con_fecha),
791 'tarballs_indicio_sin_fecha': len(ix_sin_fecha),
792 'tarballs_ramas_muertas_descartadas': len(ix_muertas),
793 'tarballs_sin_mirar': tb_fuera,
794 'tarballs_por_host_no_forja': dict(sorted(tb_por_host.items(),
795 key=lambda kv: -kv[1])),
796 'peticiones_http': len([k for k, v in IDX_CACHE.items() if v is not None]),
797 'detalle': sorted(res, key=lambda x: x['receta']),
798 'detalle_tarballs': sorted(res_tb, key=lambda x: x['receta']),
799 'detalle_indices': sorted(res_ix, key=lambda x: x['receta'])}, f,
800 ensure_ascii=False, indent=1)
801 print('\n informe: %s' % a.json)
802 # Sale 0 siempre: esto INFORMA. Un cron que falle porque upstream sacó una versión nueva sería
803 # un guardián que grita todos los días y al que nadie mira (la lección de los guardianes ruidosos).
804 return 0
805
806
807if __name__ == '__main__':
808 sys.exit(main())