Files
RedBear-OS/local/scripts/lib/external_version_lib.py
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

214 lines
7.4 KiB
Python

"""Shared upstream-version resolution helpers for Red Bear external-package tooling.
Consumers (add local/scripts/lib to sys.path, then import):
- local/scripts/check-external-versions.sh (embedded Python)
- local/scripts/bump-graphics-recipes.sh (embedded Python)
Single source of truth for: index fetching (cache + fallback), version sort
keys, sed-style version transforms, index resolution, tar-template rendering,
group member helpers, recipe.toml source pins, and git ls-remote tag
resolution. Behavior changes belong here, not in the consumers.
"""
import hashlib
import os
import re
import subprocess
from pathlib import Path
from typing import Optional
_CACHE_DIR: Optional[Path] = None
_NO_FETCH = False
def configure(cache_dir: Path, no_fetch: bool) -> None:
global _CACHE_DIR, _NO_FETCH
_CACHE_DIR = Path(cache_dir)
_NO_FETCH = bool(no_fetch)
def cache_key_for(url: str) -> Path:
assert _CACHE_DIR is not None, "configure() must be called before fetch use"
safe = re.sub(r'[^a-zA-Z0-9_.-]', '_', url)
if len(safe) > 180:
# Stable hash (blake2b) — NOT Python's hash(), which is randomized
# per-process via PYTHONHASHSEED and would orphan cache entries.
digest = hashlib.blake2b(url.encode(), digest_size=8).hexdigest()
safe = safe[:80] + "___" + digest + "___" + safe[-80:]
return _CACHE_DIR / f"{safe}.cache"
def fetch(url: str):
"""Return (text, source); source is fetch/cache/cache-fallback*/error."""
assert _CACHE_DIR is not None, "configure() must be called before fetch use"
cache = cache_key_for(url)
if _NO_FETCH:
if cache.exists():
return cache.read_text(errors="replace"), "cache"
return None, "no-cache"
try:
result = subprocess.run(
["curl", "-sLf", "--retry", "3", "--retry-delay", "2",
"--connect-timeout", "20", "--max-time", "90", url],
capture_output=True, text=True, timeout=120,
)
if result.returncode == 0 and result.stdout:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
tmp = cache.with_suffix(cache.suffix + ".tmp")
tmp.write_text(result.stdout)
os.replace(str(tmp), str(cache))
return result.stdout, "fetch"
if cache.exists():
return cache.read_text(errors="replace"), "cache-fallback"
err = (result.stderr or "").strip()
return None, err or f"curl-exit-{result.returncode}"
except subprocess.TimeoutExpired:
if cache.exists():
return cache.read_text(errors="replace"), "cache-fallback-timeout"
return None, "timeout"
except Exception as e:
if cache.exists():
return cache.read_text(errors="replace"), "cache-fallback-error"
return None, str(e)
def version_sort_key(v: str):
"""Sort key that orders pre-releases BEFORE the corresponding final.
Handles forms like 1.0.0, 1.0.0-rc1, 1.0.0-alpha1, 1.0.0-beta2.
A final release sorts after any pre-release of the same X.Y.Z base.
"""
m = re.match(r'^(\d+(?:[._]\d+)*)(?:[-.]([0-9A-Za-z]+))?$', v)
if not m:
parts = re.split(r'[._+-]', v)
return [(1, 0, p) if not p.isdigit() else (0, int(p), "") for p in parts]
main_str, pre = m.group(1), m.group(2)
main_key = [(0, int(p), "") if p.isdigit() else (1, 0, p)
for p in re.split(r'[._]', main_str)]
if pre is None or pre == "":
pre_key = [(1, 0, "zzzzzz_final")]
else:
pre_key = [(-1, 0, pre)]
return main_key + pre_key
def apply_ver_transform(ver: str, transform: str) -> str:
"""Apply a sed-style transform like 's/-/./g' to a version token."""
m = re.match(r'^s/(.*)/(.*)/([gi]*)$', transform)
if not m:
return ver
pat, repl, flags = m.group(1), m.group(2), m.group(3)
count = 0 if 'g' in flags else 1
try:
return re.sub(pat, repl, ver, count=count)
except re.error:
return ver
def resolve_index(index_url: str, regex_str: str, resolve_kind: str,
ver_transform: Optional[str] = None):
"""Resolve latest version vars from an index page -> (vars, source, error)."""
text, src = fetch(index_url)
if text is None:
return None, src, "fetch-failed"
try:
pattern = re.compile(regex_str)
except re.error as e:
return None, src, f"bad-regex: {e}"
matches = pattern.findall(text)
if not matches:
return None, src, "no-match"
norm = []
for mm in matches:
if isinstance(mm, tuple):
mm = "-".join(str(x) for x in mm)
norm.append(mm)
unique = list(dict.fromkeys(norm))
unique.sort(key=version_sort_key, reverse=True)
best = unique[0]
if ver_transform:
best = apply_ver_transform(best, ver_transform)
if resolve_kind == "majmin":
return {"majmin": best, "ver": f"{best}.0"}, src, None
parts = best.split(".")
majmin = ".".join(parts[:2]) if len(parts) >= 2 else best
return {"majmin": majmin, "ver": best}, src, None
def render_template(template: str, variables: dict) -> str:
out = template
for k in ("ver", "majmin", "name"):
out = out.replace("{" + k + "}", variables.get(k, ""))
return out
def member_url_name(group: dict, member: str) -> str:
overrides = group.get("member_url_names", {}) or {}
if member in overrides:
return overrides[member]
strip = group.get("url_name_strip_prefix", "") or ""
if strip and member.startswith(strip):
return member[len(strip):]
return member
def member_template(group: dict, member: str) -> str:
mts = group.get("member_templates", {}) or {}
if member in mts and "tar_template" in mts[member]:
return mts[member]["tar_template"]
return group["tar_template"]
def read_recipe_text(recipe_path: Path) -> str:
toml = recipe_path / "recipe.toml"
if not toml.exists():
return ""
return toml.read_text(errors="replace")
def current_tar_url(text: str):
m = re.search(r'^\s*tar\s*=\s*"([^"]+)"', text, re.MULTILINE)
return m.group(1) if m else None
def current_rev(text: str):
m = re.search(r'^\s*rev\s*=\s*"([^"]+)"', text, re.MULTILINE)
return m.group(1) if m else None
def resolve_git_rev(git_url: str, tag_regex_str: str):
"""Resolve latest tag + commit sha via git ls-remote --tags."""
try:
result = subprocess.run(
["git", "ls-remote", "--tags", git_url],
capture_output=True, text=True, timeout=90,
)
except subprocess.TimeoutExpired:
return None, "timeout"
except Exception as e:
return None, str(e)
if result.returncode != 0:
return None, (result.stderr or "git-error").strip()
try:
pattern = re.compile(tag_regex_str)
except re.error as e:
return None, f"bad-regex: {e}"
shas = {}
for line in result.stdout.splitlines():
parts = line.split("\t")
if len(parts) != 2:
continue
sha, ref = parts
if not ref.startswith("refs/tags/"):
continue
tag = ref[len("refs/tags/"):]
deref = tag.endswith("^{}")
clean = tag[:-3] if deref else tag
if pattern.fullmatch(clean):
if clean not in shas or deref:
shas[clean] = sha.strip()
if not shas:
return None, "no-tags-matched"
best = max(shas.keys(), key=version_sort_key)
return {"tag": best, "sha": shas[best]}, None