#!/usr/bin/env python3 # upstream-vigia.py — ¿qué se movió RÍO ARRIBA desde que pineamos? # # ══ QUÉ CONTESTA, Y QUÉ NO ════════════════════════════════════════════════════════════════════ # `fuentes-vigia.sh` pregunta si la fuente SIGUE VIVA (¿responde la URL?). Ésta pregunta otra cosa: # **¿hay algo más nuevo que lo que tenemos clavado?** Son las dos mitades del mismo problema y se # confunden: una fuente viva puede llevar dos años sin tocarse, y una que se movió ayer puede estar # muerta mañana. # # **No modifica ninguna receta.** Informa. Quién decide aplicar un salto de versión —y con qué # pruebas— es la política del SDD 33, no este guion: una versión nueva de upstream puede cambiar el # sistema de build, las banderas, las dependencias y el resultado, y eso no se automatiza a ciegas # para código ajeno. # # ── 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`. # # ⚠ **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 ] [--solo tawasuyu] [--jobs N] # La superficie va en INGLÉS (`CLAUDE.md` regla 4); lo que se lee, en castellano. import argparse, concurrent.futures as cf, datetime, glob, json, os, re, ssl, subprocess, sys import threading, time, urllib.parse, urllib.request RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) def recetas_git(): """(receta, 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() repo = re.search(r'^\s*repo\s*=\s*"([^"]+)"', txt, re.M) com = re.search(r'^\s*commit\s*=\s*"([^"]+)"', txt, re.M) ver = re.search(r'^\s*version\s*=\s*"([^"]+)"', txt, re.M) if repo and com: out.append((os.path.relpath(r, RAIZ), repo.group(1), com.group(1), ver.group(1) if ver else None)) return out # 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)') def clave(t): """Orden por versión: (1,2,10) va DESPUÉS de (1,2,9), que es donde el orden alfabético miente.""" nums = [int(x) for x in re.findall(r'\d+', t)[:5]] 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. # ── TARBALLS DE FORJA: github Y gitlab, con el ls-remote que ya estaba ──────────────────────── # 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: de las 625 recetas # con tarball, **196 son de github.com** y **26 de gitlab.freedesktop.org**, y en esas URLs el # repo y la versión YA ESTÁN ESCRITOS. El `git ls-remote --tags` que esta vigía ya hacía las cubre # tal cual: sin API, sin token, sin pista. No hacía falta un mecanismo; hacía falta LEER LA URL. # # GitLab se reconoce por el `/-/`, que es inequívoco: todo lo que va antes es la ruta del proyecto, # así que vale para cualquier instancia (freedesktop, gnome, salsa, gitlab.com), no sólo las que # hoy aparecen en el catálogo. 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'(.+)$') GL_TARBALL = re.compile( r'^(https?://[^/]+)/(.+?)/-/' r'(?:archive/|releases/)' 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 _forja(url): """(repo, ref) si la URL es un tarball de forja git; None si no.""" g = GH_TARBALL.match(url) if g: return 'https://github.com/%s/%s' % (g.group(1), g.group(2)), _limpiar_ref(g.group(3)) g = GL_TARBALL.match(url) if g: return '%s/%s' % (g.group(1), g.group(2)), _limpiar_ref(g.group(3)) return None # ⚠ UN SEGUNDO REGEX DE TAG, MÁS LAXO, Y SÓLO PARA EL CAMINO DE LOS TARBALLS (2026-10-09). # `TAG` exige que el tag empiece por dígito (con `v` opcional), y eso descarta los esquemas más # comunes del mundo real: `llvmorg-18.1.8`, `fuse-3.16.2`, `R_2_6_0`, `release-74-2`. Medido: **53 # recetas de tarball salían «sin-tags» contra repos que tienen cientos** —llvm-project, libexpat, # icu, libfuse— porque el filtro las tiraba antes de mirarlas. # # Por qué laxo acá y estricto allá, que es la parte que importa: en el camino del tarball **la URL # nos dice la forma que buscamos** (`llvmorg-18.1.8`), así que `forma()` acota la comparación a esa # serie y un regex amplio no puede colar basura — sólo compite lo que tiene el mismo prefijo y la # misma cantidad de números. En el camino de `repo`+`commit` no hay URL que diga la forma, así que # ahí la estrechez de `TAG` es la protección y se deja como está: aflojarla dejaría que un tag raro # gane el `max()` y cambiaría `al_dia` en 660 recetas que hoy se miden bien. TAG_LIBRE = re.compile(r'^refs/tags/([A-Za-z_.\-]{0,24}\d[\w.\-]*)$') 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//` 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, ref, version)] de forja, [(receta, url, version)] del resto, {host: n} del resto). El `resto` NO se descarta: va al sondeador de índices.""" total, forja, resto, por_host = 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) ver = mv.group(1) if mv else None f = _forja(url) if f: forja.append((rel, f[0], f[1], ver)) else: resto.append((rel, url, ver)) host = url.split('/')[2] if '://' in url else '(sin host)' por_host[host] = por_host.get(host, 0) + 1 return total, forja, resto, por_host # ── RECETAS ANCLADAS POR SU PROPIO NOMBRE ───────────────────────────────────────────────────── # `clang18`, `llvm21`, `lld21`, `gtk3`, `python3`: el nombre LLEVA el major, y eso no es un # descuido — es la decisión de quedarse en esa rama. Decirle a `clang18` que existe # `llvmorg-23.1.3` es cierto y es **inaccionable**: si se adoptara, la receta debería llamarse # `clang23`, que es otra receta. # # Medido el 2026-10-09: sin esto, el frente llvm aportaba 5 hallazgos de salto de major sobre # recetas cuyo nombre dice explícitamente que no van a saltar. No se ocultan —siguen en el detalle # del json— pero se cuentan aparte, porque un informe que mezcla «hay que mirar esto» con «esto es # así a propósito» obliga a filtrar a mano cada vez, y lo que se filtra a mano se deja de leer. def anclada_por_nombre(receta, version_actual, version_nueva): """¿La receta fija su major en el nombre, y el hallazgo lo cruza?""" if not (version_actual and version_nueva): return False m = re.match(r'^(.*?)(\d+)$', os.path.basename(receta).rsplit('.toml', 1)[0]) if not m: return False nombre_major = m.group(2) act = re.findall(r'\d+', version_actual) nue = re.findall(r'\d+', version_nueva) if not (act and nue): return False # El número del nombre tiene que ser el major que la receta usa HOY — si no, el nombre habla de # otra cosa (p.ej. `libxcb2` no es un major) y esto no aplica. return act[0] == nombre_major and nue[0] != act[0] 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_LIBRE.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) if not r['al_dia'] and anclada_por_nombre(receta, ref_url, ult): r['anclada_por_nombre'] = True r['estado'] = 'ok' return r # ══ SEGUNDA MITAD DEL PUNTO CIEGO: LOS ÍNDICES DE DIRECTORIO (2026-10-09) ═════════════════════ # Tras cubrir github, quedaban 429 tarballs, y la medición dijo que NO están dispersos: 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. Dos mecanismos los cubren casi todos, y # el primero no es nuevo: # # 1. **gitlab.freedesktop.org es una FORJA GIT.** Sus URLs `/-/archive//` llevan el # repo escrito igual que las de github ⇒ el mismo `ls-remote`. Cero mecanismo nuevo. (+26) # 2. **El resto son índices de directorio servidos por HTTP.** `https://ftp.gnu.org/gnu/bash/` # lista `bash-5.3.tar.gz`; `https://download.kde.org/stable/frameworks/` lista `6.26/ 6.27/`. # Un sondeador genérico los lee. (+~230) # # ⚠ **EL PYTHON DEL CORPUS NO ENCUENTRA EL BUNDLE DE CA, y falla con `CERTIFICATE_VERIFY_FAILED`** # (medido 2026-10-09 en la jaula). Es el mismo patrón que el `GIT_EXEC_PATH` de la regla 2 de # CLAUDE.md: el binario está bien, lo que falta es decirle dónde están sus cosas. En el ANFITRIÓN # —donde corre el latido— el python del sistema funciona sin tocar nada, así que esto se descubre # y sólo se fija si hace falta: cablear la ruta rompería en la otra caja. # ⚠ ══ EL LÍMITE DEL MÉTODO POR ÍNDICE, DICHO ANTES DE USARLO ═══════════════════════════════════ # Un índice de directorio contesta **«¿hay un número mayor?»**, y eso NO es lo mismo que «hay una # release más nueva». La diferencia no es teórica, es el caso que la encontró (2026-10-09): # # `recipes/gtk3.toml` baja de `sources/gtk+/3.24/`, y ese directorio TODAVÍA guarda las series # `3.89`…`3.96` — las alfas de GTK4, de 2017. `3.94 > 3.24` es cierto numéricamente y falso # semánticamente: no hay ningún GTK3 más nuevo que 3.24. # # Y **ninguna regla numérica barata lo distingue**: 94 es par (así que el par/impar de GNOME no lo # toca), está dentro del mismo major, y el directorio `3.94/` contiene un `gtk+-3.94.0.tar.xz` de # verdad, así que verificar que el fichero existe tampoco ayuda. # # ⇒ Por eso los hallazgos por índice se informan como **INDICIO**, en su propio recuento y con su # propia palabra, y no se suman a los de forja como si fueran la misma cosa. Un tag con su filtro de # prereleases es evidencia; un directorio con número mayor es una pista que alguien tiene que mirar. # Sobrevalorarlo es la forma de que 160 indicios buenos se tiren a la basura junto con los malos. # # Lo que SÍ se filtra, porque es una convención documentada y no una corazonada: las series de # minor IMPAR de download.gnome.org son inestables (`glib 2.89` lo es; `2.90` no). Quita ruido real # sin inventar nada. # # La pista que queda sin usar, para quien siga: los índices de Apache traen la FECHA de cada # entrada, y `3.24/` está tocado hace meses mientras `3.94/` lleva ocho años quieto. Ordenar por # fecha en vez de por número resolvería justo este caso. No se hace acá porque pide parsear el # formato de cada índice, y eso es su propia unidad de trabajo. GNOME_INESTABLE = re.compile(r'^\d+\.(\d+)$') def _serie_descartable(host, nombre): """¿Esta serie es inestable por convención del host? Sólo se afirma donde está documentado.""" if 'download.gnome.org' in host: m = GNOME_INESTABLE.match(nombre) if m and int(m.group(1)) % 2 == 1: return True return False CANDIDATOS_CA = ('/etc/ssl/certs/ca-certificates.crt', '/etc/ssl/cert.pem', '/etc/ca-certificates/extracted/tls-ca-bundle.pem', '/etc/pki/tls/certs/ca-bundle.crt') def _contexto_tls(): """Contexto TLS que verifica, con el bundle del sistema si el de serie no sirve. NO se desactiva la verificación en ningún camino: un índice bajado sin verificar puede decir que hay una versión nueva que no existe, y esta vigía sólo vale si lo que informa es cierto. """ ctx = ssl.create_default_context() try: # Sonda baratísima contra el propio almacén: si no cargó NADA, no va a verificar nada. if ctx.cert_store_stats().get('x509_ca', 0) > 0: return ctx except Exception: pass for c in CANDIDATOS_CA: if os.path.exists(c): try: return ssl.create_default_context(cafile=c) except Exception: continue return ctx # se devuelve el de serie: fallará ruidosamente, que es mejor que no verificar CTX = None # se crea una vez en main() IDX_CACHE = {} IDX_LOCK = threading.Lock() HREF = re.compile(r'(?i)href="([^"?#]+)"') # Un segmento de ruta que ES una versión: `6.27`, `25.04.3`, `1.7`, `v1.2`. No `src`, no `individual`. SEG_VER = re.compile(r'^v?\d+(?:\.\d+)*$') def _bajar_indice(url, timeout): """El listado de un directorio, CACHEADO por URL — y la caché es lo que lo hace viable: las 108 recetas de KDE comparten 3 directorios y las 29 de xorg comparten uno. Sin caché serían 429 peticiones; con caché son unas pocas docenas.""" with IDX_LOCK: if url in IDX_CACHE: return IDX_CACHE[url] datos = None try: req = urllib.request.Request(url, headers={'User-Agent': 'takana-upstream-vigia'}) with urllib.request.urlopen(req, timeout=timeout, context=CTX) as f: datos = f.read(4_000_000).decode('utf-8', 'replace') except Exception: # No se distingue «no existe» de «no se pudo bajar», y decir cuál sería inventarlo. datos = None with IDX_LOCK: IDX_CACHE[url] = datos return datos def _nombres(html): """Los nombres de entrada de un índice. Vale para los índices de Apache/nginx y para cualquier página con enlaces: se queda con el último segmento del href y le quita la barra final.""" fuera = set() for h in HREF.findall(html): n = urllib.parse.unquote(h.rstrip('/')).split('/')[-1] if n and n not in ('..', '.'): fuera.add(n) return fuera # ══ LA FECHA DEL ÍNDICE: CÓMO UN INDICIO SE VUELVE EVIDENCIA (2026-10-09) ═════════════════════ # El sondeo por índice contestaba «¿hay un número mayor?», y eso no es «hay una release más nueva». # El caso que lo probaba era `gtk3`, que baja de `sources/gtk+/3.24/` mientras ese directorio aún # guarda las alfas de GTK4. **La fecha lo resuelve, y está en el índice todo el tiempo:** # # 3.24/ 2024-Jul-10 17:49 ← lo que usamos # 3.94/ 2018-Jun-26 11:58 ← seis años MÁS VIEJO: rama muerta, no versión nueva # 3.89/ 2017-Mar-14 00:36 # # La regla, que es la que upstream no puede violar sin querer: **nadie vuelve a tocar una rama # abandonada.** Si lo que usamos hoy se modificó DESPUÉS que el candidato «más nuevo», el candidato # está muerto. Comprobado en el otro sentido con KDE, donde el hallazgo es real y la fecha lo # confirma: `6.27/` es de 2026-06-11 y `6.30/` de 2026-09-04. # # Tres formatos en tres servidores, y hay que cubrir los tres o la regla no se aplica donde más # falta: `2026-06-11 10:12` (KDE, ftp.gnu.org), `2024-Jul-10 17:49` (download.gnome.org), # `15-Sep-2025 10:23` (autoindex clásico de Apache). MESES = {m: i for i, m in enumerate( ('jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'), 1)} FECHAS = ( re.compile(r'(\d{4})-(\d{2})-(\d{2})'), # 2026-06-11 re.compile(r'(?i)(\d{4})-([a-z]{3})-(\d{2})'), # 2024-Jul-10 re.compile(r'(?i)(\d{2})-([a-z]{3})-(\d{4})'), # 15-Sep-2025 ) def _fecha(txt): """(año, mes, día) de la primera fecha reconocible, o None. Tupla y no `datetime` a propósito: sólo se usa para ORDENAR, y un orden lexicográfico de enteros basta y no puede fallar por zona horaria ni por un formato raro.""" m = FECHAS[0].search(txt) if m: return (int(m.group(1)), int(m.group(2)), int(m.group(3))) m = FECHAS[1].search(txt) if m and m.group(2).lower() in MESES: return (int(m.group(1)), MESES[m.group(2).lower()], int(m.group(3))) m = FECHAS[2].search(txt) if m and m.group(2).lower() in MESES: return (int(m.group(3)), MESES[m.group(2).lower()], int(m.group(1))) return None def _entradas(html): """{nombre: fecha|None} de un índice. La fecha se busca en los 200 caracteres QUE SIGUEN al href, que es donde la ponen los tres autoindex; no se parte por líneas porque hay servidores que mandan el índice entero en una.""" fuera = {} for m in HREF.finditer(html): n = urllib.parse.unquote(m.group(1).rstrip('/')).split('/')[-1] if not n or n in ('..', '.'): continue # El primer href gana: si el mismo nombre aparece dos veces (icono + enlace), la fecha # buena es la de la fila, y la fila empieza en el primero. if n not in fuera: fuera[n] = _fecha(html[m.end(): m.end() + 200]) return fuera # ⚠ **«MÁS VIEJO» NO ALCANZA: TIENE QUE SER MUCHO MÁS VIEJO, Y ESTO ES UN FALSO NEGATIVO MEDIDO.** # La primera versión de esta regla descartaba el candidato en cuanto su fecha fuera ANTERIOR a la de # lo que usamos. Rompía así: # # glib: la serie `2.90/` salió el 2026-Oct-06 — es la nueva de verdad, con 2.90.0 y 2.90.1 # dentro— y `2.88/` recibió un parche (2.88.4) el 2026-Oct-08, DOS DÍAS DESPUÉS. # ⇒ la regla estricta tiraba la serie nueva porque la de MANTENIMIENTO se tocó más tarde. # # Una rama que sigue recibiendo parches es normal y no debe tapar una serie nueva; una rama # ABANDONADA lleva años quieta (`gtk+/3.94/` es de 2018 contra `3.24/` de 2024). Entre dos días y # seis años hay sitio de sobra, así que el umbral no está calibrado contra ninguno de los dos casos # — que es justo lo que [[umbral-a-mano-envejece]] avisa de no hacer. Un año: lejos de los dos # bordes, y explicable en una frase. DIAS_ABANDONO = 365 def _no_es_rama_muerta(fechas, actual, candidato): """¿El candidato está vivo, o es una rama abandonada con número mayor? Devuelve (veredicto, con_fecha). `con_fecha=False` significa que no se pudo comprobar: el hallazgo sigue valiendo como INDICIO, pero no se asciende a evidencia. **Nunca se descarta por falta de fecha**, que sería perder información real por no tener la corroboración. """ fa, fc = fechas.get(actual), fechas.get(candidato) if fa is None or fc is None: return True, False dia_a = datetime.date(*fa).toordinal() dia_c = datetime.date(*fc).toordinal() return (dia_a - dia_c) <= DIAS_ABANDONO, True def _partir(nombre): """`bash-5.3.tar.gz` → ('bash', '5.3'). None si no tiene forma de -.""" for suf in SUFIJOS: if nombre.endswith(suf): base = nombre[: -len(suf)] m = re.match(r'^(.*?)[-_](v?\d[\w.]*?)$', base) return (m.group(1), m.group(2)) if m else None return None def mirar_indice(item, timeout): """¿Hay algo más nuevo en el índice del que este tarball cuelga? Dos sondeos, y se informa el mayor de los dos: (a) EN SU PROPIO DIRECTORIO, un `-`. Cubre ftp.gnu.org, xorg, kernel.org: un directorio por paquete con todas sus versiones. (b) UNA SERIE MÁS NUEVA. Cubre KDE y GNOME, donde la versión está en un SEGMENTO DE LA RUTA (`stable/frameworks/6.27/`, `sources/libadwaita/1.7/`) y el directorio propio sólo tiene la serie vieja — mirarlo diría «al día» para siempre. Y los dos pasan por el filtro de FECHA (`_no_es_rama_muerta`): un número mayor con fecha ANTERIOR a la que usamos es una rama abandonada, no una versión nueva. Si no hay fecha el hallazgo no se descarta —sería perder información real— pero no se asciende a evidencia. """ receta, url, version = item r = {'receta': receta, 'tarball': url, 'version': version, 'fuente': 'indice'} dir_url, _, fichero = url.rpartition('/') partido = _partir(fichero) if not partido: # Sin `-` en el fichero no hay con qué comparar. Pasa con # files.pythonhosted.org, cuyas URLs llevan un hash en la ruta. r['estado'] = 'fichero-no-versionado' return r stem, ver_fichero = partido r['stem'], r['version_en_url'] = stem, ver_fichero hallazgos = [] muertas = 0 # (a) mismo directorio html = _bajar_indice(dir_url + '/', timeout) if html: ent = _entradas(html) # Las fechas de los FICHEROS, indexadas por su versión, para poder comparar fecha a fecha. fechas_ver, cand = {}, [] for n, f in ent.items(): pp = _partir(n) if pp and pp[0] == stem and forma(pp[1]) == forma(ver_fichero): cand.append(pp[1]) if pp[1] not in fechas_ver or f: fechas_ver[pp[1]] = f if cand: mejor = max(cand, key=clave) r['mejor_en_su_dir'] = mejor if clave(mejor) > clave(ver_fichero): vivo, con_fecha = _no_es_rama_muerta(fechas_ver, ver_fichero, mejor) if vivo: hallazgos.append(('fichero', mejor, con_fecha)) else: muertas += 1 # (b) serie más nueva: el segmento de versión más EXTERNO de la ruta partes = urllib.parse.urlparse(url) segs = partes.path.split('/') idx = next((i for i, s in enumerate(segs[:-1]) if SEG_VER.match(s)), None) if idx is not None: padre = '%s://%s%s/' % (partes.scheme, partes.netloc, '/'.join(segs[:idx])) html2 = _bajar_indice(padre, timeout) if html2: ent2 = _entradas(html2) series = [n for n in ent2 if SEG_VER.match(n) and forma(n) == forma(segs[idx]) and not _serie_descartable(partes.netloc, n)] if series: mejor_s = max(series, key=clave) r['serie_en_url'] = segs[idx] r['mejor_serie'] = mejor_s if clave(mejor_s) > clave(segs[idx]): vivo, con_fecha = _no_es_rama_muerta(ent2, segs[idx], mejor_s) if vivo: hallazgos.append(('serie', mejor_s, con_fecha)) else: # ⚠ ESTE es el caso `gtk+`: `3.94/` existe, es un número mayor, tiene un # tarball dentro… y es de 2018 mientras `3.24/` es de 2024. muertas += 1 r['descartado_por_fecha'] = mejor_s if muertas: r['ramas_muertas_descartadas'] = muertas if not hallazgos: if 'mejor_en_su_dir' in r or 'mejor_serie' in r: r['al_dia'] = True r['estado'] = 'ok' else: # Se llegó al índice pero no había nada comparable, o no se llegó. No se afirma nada. r['estado'] = 'sin-comparable' if (html or '') else 'inalcanzable' return r clase, valor, con_fecha = max(hallazgos, key=lambda h: clave(h[1])) r['al_dia'] = False r['nuevo'] = valor r['nuevo_es'] = clase r['con_fecha'] = con_fecha actual = r.get('serie_en_url') if clase == 'serie' else ver_fichero if anclada_por_nombre(receta, actual, valor): r['anclada_por_nombre'] = True 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') r = {'receta': receta, 'repo': repo, 'commit': commit, 'version': version} try: p = subprocess.run(['git', 'ls-remote', '--tags', '--refs', '--heads', repo], capture_output=True, text=True, timeout=timeout, env=env) except subprocess.TimeoutExpired: r['estado'] = 'timeout' return r except FileNotFoundError: r['estado'] = 'sin-git' return r if p.returncode != 0: # No se puede alcanzar: puede ser red, puede ser que el repo desapareció. No se distingue # desde acá y decir cuál sería inventar. r['estado'] = 'inalcanzable' r['error'] = (p.stderr or '').strip().splitlines()[-1:] and (p.stderr.strip().splitlines()[-1])[:160] return r tags, cabezas = {}, {} for linea in p.stdout.splitlines(): try: sha, ref = linea.split('\t', 1) except ValueError: continue m = TAG.match(ref) if m: tags[m.group(1)] = sha elif ref.startswith('refs/heads/'): cabezas[ref[len('refs/heads/'):]] = sha estables = {t: s for t, s in tags.items() if not PRE.search(t)} usar = estables or tags r['tags'] = len(tags) if usar: ult = max(usar, key=clave) r['ultimo_tag'] = ult r['ultimo_tag_sha'] = usar[ult] r['al_dia'] = usar[ult].startswith(commit) or commit.startswith(usar[ult][:12]) # La rama por defecto no se puede saber sin `HEAD`, pero con `main`/`master` se cubre casi todo; # si no está, se informa el conjunto y no se elige uno al azar. for nombre in ('main', 'master', 'trunk'): if nombre in cabezas: r['rama'] = nombre r['cabeza'] = cabezas[nombre] r['pin_es_la_cabeza'] = cabezas[nombre].startswith(commit) or commit.startswith(cabezas[nombre][:12]) break r['estado'] = 'ok' return r def main(): ap = argparse.ArgumentParser(description='¿qué se movió río arriba desde que pineamos?') ap.add_argument('--json', help='escribir el informe en este fichero') ap.add_argument('--only', default='', help='sólo las recetas cuyo repo contenga esta cadena') ap.add_argument('--jobs', type=int, default=16) ap.add_argument('--timeout', type=int, default=25) ap.add_argument('--no-http', action='store_true', help='sólo ls-remote: salta el sondeo de índices por HTTP') a = ap.parse_args() global CTX CTX = _contexto_tls() items = recetas_git() tb_total, tb_forja, tb_resto, tb_por_host = recetas_tarball() if a.only: items = [i for i in items if a.only in i[1]] tb_forja = [i for i in tb_forja if a.only in i[1]] tb_resto = [i for i in tb_resto if a.only in i[1]] if a.no_http: tb_resto = [] 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 forja 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_forja)) # Y el resto por índice HTTP. Van DESPUÉS a propósito: el `ls-remote` no depende de la # caché de índices, así que si el HTTP se atasca lo de arriba ya está contestado. res_ix = list(ex.map(lambda i: mirar_indice(i, a.timeout), tb_resto)) seg = int(time.time() - t0) ok = [r for r in res if r['estado'] == 'ok'] atras_tag = [r for r in ok if r.get('ultimo_tag') and not r.get('al_dia')] atras_rama = [r for r in ok if 'pin_es_la_cabeza' in r and not r['pin_es_la_cabeza']] inalcanzables = [r for r in res if r['estado'] != 'ok'] print('== upstream: %d receta(s) con repo git · %ds ==' % (len(res), seg)) print(' %d con un tag más nuevo que el pin' % len(atras_tag)) print(' %d con la rama movida desde el pin' % len(atras_rama)) if inalcanzables: print(' ⚠ %d no se pudieron consultar' % len(inalcanzables)) 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') and not r.get('anclada_por_nombre')] tb_ancladas = [r for r in tb_ok if not r.get('al_dia') and r.get('anclada_por_nombre')] 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'] ix_ok = [r for r in res_ix if r['estado'] == 'ok'] ix_atras = [r for r in ix_ok if not r.get('al_dia') and not r.get('anclada_por_nombre')] ix_ancladas = [r for r in ix_ok if not r.get('al_dia') and r.get('anclada_por_nombre')] ix_mudos = [r for r in res_ix if r['estado'] != 'ok'] # ⚠ «Fuera del radar» = aquello de lo que NO hay respuesta: total menos lo que contestó cada # mecanismo. Se cuenta así, y no restando lo intentado, porque intentar no es contestar — un # índice que no respondió deja la receta igual de a oscuras que no haberla mirado. tb_fuera = tb_total - len(tb_ok) - len(ix_ok) print('\n== tarballs: %d receta(s) con tarball ==' % tb_total) print(' %d de FORJA git (github/gitlab) — la URL trae el repo ⇒ 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_ancladas or ix_ancladas: print(' %d ANCLADAS POR SU NOMBRE (clang18, llvm21…): el salto de major es a propósito' % (len(tb_ancladas) + len(ix_ancladas))) 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: 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)) print(' %d por ÍNDICE de directorio HTTP (%d peticiones distintas, el resto por caché)' % (len(res_ix), len([k for k, v in IDX_CACHE.items() if v is not None]))) # Tres niveles de confianza, y nombrarlos es lo que hace usable el montón: un número mayor # CON FECHA que lo respalda no es lo mismo que uno sin comprobar, y ninguno de los dos es un # tag firmado. Mezclarlos haría que los 231 buenos se tiraran junto con los 30 dudosos. ix_con_fecha = [r for r in ix_atras if r.get('con_fecha')] ix_sin_fecha = [r for r in ix_atras if not r.get('con_fecha')] ix_muertas = [r for r in res_ix if r.get('ramas_muertas_descartadas')] print(' %d con un número MAYOR río arriba, de los cuales:' % len(ix_atras)) print(' %d con la FECHA del índice respaldándolo (el candidato no es rama abandonada)' % len(ix_con_fecha)) if ix_sin_fecha: print(' %d sin fecha comprobable ⇒ INDICIO a secas, pide una mirada' % len(ix_sin_fecha)) if ix_muertas: print(' %d con una rama ABANDONADA descartada por fecha (p.ej. gtk+ 3.94, de 2018)' % len(ix_muertas)) if ix_mudos: # Se dice en vez de desaparecer: un índice que no se pudo leer NO es un «al día». porqué = {} for r in ix_mudos: porqué[r['estado']] = porqué.get(r['estado'], 0) + 1 print(' %d sin respuesta útil: %s' % (len(ix_mudos), ', '.join('%d %s' % (n, k) for k, n in sorted(porqué.items(), key=lambda kv: -kv[1])))) if ix_atras: print('\n por índice, con algo más nuevo (los 20 primeros):') for r in sorted(ix_atras, key=lambda x: x['receta'])[:20]: # ⚠ Se compara CONTRA LO MISMO. Si el hallazgo es una SERIE (la versión vive en un # segmento de la ruta, como en KDE y GNOME), lo que tiene hoy es la serie de la URL, no # la versión del fichero: imprimir «baja 6.27.0 · hay 6.30» sugiere que 6.27.0 y 6.30 # son la misma magnitud y no lo son. Mezclar las dos escalas en una línea es la forma # barata de que un informe correcto se lea como un error. if r['nuevo_es'] == 'serie': tiene, etiq = r.get('serie_en_url', '?'), 'serie' else: tiene, etiq = r.get('version_en_url', '?'), 'fichero' print(' %-46s %-8s %-12s → %s' % (r['receta'], etiq, tiene, r['nuevo'])) if tb_atras: print('\n de forja, 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'])) if a.json: with open(a.json, 'w', encoding='utf-8') as f: # 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». # schema /3: se AÑADEN las claves del sondeo por índice y se CONSERVAN las de /1 y # /2 —`tarballs_sin_mirar` y `tarballs_con_version_nueva` incluidas— 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: `tarballs_con_version_nueva` ahora suma los dos # mecanismos, y `tarballs_sin_mirar` son los que NINGUNO pudo contestar. json.dump({'schema': 'takana-upstream/3', 'total': len(res), 'segundos': seg, 'con_tag_nuevo': len(atras_tag), 'con_rama_movida': len(atras_rama), 'inalcanzables': len(inalcanzables), 'tarballs_total': tb_total, 'tarballs_forja': len(res_tb), 'tarballs_forja_contestados': len(tb_ok), 'tarballs_forja_con_version_nueva': len(tb_atras), 'tarballs_forja_sin_serie': len(tb_sin_serie), 'tarballs_forja_pin_por_sha': len(tb_sha), 'tarballs_forja_mudos': len(tb_mudos), 'tarballs_indice': len(res_ix), 'tarballs_indice_contestados': len(ix_ok), 'tarballs_indice_con_novedad': len(ix_atras), 'tarballs_indice_mudos': len(ix_mudos), # ⚠ `tarballs_con_version_nueva` SIGUE SIENDO SÓLO LA DE FORJA, como en # /2. Sumarle los indicios por índice habría cambiado el significado de una # clave que el latido ya publica, y además mezcla evidencia con pista: 100 # tags verificados y 266 números mayores no son la misma afirmación. 'tarballs_con_version_nueva': len(tb_atras), 'tarballs_con_indicio': len(ix_atras), 'tarballs_ancladas_por_nombre': len(tb_ancladas) + len(ix_ancladas), 'tarballs_indicio_con_fecha': len(ix_con_fecha), 'tarballs_indicio_sin_fecha': len(ix_sin_fecha), 'tarballs_ramas_muertas_descartadas': len(ix_muertas), 'tarballs_sin_mirar': tb_fuera, 'tarballs_por_host_no_forja': dict(sorted(tb_por_host.items(), key=lambda kv: -kv[1])), 'peticiones_http': len([k for k, v in IDX_CACHE.items() if v is not None]), 'detalle': sorted(res, key=lambda x: x['receta']), 'detalle_tarballs': sorted(res_tb, key=lambda x: x['receta']), 'detalle_indices': sorted(res_ix, 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 # un guardián que grita todos los días y al que nadie mira (la lección de los guardianes ruidosos). return 0 if __name__ == '__main__': sys.exit(main())