summaryrefslogtreecommitdiffstats
path: root/scripts/check-versions.py
diff options
context:
space:
mode:
authorcoast <coasteen@proton.me>2026-10-12 14:41:07 +0330
committercoast <coasteen@proton.me>2026-10-12 14:41:07 +0330
commit25922912c7e37f3bc3000e71b647e77677d8f2c5 (patch)
tree8383c5569a668613eee384ee31ea8cddc2113ee5 /scripts/check-versions.py
downloadexherbo-overlay-25922912c7e37f3bc3000e71b647e77677d8f2c5.tar.gz
exherbo-overlay-25922912c7e37f3bc3000e71b647e77677d8f2c5.tar.bz2
exherbo-overlay-25922912c7e37f3bc3000e71b647e77677d8f2c5.tar.xz
exherbo-overlay-25922912c7e37f3bc3000e71b647e77677d8f2c5.tar.zst
Diffstat (limited to 'scripts/check-versions.py')
-rw-r--r--scripts/check-versions.py191
1 files changed, 191 insertions, 0 deletions
diff --git a/scripts/check-versions.py b/scripts/check-versions.py
new file mode 100644
index 0000000..d7762fe
--- /dev/null
+++ b/scripts/check-versions.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+"""Compare repository recipe versions with nvchecker results, without writing state."""
+
+import json
+import re
+import subprocess
+import sys
+import tomllib
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+CONFIG = ROOT / "nvchecker.toml"
+
+UNSUPPORTED = {
+ "dev-libs/tlottie": "recipe version is tied to AyuGram's pinned commit and patch",
+ "media-libs/nanosvg": "recipe version is tied to a pinned commit",
+ "media-libs/tg_owt": "recipe pins four commits, not a release",
+ "net-libs/tde2e": "recipe version is tied to a pinned TDLib commit",
+ "x11-apps/qt6ct": "Git tag does not verify the generic registry archive",
+}
+VERSION = re.compile(r"[0-9]+(?:\.[0-9]+)*\Z")
+KERNEL_RELEASE = re.compile(r"[0-9]+(?:\.[0-9]+)*-[0-9]+\Z")
+KERNEL_RECIPE = re.compile(r"([0-9]+(?:\.[0-9]+)*)(?:-r([1-9][0-9]*))?\Z")
+
+LABELS = {
+ "update": "to update",
+ "failed": "failed",
+ "ahead": "ahead of upstream",
+ "unsupported": "not tracked",
+ "current": "up to date",
+}
+
+
+def recipes(root):
+ found = {}
+ problems = []
+ kernel_count = 0
+ for path in sorted((root / "packages").glob("*/*/*.exheres-0")):
+ name = f"{path.parent.parent.name}/{path.parent.name}"
+ stem = path.name.removesuffix(".exheres-0")
+ prefix = path.parent.name + "-"
+ if not stem.startswith(prefix) or not stem[len(prefix):]:
+ problems.append(f"invalid recipe filename: {path.relative_to(root)}")
+ continue
+ version = stem[len(prefix):]
+ if name == "sys-kernel/kernel-cachyos":
+ kernel_count += 1
+ if not KERNEL_RECIPE.fullmatch(version):
+ problems.append(f"invalid kernel version: {version}")
+ continue
+ found[name] = version
+ elif name in found:
+ problems.append(f"multiple versions for {name}")
+ else:
+ found[name] = version
+ if kernel_count > 1:
+ problems.append("keep only one kernel-cachyos recipe")
+ return found, problems
+
+
+def compare(current, upstream):
+ if not VERSION.fullmatch(current) or not VERSION.fullmatch(upstream):
+ return "incomparable"
+ a = tuple(map(int, current.split(".")))
+ b = tuple(map(int, upstream.split(".")))
+ length = max(len(a), len(b))
+ a += (0,) * (length - len(a))
+ b += (0,) * (length - len(b))
+ return "update" if b > a else "ahead" if b < a else "current"
+
+
+def kernel_release(root, version):
+ path = root / "packages/sys-kernel/kernel-cachyos" / f"kernel-cachyos-{version}.exheres-0"
+ try:
+ recipe = path.read_text()
+ except OSError:
+ return None
+ match = re.search(r'^MY_RELEASE="\$\{PV\}-([0-9]+)"$', recipe, re.MULTILINE)
+ return f"{KERNEL_RECIPE.fullmatch(version).group(1)}-{match.group(1)}" if match else None
+
+
+def results(output):
+ versions = {}
+ errors = {}
+ for line in output.splitlines():
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ name = event.get("name")
+ if not name:
+ continue
+ if event.get("event") == "updated":
+ versions[name] = event.get("version")
+ elif event.get("level") == "error":
+ errors[name] = event.get("event", "unknown error")
+ return versions, errors
+
+
+def report(root, config, output, returncode):
+ current, problems = recipes(root)
+ entries = {name for name in config if name != "__config__"}
+ overlap = entries & UNSUPPORTED.keys()
+ missing = current.keys() - entries - UNSUPPORTED.keys()
+ if config.get("__config__", {}).get("oldver") or config.get("__config__", {}).get("newver"):
+ problems.append("nvchecker must not write version files")
+ extra = (entries | UNSUPPORTED.keys()) - current.keys()
+ problems.extend(f"both configured and unsupported: {name}" for name in sorted(overlap))
+ problems.extend(f"unmapped recipe: {name}" for name in sorted(missing))
+ problems.extend(f"mapping without recipe: {name}" for name in sorted(extra))
+ versions, errors = results(output)
+ unknown = (versions.keys() | errors.keys()) - entries
+ problems.extend(f"unexpected nvchecker result: {name}" for name in sorted(unknown))
+ if returncode not in (0, 3):
+ problems.append(f"nvchecker exited with status {returncode}")
+
+ rows = {status: [] for status in LABELS}
+ for name in sorted(current):
+ installed = current[name]
+ if name in UNSUPPORTED:
+ status, detail = "unsupported", UNSUPPORTED[name]
+ elif name not in entries:
+ status, detail = "failed", "no source configured"
+ elif name in errors:
+ status, detail = "failed", errors[name]
+ elif name not in versions or not isinstance(versions[name], str):
+ status, detail = "failed", "no version returned"
+ else:
+ upstream = versions[name]
+ if name == "sys-kernel/kernel-cachyos":
+ release = kernel_release(root, installed)
+ if release and KERNEL_RELEASE.fullmatch(upstream):
+ status = compare(release.replace("-", "."), upstream.replace("-", "."))
+ else:
+ status = "incomparable"
+ detail = f"{release} -> {upstream}" if release else "unrecognized recipe release"
+ else:
+ status = compare(installed, upstream)
+ detail = f"{installed} -> {upstream}"
+ if status == "incomparable":
+ status = "failed"
+ if name != "sys-kernel/kernel-cachyos" or release:
+ detail = f"unrecognized version {upstream!r}"
+ rows[status].append((name, detail))
+ if returncode == 3 and not errors:
+ problems.append("nvchecker reported failures without error details")
+
+ lines = [", ".join(f"{len(rows[status])} {label}" for status, label in LABELS.items() if rows[status])]
+ shown = [f"{status:6} {name:39} {detail}" for status in ("update", "failed") for name, detail in rows[status]]
+ if shown:
+ lines += ["", *shown]
+ if problems:
+ lines += ["", *(f"ERROR: {problem}" for problem in problems)]
+ return "\n".join(lines), bool(problems or rows["failed"])
+
+
+def main():
+ try:
+ with CONFIG.open("rb") as file:
+ config = tomllib.load(file)
+ except (OSError, tomllib.TOMLDecodeError) as error:
+ print(f"Cannot load {CONFIG}: {error}", file=sys.stderr)
+ return 1
+ if config.get("__config__", {}).get("oldver") or config.get("__config__", {}).get("newver"):
+ print("Refusing nvchecker config with version file writes", file=sys.stderr)
+ return 1
+ current, problems = recipes(ROOT)
+ entries = set(config) - {"__config__"}
+ problems.extend(f"unmapped recipe: {name}" for name in sorted(current.keys() - entries - UNSUPPORTED.keys()))
+ problems.extend(f"mapping without recipe: {name}" for name in sorted((entries | UNSUPPORTED.keys()) - current.keys()))
+ problems.extend(f"both configured and unsupported: {name}" for name in sorted(entries & UNSUPPORTED.keys()))
+ if problems:
+ print("\n".join(f"ERROR: {problem}" for problem in problems), file=sys.stderr)
+ return 1
+ try:
+ completed = subprocess.run(
+ ["nvchecker", "--logger", "json", "--failures", "-c", str(CONFIG)],
+ text=True, capture_output=True, check=False,
+ )
+ except OSError as error:
+ print(f"Cannot run nvchecker: {error}", file=sys.stderr)
+ return 1
+ text, failed = report(ROOT, config, completed.stdout, completed.returncode)
+ print(text)
+ if completed.stderr:
+ print(completed.stderr, file=sys.stderr, end="")
+ return int(failed)
+
+
+if __name__ == "__main__":
+ sys.exit(main())