Files
stack/dev/scripts/check_freshness.py
kert cf5ca45ac3
Some checks failed
CI / lint (push) Successful in 33s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Failing after 21s
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Failing after 50s
Infra CI / zotero (push) Successful in 25s
Infra CI / docs (push) Successful in 15s
Infra CI / api (push) Successful in 39s
Infra CI / mc (push) Successful in 14s
Package Supply Chain / pkg-supply-chain (push) Failing after 1m11s
Deploy / report (push) Successful in 19s
CI / test (push) Successful in 22m44s
feat(infra): version-pin freshness check + Renovate auto-bump for ARG pins
Renovate's pip and dockerfile managers don't see bare `ARG *_VERSION=`
pins (marimo cloned from a git tag, yq/otel-cli from GitHub releases), so
they silently went stale — the marimo one was 3 minor versions behind and
broke acodb_explorer's charts.

- Annotate each such pin with a `# renovate: datasource=… depName=…`
  comment and add a Renovate customManager that reads them, so Renovate
  now opens bump PRs for MARIMO/YQ/OTEL_CLI versions automatically.
- Add dev/scripts/check_freshness.py: the on-demand/CI counterpart that
  reads the same annotations, reports which pins are behind upstream, and
  exits nonzero if any are stale. All three currently fresh.
2026-07-08 19:55:44 -04:00

154 lines
5.3 KiB
Python

"""Freshness / staleness check for annotated version pins.
Renovate's pip and dockerfile managers cover `pyproject.toml` and image
`FROM` lines, but NOT bare `ARG *_VERSION=` pins (e.g. marimo cloned from
a git tag, yq/otel-cli from GitHub releases). Those are annotated with a
`# renovate:` comment so Renovate's customManager auto-opens bump PRs
(see renovate.json). This script is the on-demand / CI counterpart: it
reads the same annotations and reports which pins are behind upstream.
uv run python dev/scripts/check_freshness.py # human report
uv run python dev/scripts/check_freshness.py --json # machine output
uv run python dev/scripts/check_freshness.py --quiet # only stale ones
Exit code: 0 if everything is fresh, 1 if any pin is stale (so CI can
gate or auto-suggest). Set GITHUB_TOKEN to avoid GitHub API rate limits.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMAGES = ROOT / "infra" / "images"
# Matches a `# renovate:` annotation immediately followed by its ARG pin.
_ANNOT = re.compile(
r"# renovate: datasource=(?P<datasource>\S+) depName=(?P<depName>\S+)"
r"(?: extractVersion=(?P<extractVersion>\S+))?\s+"
r"ARG (?P<arg>\w+?_VERSION)=(?P<current>\S+)"
)
def _version_key(v: str) -> tuple:
"""Best-effort semver-ish sort key; falls back to a string tuple."""
parts = re.split(r"[.\-+]", v)
return tuple(int(p) if p.isdigit() else p for p in parts)
def _gh(url: str) -> object:
req = urllib.request.Request(url, headers={"User-Agent": "stack-freshness/1.0"})
token = os.environ.get("GITHUB_TOKEN")
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (trusted host)
return json.load(resp)
def _latest(datasource: str, dep: str, extract: str | None) -> str | None:
"""Resolve the latest upstream version for a dep."""
if datasource == "github-releases":
tag = _gh(f"https://api.github.com/repos/{dep}/releases/latest")["tag_name"]
tags = [tag]
elif datasource == "github-tags":
data = _gh(f"https://api.github.com/repos/{dep}/tags?per_page=100")
tags = [t["name"] for t in data]
else:
return None
versions: list[str] = []
# The annotation uses Renovate/RE2 named-group syntax `(?<name>...)`;
# translate to Python's `(?P<name>...)` before compiling.
pat = re.compile(extract.replace("(?<", "(?P<")) if extract else None
for t in tags:
if pat:
m = pat.search(t)
if not m:
continue
versions.append(
m.group("version") if "version" in m.groupdict() else m.group(1)
)
else:
versions.append(t)
# Ignore pre-releases for the "latest stable" comparison.
stable = [v for v in versions if not re.search(r"[a-zA-Z]", v)]
pool = stable or versions
return max(pool, key=_version_key) if pool else None
def collect() -> list[dict]:
pins: list[dict] = []
for dockerfile in sorted(IMAGES.glob("*.Dockerfile")):
text = dockerfile.read_text()
for m in _ANNOT.finditer(text):
pins.append(
{
"file": str(dockerfile.relative_to(ROOT)),
"arg": m["arg"],
"dep": m["depName"],
"datasource": m["datasource"],
"extractVersion": m["extractVersion"],
"current": m["current"],
}
)
return pins
def main() -> int:
ap = argparse.ArgumentParser(
description="Check annotated version pins for staleness."
)
ap.add_argument("--json", action="store_true", help="Emit JSON")
ap.add_argument("--quiet", action="store_true", help="Only show stale pins")
args = ap.parse_args()
rows = []
stale = 0
for pin in collect():
try:
latest = _latest(pin["datasource"], pin["dep"], pin["extractVersion"])
err = None
except Exception as e: # network / API hiccup — report, don't crash
latest, err = None, str(e)
is_stale = bool(
latest
and latest != pin["current"]
and _version_key(latest) > _version_key(pin["current"])
)
stale += is_stale
rows.append({**pin, "latest": latest, "stale": is_stale, "error": err})
if args.json:
print(json.dumps(rows, indent=2))
else:
shown = [r for r in rows if r["stale"]] if args.quiet else rows
if shown:
w = max(len(r["dep"]) for r in shown)
for r in shown:
if r["error"]:
flag = f"?? ({r['error'][:40]})"
elif r["stale"]:
flag = f"STALE → {r['latest']}"
else:
flag = "fresh"
print(
f" {r['dep']:<{w}} {r['current']:>10} {flag} [{r['arg']} in {r['file']}]"
)
print(f"\n{stale} stale of {len(rows)} pin(s).")
if stale:
print(
"Renovate will open bump PRs for these (renovate.json customManager)."
)
return 1 if stale else 0
if __name__ == "__main__":
sys.exit(main())