diff options
| author | coast <coasteen@proton.me> | 2026-10-12 14:41:07 +0330 |
|---|---|---|
| committer | coast <coasteen@proton.me> | 2026-10-12 14:41:07 +0330 |
| commit | 25922912c7e37f3bc3000e71b647e77677d8f2c5 (patch) | |
| tree | 8383c5569a668613eee384ee31ea8cddc2113ee5 /scripts/check-versions.py | |
| download | exherbo-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.py | 191 |
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()) |
