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
214 lines
7.4 KiB
Python
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
|