#!/usr/bin/env bash # bump-graphics-recipes.sh — Map-driven external source-bump engine. # # Rewritten 2026-07-18 from the legacy hardcoded UPSTREAM_INDEX approach. # The single source of truth is local/external-upstream-map.toml; this script # never scans directories (symlinked duplicates like recipes/qt/qtbase ↔ # recipes/wip/qt/qtbase are handled by iterating the MAP, which lists each # canonical local/recipes/ path exactly once). # # What it does: # - Resolves each group's latest version ONCE, then applies to all members. # - Resolves each singleton's latest version individually. # - tar bumps: download new tarball → b3sum → sed tar=/blake3= in recipe.toml # (+ sync [package] version = "..." if present, semver, and differs). # - git-rev bumps: git ls-remote --tags → latest matching tag → resolve its # commit sha (annotated tags dereferenced via ^{}) → sed rev = "...". # - After each non-dry-run bump: runs ./target/release/repo validate-patches # and captures [PASS]/[FAIL] counts. # - Writes a machine-parseable stabilization report to stdout AND # .redbear-recipe-bump/last-report.txt. # # Usage: # bump-graphics-recipes.sh [--dry-run] [--no-fetch] # # --dry-run resolve and print intended changes; perform no downloads, # edits, or validate-patches runs. # --no-fetch reuse cached version indexes under .redbear-recipe-bump/; # error on entries whose cache is missing. # # Never commits. Never deletes recipes. Never creates branches. # See local/external-upstream-map.toml for the package inventory and schema. set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" MAP="$ROOT/local/external-upstream-map.toml" CACHE_DIR="$ROOT/.redbear-recipe-bump" REPORT="$CACHE_DIR/last-report.txt" DRY_RUN=0 NO_FETCH=0 for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=1 ;; --no-fetch) NO_FETCH=1 ;; --help|-h) grep -E '^#' "$0" | head -30; exit 0 ;; *) echo "Unknown option: $arg" >&2; exit 1 ;; esac done if [ ! -f "$MAP" ]; then echo "ERROR: map not found: $MAP" >&2 exit 2 fi REPO_BIN="$ROOT/target/release/repo" NO_FETCH_FLAG="" [ "$NO_FETCH" = "1" ] && NO_FETCH_FLAG="--no-fetch" mkdir -p "$CACHE_DIR" # --------------------------------------------------------------------------- # Phase 1: Python resolves every entry and emits a TSV action plan. # Fields (tab-separated): # ACTION RECIPE_NAME RECIPE_DIR KIND OLD NEW NEW_VERSION NEW_PKG_VERSION NOTE # ACTION ∈ {bump, current, failed} # --------------------------------------------------------------------------- ACTIONS_FILE="$(mktemp)" trap 'rm -f "$ACTIONS_FILE"' EXIT python3 - "$ROOT" "$NO_FETCH_FLAG" <<'PYEOF' > "$ACTIONS_FILE" import re import sys import tomllib from pathlib import Path ROOT = Path(sys.argv[1]) NO_FETCH = "--no-fetch" in sys.argv[2:] MAP_PATH = ROOT / "local/external-upstream-map.toml" CACHE_DIR = ROOT / ".redbear-recipe-bump" 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 ( 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 current_pkg_version(text): m = re.search(r'^\[package\]\s*\n(?:[^\[]*?\n)*?\s*version\s*=\s*"([^"]+)"', text, re.MULTILINE) return m.group(1) if m else None def is_semver(v): return bool(re.match(r'^\d+\.\d+(\.\d+)?$', v or "")) def emit(action, name, rdir, kind, old, new, new_ver, pkg_ver, note): print("\t".join([action, name, str(rdir), kind, old or "", new or "", new_ver or "", pkg_ver or "", note or ""])) # Groups for gid, g in MAP.get("groups", {}).items(): v, _src, err = resolve_index(g["version_index"], g["version_regex"], g.get("resolve", "version")) for member in g["members"]: rdir = ROOT / g["recipe_root"] / member text = read_recipe_text(rdir) cur = current_tar_url(text) tmpl = member_template(g, member) uname = member_url_name(g, member) if cur is None: emit("failed", f"{gid}:{member}", rdir, "tar", "", "", "", "", "no-tar-in-recipe") continue if v is None: emit("failed", f"{gid}:{member}", rdir, "tar", cur, "", "", "", f"index:{err}") continue new_url = render_template(tmpl, {**v, "name": uname}) if new_url == cur: emit("current", f"{gid}:{member}", rdir, "tar", cur, new_url, v["ver"], "", "") else: pkg = current_pkg_version(text) new_pkg = v["ver"] if (pkg and is_semver(pkg) and pkg != v["ver"]) else "" emit("bump", f"{gid}:{member}", rdir, "tar", cur, new_url, v["ver"], new_pkg, "") # Singletons for pid, p in MAP.get("packages", {}).items(): rdir = ROOT / p["recipe"] text = read_recipe_text(rdir) if p["kind"] == "tar": cur = current_tar_url(text) if cur is None: emit("failed", pid, rdir, "tar", "", "", "", "", "no-tar-in-recipe") continue v, _src, err = resolve_index(p["version_index"], p["version_regex"], p.get("resolve", "version"), p.get("ver_transform")) if v is None: emit("failed", pid, rdir, "tar", cur, "", "", "", f"index:{err}") continue new_url = render_template(p["tar_template"], {**v, "name": pid}) if new_url == cur: emit("current", pid, rdir, "tar", cur, new_url, v["ver"], "", "") else: pkg = current_pkg_version(text) new_pkg = v["ver"] if (pkg and is_semver(pkg) and pkg != v["ver"]) else "" emit("bump", pid, rdir, "tar", cur, new_url, v["ver"], new_pkg, "") elif p["kind"] == "git-rev": cur = current_rev(text) if cur is None: emit("failed", pid, rdir, "git-rev", "", "", "", "", "no-rev-in-recipe") continue res, err = resolve_git_rev(p["git_url"], p["tag_regex"]) if res is None: emit("failed", pid, rdir, "git-rev", cur, "", "", "", f"git:{err}") continue new_sha = res["sha"] if cur == new_sha or cur.startswith(new_sha[:12]) or cur == res["tag"]: emit("current", pid, rdir, "git-rev", cur, new_sha, res["tag"], "", "") else: emit("bump", pid, rdir, "git-rev", cur, new_sha, res["tag"], "", "") PYEOF # --------------------------------------------------------------------------- # Phase 2: bash applies the action plan. # --------------------------------------------------------------------------- UPDATED=0 CURRENT=0 FAILED=0 REPORT_LINES=() run_validate_patches() { # $1 = recipe name (basename). Outputs patches_total/pass/fail on stdout # as "total=N pass=N fail=N". Returns non-zero only if the binary is # missing (caller treats that as a warning, not a failure). local recipe="$1" if [ ! -x "$REPO_BIN" ]; then echo "total=0 pass=0 fail=0" echo "WARN: $REPO_BIN not found; skipped validate-patches for $recipe" >&2 return 1 fi local out out="$(REDBEAR_CANONICAL_BUILD=1 "$REPO_BIN" validate-patches "$recipe" 2>&1)" || true local pass fail pass="$(printf '%s\n' "$out" | grep -c '\[PASS\]' || true)" fail="$(printf '%s\n' "$out" | grep -c '\[FAIL\]' || true)" local total=$((pass + fail)) echo "total=${total} pass=${pass} fail=${fail}" } # Read the TSV action plan line by line. while IFS=$'\t' read -r ACTION RNAME RDIR KIND OLD NEW NVER NPKG NOTE; do [ -n "$ACTION" ] || continue RTOML="$RDIR/recipe.toml" case "$ACTION" in current) CURRENT=$((CURRENT + 1)) REPORT_LINES+=("recipe=$RNAME old=$OLD new=$NEW patches_total= patches_pass= patches_fail= fail_details=") if [ "$DRY_RUN" = "1" ]; then printf '[current] %s (already at %s)\n' "$RNAME" "$NVER" fi ;; failed) FAILED=$((FAILED + 1)) REPORT_LINES+=("recipe=$RNAME old=$OLD new= patches_total= patches_pass= patches_fail= fail_details=$NOTE") printf '[FAILED] %s: %s\n' "$RNAME" "$NOTE" >&2 ;; bump) if [ "$DRY_RUN" = "1" ]; then if [ "$KIND" = "tar" ]; then printf '[would-bump] %s\n %s\n -> %s\n (would download + b3sum + sed tar=/blake3=)\n' \ "$RNAME" "$OLD" "$NEW" else printf '[would-bump] %s\n rev=%s\n -> rev=%s (%s)\n' \ "$RNAME" "$OLD" "$NEW" "$NVER" fi UPDATED=$((UPDATED + 1)) REPORT_LINES+=("recipe=$RNAME old=$OLD new=$NEW patches_total= patches_pass= patches_fail= fail_details=(dry-run)") continue fi # --- tar bump --- if [ "$KIND" = "tar" ]; then tmp="$(mktemp)" if ! curl -sLf --retry 3 --retry-delay 2 --connect-timeout 20 --max-time 300 "$NEW" -o "$tmp" 2>/dev/null; then FAILED=$((FAILED + 1)) REPORT_LINES+=("recipe=$RNAME old=$OLD new=$NEW patches_total= patches_pass= patches_fail= fail_details=download-failed") printf '[FAILED] %s: download failed for %s\n' "$RNAME" "$NEW" >&2 rm -f "$tmp" continue fi new_hash="$(b3sum "$tmp" | awk '{print $1}')" rm -f "$tmp" # Escape sed-special replacement chars (& \ |) so URLs containing # query strings or other metacharacters do not corrupt the rewrite. esc_new=$(printf '%s' "$NEW" | sed 's/[&\\|]/\\&/g') sed -i -E "s|^\\s*tar\\s*=\\s*\"[^\"]+\"|tar = \"$esc_new\"|" "$RTOML" sed -i -E "s|^\\s*blake3\\s*=\\s*\"[a-f0-9]+\"|blake3 = \"$new_hash\"|" "$RTOML" if [ -n "$NPKG" ]; then sed -i -E "s|^(\\s*version\\s*=\\s*)\"[^\"]+\"|\\1\"$NPKG\"|" "$RTOML" fi UPDATED=$((UPDATED + 1)) # --- git-rev bump --- elif [ "$KIND" = "git-rev" ]; then sed -i -E "s|^\\s*rev\\s*=\\s*\"[^\"]+\"|rev = \"$NEW\"|" "$RTOML" UPDATED=$((UPDATED + 1)) fi # --- validate-patches --- validate_out="$(run_validate_patches "$(basename "$RDIR")" 2>/dev/null || true)" vt="$(printf '%s' "$validate_out" | grep -oE 'total=[0-9]*' | head -1 | cut -d= -f2)" vp="$(printf '%s' "$validate_out" | grep -oE 'pass=[0-9]*' | head -1 | cut -d= -f2)" vf="$(printf '%s' "$validate_out" | grep -oE 'fail=[0-9]*' | head -1 | cut -d= -f2)" vt="${vt:-0}"; vp="${vp:-0}"; vf="${vf:-0}" REPORT_LINES+=("recipe=$RNAME old=$OLD new=$NEW patches_total=$vt patches_pass=$vp patches_fail=$vf fail_details=") ;; esac done < "$ACTIONS_FILE" # --------------------------------------------------------------------------- # Phase 3: report + summary. # --------------------------------------------------------------------------- { for line in "${REPORT_LINES[@]}"; do printf '%s\n' "$line" done } | tee "$REPORT" echo "" echo "=== Bump summary ===" echo "Updated (or would-update): $UPDATED" echo "Already current: $CURRENT" echo "Failed (network/parse): $FAILED" [ "$DRY_RUN" = "1" ] && echo "(dry-run; no files changed)" echo "Report written to: $REPORT"