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
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.
154 lines
5.3 KiB
Python
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())
|