feat(infra): version-pin freshness check + Renovate auto-bump for ARG pins
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
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.
This commit is contained in:
153
dev/scripts/check_freshness.py
Normal file
153
dev/scripts/check_freshness.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""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())
|
||||
@@ -29,6 +29,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Reinstall yq as a current static binary (replaces the vulnerable base copy
|
||||
# purged above). KasmVNC's entrypoint hard-depends on it (mikefarah v4 syntax);
|
||||
# a recent release carries the patched Go stdlib that motivated the removal.
|
||||
# renovate: datasource=github-releases depName=mikefarah/yq extractVersion=^v(?<version>.+)$
|
||||
ARG YQ_VERSION=4.53.3
|
||||
RUN arch="$(dpkg --print-architecture)" \
|
||||
&& curl -fsSL "https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_${arch}" -o /usr/bin/yq \
|
||||
@@ -56,6 +57,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# default (the guard only emits when OTEL_EXPORTER_OTLP_ENDPOINT is set and
|
||||
# this container is attached to the `observability` network — see compose.yml).
|
||||
# Best-effort install so a release-asset hiccup never breaks the image build.
|
||||
# renovate: datasource=github-releases depName=equinix-labs/otel-cli extractVersion=^v(?<version>.+)$
|
||||
ARG OTEL_CLI_VERSION=0.4.5
|
||||
RUN arch="$(dpkg --print-architecture | sed -e 's/amd64/amd64/' -e 's/arm64/arm64/')" \
|
||||
&& curl -fsSL "https://github.com/equinix-labs/otel-cli/releases/download/v${OTEL_CLI_VERSION}/otel-cli_${OTEL_CLI_VERSION}_linux_${arch}.tar.gz" \
|
||||
|
||||
@@ -20,5 +20,15 @@
|
||||
},
|
||||
"dockerfile": {
|
||||
"fileMatch": ["infra/images/*.Dockerfile"]
|
||||
}
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Bump `ARG *_VERSION=` pins annotated with a `# renovate:` comment (marimo, yq, otel-cli, …) that the dockerfile manager can't see.",
|
||||
"fileMatch": ["(^|/)[^/]+\\.Dockerfile$"],
|
||||
"matchStrings": [
|
||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: extractVersion=(?<extractVersion>\\S+))?\\s+ARG \\w+?_VERSION=(?<currentValue>\\S+)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user