Files
RedBear-OS/local/scripts/check-external-versions.sh
T
vasilito 99e5641127 feat: release-bump pipeline + external graphics version sync
Pipeline (3 operator asks):
- bump-release.sh: canonical orchestrator (forks + sources + external)
- upgrade-forks.sh --to=<tag>: rebase forks with diverged-mode guard
- bump-graphics-recipes.sh: map-driven group-aware graphics bumps
- check-external-versions.sh: drift checker for Qt6/KF6/Plasma/Mesa/Wayland
- refresh-fork-upstream-map.sh: append-only map updater with --check
- post-checkout-version-sync.sh + install-git-hooks.sh: opt-in branch hook
- external_version_lib.py: shared version-parsing/bumping library
- external-upstream-map.toml: ~80 external package entries
- bump-fork.sh: deprecated (REDBEAR_I_KNOW_BUMP_FORK_IS_DEPRECATED=1)
- RELEASE-BUMP-WORKFLOW.md: operator runbook

Quality fixes (8 defects from two independent audits):
- blake2b stable cache keys (was hash(), non-portable)
- atomic cache writes via os.replace
- version_sort_key pre-release demotion (was sorting after finals)
- apply_ver_transform re.error tolerance
- grep || true (pipefail abort)
- cd failure detection in upgrade-forks
- sed URL escape (injection hardening)
- refresh-fork-upstream-map last-row drop fix

Doc cleanup:
- Archive 5 obsolete plans to local/docs/archived/
- Remove 14 stale/superseded docs
- Update 18 docs to reference bump-release.sh and fix inbound links
- TOOLS.md drift fixes
2026-07-18 14:45:41 +09:00

269 lines
9.4 KiB
Bash
Executable File

#!/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<name>[^/]+)'
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