#!/usr/bin/env bash # check-external-versions.sh — Report current vs latest upstream versions for # every entry in local/external-upstream-map.toml. # # Read-only. Network is used to resolve latest versions unless --no-fetch is # passed (in which case the on-disk cache under .redbear-recipe-bump/ is used # and the script errors on a missing cache entry). # # Usage: # check-external-versions.sh [--no-fetch] [--strict] [--json] [--help] # # Output (default): a fixed-width table # name current latest STATUS # qtbase 6.11.1 6.11.1 CURRENT # ... # Groups expand to one row per member; the group's resolved version is shown # for every member, so any member whose embedded URL version differs from the # group's is flagged OUTDATED. # # Exit codes: # 0 always (default); --strict exits 1 if any entry is OUTDATED or failed # to resolve. # # Map-driven: the TOML at local/external-upstream-map.toml is the single source # of truth. See that file's header for schema and update policy. set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" MAP="$ROOT/local/external-upstream-map.toml" if [ ! -f "$MAP" ]; then echo "ERROR: map not found: $MAP" >&2 exit 2 fi # Delegate everything to an embedded Python3 script. Python (stdlib tomllib) # gives correct TOML parsing; curl (called via subprocess) is used for the # actual network fetches per the plan's requirement. exec python3 - "$ROOT" "$@" <<'PYEOF' import json import re import sys import tomllib from pathlib import Path ROOT = Path(sys.argv[1]) MAP_PATH = ROOT / "local/external-upstream-map.toml" CACHE_DIR = ROOT / ".redbear-recipe-bump" args = sys.argv[2:] NO_FETCH = "--no-fetch" in args STRICT = "--strict" in args AS_JSON = "--json" in args if "--help" in args or "-h" in args: print(__doc__.replace("check-external-versions.sh", Path(sys.argv[0]).name)) sys.exit(0) with open(MAP_PATH, "rb") as f: MAP = tomllib.load(f) sys.path.insert(0, str(ROOT / "local/scripts/lib")) import external_version_lib external_version_lib.configure(CACHE_DIR, NO_FETCH) from external_version_lib import ( cache_key_for, fetch, version_sort_key, apply_ver_transform, resolve_index, render_template, member_url_name, member_template, read_recipe_text, current_tar_url, current_rev, resolve_git_rev, ) def template_to_regex(template: str) -> re.Pattern: """Convert a tar_template into a regex that captures {ver}/{majmin}/{name}. Handles templates where the same variable appears more than once: the first occurrence becomes a named capture group, subsequent ones become backreferences. {name} is greedy; {ver}/{majmin} are anchored to a leading digit so '{name}-{ver}' doesn't split 'extra-cmake-modules' wrong. """ escaped = re.escape(template) # re.escape emits '\{ver\}' for '{ver}' on Python 3.7+ for var in ("ver", "majmin", "name"): token = "\\{" + var + "\\}" # literal \{var\} as it appears post-escape if token not in escaped: continue if var == "name": capture = r'(?P[^/]+)' else: capture = f'(?P<{var}>\\d[^/]*?)' escaped = escaped.replace(token, capture, 1) # remaining occurrences → backreference escaped = escaped.replace(token, f"(?P={var})") return re.compile(escaped) def extract_version_vars(url: str, template: str): """Reverse-match url against template to recover {ver}/{majmin}.""" try: rx = template_to_regex(template) except re.error: return {} m = rx.search(url) if not m: return {} d = m.groupdict() out = {} if d.get("ver"): out["ver"] = d["ver"] if d.get("majmin"): out["majmin"] = d["majmin"] return out # --- main ----------------------------------------------------------------- rows = [] # list of dicts: name, current, latest, status, note summary = {"current": 0, "outdated": 0, "failed": 0} # Groups: resolve the group version ONCE, then expand to members. for gid, g in MAP.get("groups", {}).items(): vars_dict, src, err = resolve_index( g["version_index"], g["version_regex"], g.get("resolve", "version")) group_latest = vars_dict["ver"] if vars_dict else "?" for member in g["members"]: recipe_path = ROOT / g["recipe_root"] / member tmpl = member_template(g, member) uname = member_url_name(g, member) text = read_recipe_text(recipe_path) cur_url = current_tar_url(text) if cur_url is None: rows.append({ "name": f"{gid}:{member}", "current": "?", "latest": group_latest, "status": "FAILED", "note": "no-tar-line", }) summary["failed"] += 1 continue # construct what the latest tar URL would be if vars_dict is None: status = "FAILED" note = f"index:{err}" else: render_vars = dict(vars_dict) render_vars["name"] = uname latest_url = render_template(tmpl, render_vars) cur_vars = extract_version_vars(cur_url, tmpl) cur_ver = cur_vars.get("ver", "?") if latest_url == cur_url: status = "CURRENT" note = "" else: status = "OUTDATED" note = "" rows.append({ "name": f"{gid}:{member}", "current": cur_ver, "latest": group_latest, "status": status, "note": note, }) if status == "CURRENT": summary["current"] += 1 else: summary["outdated"] += 1 continue # failed resolution path cur_vars = extract_version_vars(cur_url, tmpl) rows.append({ "name": f"{gid}:{member}", "current": cur_vars.get("ver", "?"), "latest": "?", "status": status, "note": note, }) summary["failed"] += 1 # Packages (singletons) for pid, p in MAP.get("packages", {}).items(): recipe_path = ROOT / p["recipe"] text = read_recipe_text(recipe_path) kind = p["kind"] if kind == "tar": cur_url = current_tar_url(text) tmpl = p["tar_template"] cur_vars = extract_version_vars(cur_url or "", tmpl) cur_ver = cur_vars.get("ver", "?") vars_dict, src, err = resolve_index( p["version_index"], p["version_regex"], p.get("resolve", "version"), p.get("ver_transform")) if vars_dict is None: rows.append({ "name": pid, "current": cur_ver, "latest": "?", "status": "FAILED", "note": f"index:{err}", }) summary["failed"] += 1 continue latest_url = render_template(tmpl, {**vars_dict, "name": pid}) status = "CURRENT" if latest_url == cur_url else "OUTDATED" rows.append({ "name": pid, "current": cur_ver, "latest": vars_dict["ver"], "status": status, "note": "", }) if status == "CURRENT": summary["current"] += 1 else: summary["outdated"] += 1 elif kind == "git-rev": cur = current_rev(text) or "?" resolved, err = resolve_git_rev(p["git_url"], p["tag_regex"]) if resolved is None: rows.append({ "name": pid, "current": cur[:12], "latest": "?", "status": "FAILED", "note": f"git:{err}", }) summary["failed"] += 1 continue latest_sha = resolved["sha"] latest_tag = resolved["tag"] # current may be either a sha or a tag name is_current = ( cur == latest_sha or cur.startswith(latest_sha[:12]) or cur == latest_tag ) status = "CURRENT" if is_current else "OUTDATED" rows.append({ "name": pid, "current": cur[:12] if len(cur) >= 40 else cur, "latest": f"{latest_tag}@{latest_sha[:12]}", "status": status, "note": "", }) if status == "CURRENT": summary["current"] += 1 else: summary["outdated"] += 1 else: rows.append({ "name": pid, "current": "?", "latest": "?", "status": "FAILED", "note": f"unknown-kind:{kind}", }) summary["failed"] += 1 # --- output --------------------------------------------------------------- if AS_JSON: payload = { "summary": summary, "rows": rows, } print(json.dumps(payload, indent=2)) else: # fixed-width table name_w = max(len("name"), max((len(r["name"]) for r in rows), default=0)) cur_w = max(len("current"), max((len(str(r["current"])) for r in rows), default=0)) lat_w = max(len("latest"), max((len(str(r["latest"])) for r in rows), default=0)) hdr = (f"{'name':<{name_w}} {'current':<{cur_w}} " f"{'latest':<{lat_w}} STATUS") print(hdr) print("-" * len(hdr)) for r in rows: note = f" ({r['note']})" if r.get("note") else "" print(f"{r['name']:<{name_w}} {str(r['current']):<{cur_w}} " f"{str(r['latest']):<{lat_w}} {r['status']}{note}") print() print(f"summary: current={summary['current']} " f"outdated={summary['outdated']} failed={summary['failed']}") if NO_FETCH: print("(from cache; --no-fetch)") exit_code = 0 if STRICT and (summary["outdated"] > 0 or summary["failed"] > 0): exit_code = 1 sys.exit(exit_code) PYEOF