99e5641127
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
269 lines
9.4 KiB
Bash
Executable File
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
|