sys: add simple deps update checker script (#6542)
Co-authored-by: Anton Kochkov <anton.kochkov@gmail.com>
This commit is contained in:
parent
c351d5f32a
commit
51414a4eb3
1 changed files with 768 additions and 0 deletions
768
sys/check_outdated_subprojects.py
Executable file
768
sys/check_outdated_subprojects.py
Executable file
|
|
@ -0,0 +1,768 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2026 RizinOrg <info@rizin.re>
|
||||
# SPDX-License-Identifier: LGPL-3.0-only
|
||||
"""
|
||||
check_outdated_subprojects.py
|
||||
|
||||
Check whether Rizin's Meson subproject wraps (subprojects/*.wrap) are outdated.
|
||||
|
||||
It understands the three ways a wrap can pin an upstream:
|
||||
|
||||
* [wrap-file] -> a versioned tarball (e.g. zstd-1.5.7). The current
|
||||
version is read from the wrap and compared against
|
||||
the newest upstream tag.
|
||||
* [wrap-git] + tag -> e.g. revision = v1.11. Compared against newest tag.
|
||||
* [wrap-git] + commit -> a 40-char SHA. Compared against the tip of the
|
||||
tracked branch (newer commits == "behind").
|
||||
* [wrap-git] + branch -> e.g. revision = main. Always builds the branch tip,
|
||||
so it is reported as "tracking" (never "outdated").
|
||||
|
||||
Exit status is 1 if any wrap is OUTDATED (a newer release exists), else 0,
|
||||
which makes it usable as a CI check. "behind"/"tracking"/"unknown" do not fail.
|
||||
|
||||
Usage:
|
||||
python3 check_outdated_subprojects.py [PATH] [options]
|
||||
|
||||
PATH may be a rizin checkout, its subprojects/ dir, or omitted (auto-detect).
|
||||
|
||||
Options:
|
||||
--json Emit machine-readable JSON instead of a report.
|
||||
--include-prerelease Flag newer pre-releases (alpha/beta/rc) as updates
|
||||
even when the current pin is a stable release.
|
||||
--jobs N Parallel ls-remote workers (default 8).
|
||||
--timeout S Per-repo ls-remote timeout in seconds (default 30).
|
||||
--no-color Disable ANSI colors.
|
||||
--self-test Run the built-in unit tests (no network) and exit.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
GIT = "git"
|
||||
|
||||
# Upstreams whose wrap does not reference a git host we can ls-remote
|
||||
# (e.g. zlib points only at tarball mirrors). Map wrap stem -> git URL.
|
||||
UPSTREAM_OVERRIDES = {
|
||||
"zlib": "https://github.com/madler/zlib.git",
|
||||
}
|
||||
|
||||
HEX40 = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
GITHUB_RE = re.compile(r"github\.com[/:]+([^/]+)/([^/#?]+?)(?:\.git)?(?:[/#?]|$)")
|
||||
|
||||
# Matches the leading numeric, dot-separated part of a version string, e.g.
|
||||
# the "5.8.1" in "5.8.1-Alpha9". It is anchored at the start of the (already
|
||||
# prefix-stripped) core and has no nested or overlapping quantifiers, so it
|
||||
# always runs in linear time.
|
||||
_REL_RE = re.compile(r"\d+(?:\.\d+)*")
|
||||
_ARCHIVE_EXTS = (
|
||||
".tar.gz",
|
||||
".tar.xz",
|
||||
".tar.bz2",
|
||||
".tar.zst",
|
||||
".tgz",
|
||||
".txz",
|
||||
".tbz2",
|
||||
".zip",
|
||||
".gz",
|
||||
".xz",
|
||||
".bz2",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Version parsing & comparison
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _chunks(s):
|
||||
"""Split a pre-release string into comparable (is_alpha, num, text) units."""
|
||||
out = []
|
||||
for part in re.findall(r"\d+|[^\d]+", s):
|
||||
if part.isdigit():
|
||||
out.append((0, int(part), ""))
|
||||
else:
|
||||
out.append((1, 0, part))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _version_core(tag):
|
||||
"""Return the version core of *tag* with any name prefix and leading "v"
|
||||
removed, e.g. "xz-5.8.1" -> "5.8.1", "v1.3" -> "1.3", "6.0.0-Alpha9"
|
||||
unchanged. Returns None if *tag* has no version core.
|
||||
|
||||
The name prefix is stripped with a plain left-to-right string scan rather
|
||||
than a regular expression, so this is guaranteed to run in linear time and
|
||||
cannot suffer from catastrophic backtracking (ReDoS).
|
||||
"""
|
||||
if not tag:
|
||||
return None
|
||||
s = tag.strip()
|
||||
start = 0
|
||||
if not (s[:1].isdigit() or (s[:1] in "vV" and s[1:2].isdigit())):
|
||||
# There is a name prefix. The version begins right after the first
|
||||
# "-"/"_" that is followed by an optional "v" and a digit; everything
|
||||
# before it (e.g. "tree-sitter-") is the name. Stopping at the *first*
|
||||
# such separator mirrors the original regex, whose name segments each
|
||||
# had to start with a letter -- so a dash-separated numeric tail like
|
||||
# "rel-0-11-18" keeps its leading "0" rather than collapsing to "18".
|
||||
boundary = -1
|
||||
for i, ch in enumerate(s):
|
||||
if ch in "-_":
|
||||
j = i + 1
|
||||
if j < len(s) and s[j] in "vV":
|
||||
j += 1
|
||||
if j < len(s) and s[j].isdigit():
|
||||
boundary = i
|
||||
break
|
||||
if boundary < 0:
|
||||
return None
|
||||
start = boundary + 1
|
||||
core = s[start:]
|
||||
if core[:1] in "vV" and core[1:2].isdigit():
|
||||
core = core[1:]
|
||||
return core if core[:1].isdigit() else None
|
||||
|
||||
|
||||
def parse_version(tag):
|
||||
"""Return (release_tuple, is_final, prerelease_chunks) or None if not a version."""
|
||||
core = _version_core(tag)
|
||||
if core is None:
|
||||
return None
|
||||
m = _REL_RE.match(core)
|
||||
if not m:
|
||||
return None
|
||||
release = tuple(int(x) for x in m.group(0).split("."))
|
||||
pre = core[m.end() :].lstrip("-._").lower()
|
||||
return (release, pre == "", _chunks(pre) if pre else ())
|
||||
|
||||
|
||||
def version_key(parsed):
|
||||
"""Sort key: higher == newer. A final release outranks its pre-releases."""
|
||||
release, is_final, pre = parsed
|
||||
return (release, 1 if is_final else 0, pre)
|
||||
|
||||
|
||||
def version_display(tag):
|
||||
"""Human-friendly version core (drops name prefixes / leading v)."""
|
||||
core = _version_core(tag)
|
||||
return core if core is not None else tag
|
||||
|
||||
|
||||
def best_tag(tags, include_pre):
|
||||
"""Return (raw_tag, key) for the newest version-like tag, or (None, None)."""
|
||||
best, best_key = None, None
|
||||
for t in tags:
|
||||
p = parse_version(t)
|
||||
if p is None:
|
||||
continue
|
||||
if not include_pre and not p[1]: # skip pre-releases unless asked
|
||||
continue
|
||||
k = version_key(p)
|
||||
if best_key is None or k > best_key:
|
||||
best, best_key = t, k
|
||||
return best, best_key
|
||||
|
||||
|
||||
def compare_to_tags(current_raw, tags, include_pre):
|
||||
"""Compare a current version string against a set of upstream tags."""
|
||||
cur = parse_version(current_raw)
|
||||
if cur is None:
|
||||
return "unknown", None, "could not parse current version %r" % current_raw
|
||||
inc = (not cur[1]) or include_pre # if current is a pre-release, consider all
|
||||
best, best_key = best_tag(tags, inc)
|
||||
if best is None: # no stable tags? fall back to anything parseable
|
||||
best, best_key = best_tag(tags, True)
|
||||
if best is None:
|
||||
return "unknown", None, "no version-like tags found upstream"
|
||||
if best_key > version_key(cur):
|
||||
return "outdated", best, None
|
||||
return "up-to-date", best, None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# git ls-remote
|
||||
# --------------------------------------------------------------------------- #
|
||||
def remote_refs(url, timeout):
|
||||
"""Return {'heads':{}, 'tags':{}, 'default':str, 'head':sha} or {'error':...}."""
|
||||
try:
|
||||
p = subprocess.run(
|
||||
[GIT, "ls-remote", "--symref", url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": "timeout after %ss" % timeout}
|
||||
except FileNotFoundError:
|
||||
return {"error": "the `git` binary was not found"}
|
||||
if p.returncode != 0:
|
||||
tail = (p.stderr or "").strip().splitlines()
|
||||
return {"error": tail[-1] if tail else "git ls-remote rc=%d" % p.returncode}
|
||||
|
||||
heads, tags, default, head = {}, {}, None, None
|
||||
for line in p.stdout.splitlines():
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("ref: "):
|
||||
try:
|
||||
ref, name = line[5:].split("\t", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
if name == "HEAD" and ref.startswith("refs/heads/"):
|
||||
default = ref[len("refs/heads/") :]
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
sha, ref = parts
|
||||
if ref == "HEAD":
|
||||
head = sha
|
||||
elif ref.startswith("refs/heads/"):
|
||||
heads[ref[len("refs/heads/") :]] = sha
|
||||
elif ref.startswith("refs/tags/"):
|
||||
name = ref[len("refs/tags/") :]
|
||||
peeled = name.endswith("^{}")
|
||||
if peeled:
|
||||
name = name[:-3]
|
||||
if peeled or name not in tags: # prefer the dereferenced commit
|
||||
tags[name] = sha
|
||||
if default is None and head is not None:
|
||||
for n, s in heads.items():
|
||||
if s == head:
|
||||
default = n
|
||||
break
|
||||
return {"heads": heads, "tags": tags, "default": default, "head": head}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Wrap parsing & analysis
|
||||
# --------------------------------------------------------------------------- #
|
||||
def load_wrap(path):
|
||||
cp = configparser.ConfigParser(strict=False, delimiters=("=",), interpolation=None)
|
||||
cp.optionxform = str # keep key case as-is
|
||||
cp.read(path, encoding="utf-8")
|
||||
return cp
|
||||
|
||||
|
||||
def strip_archive_ext(name):
|
||||
low = name.lower()
|
||||
for ext in _ARCHIVE_EXTS:
|
||||
if low.endswith(ext):
|
||||
return name[: -len(ext)]
|
||||
return name
|
||||
|
||||
|
||||
def github_url(urls):
|
||||
for u in urls:
|
||||
if not u:
|
||||
continue
|
||||
m = GITHUB_RE.search(u)
|
||||
if m:
|
||||
return "https://github.com/%s/%s.git" % (
|
||||
m.group(1),
|
||||
m.group(2),
|
||||
), "%s/%s" % (m.group(1), m.group(2))
|
||||
return None, None
|
||||
|
||||
|
||||
def current_version_from_file(sec):
|
||||
"""Best-effort current version for a [wrap-file]."""
|
||||
candidates = [
|
||||
sec.get("directory", ""),
|
||||
strip_archive_ext(sec.get("source_filename", "")),
|
||||
]
|
||||
for c in candidates:
|
||||
if parse_version(c):
|
||||
return c
|
||||
# Fall back to a version-looking path segment in the URLs
|
||||
# (e.g. zydis: .../download/v4.1.0/zydis-amalgamated.tar.gz).
|
||||
for key in ("source_url", "source_fallback_url"):
|
||||
for part in sec.get(key, "").split("/"):
|
||||
if parse_version(part):
|
||||
return part
|
||||
return ""
|
||||
|
||||
|
||||
def preferred_branch(stem, heads, default):
|
||||
"""Pick the branch a commit-pinned wrap most likely tracks."""
|
||||
chosen = default
|
||||
for seg in re.split(r"[-_]", stem):
|
||||
if seg in heads:
|
||||
chosen = seg # last matching segment wins (e.g. capstone-"next")
|
||||
if chosen in heads:
|
||||
return chosen
|
||||
return default if default in heads else None
|
||||
|
||||
|
||||
def short(sha):
|
||||
return sha[:10] if sha else sha
|
||||
|
||||
|
||||
def analyze(path, args):
|
||||
"""Return a result dict for a single wrap file."""
|
||||
stem = os.path.basename(path)[:-5] # drop ".wrap"
|
||||
res = {
|
||||
"name": stem,
|
||||
"repo": None,
|
||||
"category": None,
|
||||
"current": None,
|
||||
"latest": None,
|
||||
"status": "unknown",
|
||||
"detail": None,
|
||||
}
|
||||
try:
|
||||
cp = load_wrap(path)
|
||||
except configparser.Error as e:
|
||||
res["detail"] = "failed to parse wrap: %s" % e
|
||||
return res
|
||||
|
||||
if cp.has_section("wrap-file"):
|
||||
sec = cp["wrap-file"]
|
||||
res["category"] = "version"
|
||||
res["current"] = version_display(current_version_from_file(sec))
|
||||
url, repo = github_url(
|
||||
[sec.get("source_url", ""), sec.get("source_fallback_url", "")]
|
||||
)
|
||||
if url is None and stem in UPSTREAM_OVERRIDES:
|
||||
url = UPSTREAM_OVERRIDES[stem]
|
||||
repo = re.sub(r"^https?://github\.com/|\.git$", "", url)
|
||||
res["repo"] = repo or "(unknown upstream)"
|
||||
if url is None:
|
||||
res["detail"] = "no upstream git repo found in wrap"
|
||||
return res
|
||||
refs = remote_refs(url, args.timeout)
|
||||
if "error" in refs:
|
||||
res["detail"] = refs["error"]
|
||||
return res
|
||||
status, best, detail = compare_to_tags(
|
||||
current_version_from_file(sec), set(refs["tags"]), args.include_prerelease
|
||||
)
|
||||
res["status"] = status
|
||||
res["detail"] = detail
|
||||
if best is not None:
|
||||
res["latest"] = version_display(best)
|
||||
return res
|
||||
|
||||
if cp.has_section("wrap-git"):
|
||||
sec = cp["wrap-git"]
|
||||
url = sec.get("url", "").strip()
|
||||
revision = sec.get("revision", "").strip()
|
||||
res["repo"] = re.sub(r"^https?://(?:github\.com/)?|\.git$", "", url) or url
|
||||
if not url or not revision:
|
||||
res["detail"] = "wrap-git missing url or revision"
|
||||
return res
|
||||
refs = remote_refs(url, args.timeout)
|
||||
if "error" in refs:
|
||||
res["detail"] = refs["error"]
|
||||
return res
|
||||
|
||||
if HEX40.match(revision): # pinned commit
|
||||
res["category"] = "commit"
|
||||
res["current"] = short(revision)
|
||||
branch = preferred_branch(stem, refs["heads"], refs["default"])
|
||||
if branch is None:
|
||||
res["detail"] = "could not determine a branch to compare against"
|
||||
return res
|
||||
tip = refs["heads"][branch]
|
||||
res["latest"] = short(tip)
|
||||
if tip.lower() == revision.lower():
|
||||
res["status"] = "up-to-date"
|
||||
res["detail"] = "at tip of %s" % branch
|
||||
else:
|
||||
res["status"] = "behind"
|
||||
res["detail"] = "pinned %s, %s tip is %s" % (
|
||||
short(revision),
|
||||
branch,
|
||||
short(tip),
|
||||
)
|
||||
return res
|
||||
|
||||
if revision in refs["heads"]: # tracks a moving branch
|
||||
res["category"] = "branch"
|
||||
res["current"] = revision
|
||||
res["latest"] = short(refs["heads"][revision])
|
||||
res["status"] = "tracking"
|
||||
res["detail"] = "builds latest commit of branch %r" % revision
|
||||
return res
|
||||
|
||||
# otherwise treat as a tag (it is in refs/tags, or just looks like a version)
|
||||
res["category"] = "tag"
|
||||
res["current"] = version_display(revision)
|
||||
if revision not in refs["tags"] and parse_version(revision) is None:
|
||||
res["detail"] = "revision %r is not a known tag/branch/commit" % revision
|
||||
return res
|
||||
status, best, detail = compare_to_tags(
|
||||
revision, set(refs["tags"]), args.include_prerelease
|
||||
)
|
||||
res["status"] = status
|
||||
res["detail"] = detail
|
||||
if best is not None:
|
||||
res["latest"] = version_display(best)
|
||||
return res
|
||||
|
||||
res["detail"] = "no [wrap-file] or [wrap-git] section"
|
||||
return res
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Discovery, reporting, CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
def find_subprojects_dir(path):
|
||||
if path is None:
|
||||
for cand in ("subprojects", "."):
|
||||
if os.path.isdir(cand) and any(
|
||||
f.endswith(".wrap") for f in os.listdir(cand)
|
||||
):
|
||||
return cand
|
||||
return None
|
||||
sub = os.path.join(path, "subprojects")
|
||||
if os.path.isdir(sub):
|
||||
return sub
|
||||
if os.path.isdir(path) and any(f.endswith(".wrap") for f in os.listdir(path)):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# status -> (symbol, label, color)
|
||||
_STATUS = {
|
||||
"outdated": ("[!]", "OUTDATED", "\033[31m"), # red
|
||||
"behind": ("[~]", "BEHIND", "\033[33m"), # yellow
|
||||
"tracking": ("[>]", "TRACKING", "\033[36m"), # cyan
|
||||
"up-to-date": ("[+]", "UP TO DATE", "\033[32m"), # green
|
||||
"unknown": ("[?]", "UNKNOWN", "\033[35m"), # magenta
|
||||
}
|
||||
_ORDER = ["outdated", "behind", "unknown", "tracking", "up-to-date"]
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
def render(results, use_color):
|
||||
def c(text, code):
|
||||
return "%s%s%s" % (code, text, _RESET) if use_color else text
|
||||
|
||||
width = max((len(r["name"]) for r in results), default=4)
|
||||
by_status = {}
|
||||
for r in results:
|
||||
by_status.setdefault(r["status"], []).append(r)
|
||||
|
||||
lines = []
|
||||
for status in _ORDER:
|
||||
group = by_status.get(status)
|
||||
if not group:
|
||||
continue
|
||||
sym, label, color = _STATUS[status]
|
||||
lines.append("")
|
||||
lines.append(c("%s %s" % (sym, label), color))
|
||||
for r in sorted(group, key=lambda x: x["name"]):
|
||||
cur = r["current"] or "?"
|
||||
if r["status"] in ("outdated",):
|
||||
ver = "%s -> %s" % (cur, r["latest"] or "?")
|
||||
elif r["status"] == "behind":
|
||||
ver = "%s -> %s" % (cur, r["latest"] or "?")
|
||||
elif r["status"] in ("up-to-date", "tracking"):
|
||||
ver = cur
|
||||
else:
|
||||
ver = cur
|
||||
detail = " (%s)" % r["detail"] if r["detail"] else ""
|
||||
lines.append(
|
||||
" %-*s %-22s %s%s"
|
||||
% (width, r["name"], ver, r["repo"] or "", c(detail, "\033[90m"))
|
||||
)
|
||||
|
||||
n_out = len(by_status.get("outdated", []))
|
||||
n_behind = len(by_status.get("behind", []))
|
||||
n_unknown = len(by_status.get("unknown", []))
|
||||
lines.append("")
|
||||
lines.append("-" * 60)
|
||||
summary = "%d outdated, %d behind, %d tracking, %d up-to-date, %d unknown" % (
|
||||
n_out,
|
||||
n_behind,
|
||||
len(by_status.get("tracking", [])),
|
||||
len(by_status.get("up-to-date", [])),
|
||||
n_unknown,
|
||||
)
|
||||
lines.append(c(summary, "\033[1m") if use_color else summary)
|
||||
return "\n".join(lines), n_out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Built-in unit tests (run with --self-test; no network required)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def run_self_tests():
|
||||
"""Exercise the offline version/wrap logic with inline unit tests.
|
||||
|
||||
Only the pure helpers are covered (parsing, comparison, classification,
|
||||
URL/version extraction); the ls-remote layer needs the network and is left
|
||||
out. Returns 0 if every check passes and 1 otherwise, so it can be wired
|
||||
into CI as ``check_outdated_subprojects.py --self-test``.
|
||||
"""
|
||||
import time
|
||||
|
||||
checks = []
|
||||
|
||||
def eq(label, got, want):
|
||||
checks.append(got == want)
|
||||
if got != want:
|
||||
print("FAIL %s" % label)
|
||||
print(" got: %r" % (got,))
|
||||
print(" want: %r" % (want,))
|
||||
|
||||
def ok(label, cond):
|
||||
checks.append(bool(cond))
|
||||
if not cond:
|
||||
print("FAIL %s" % label)
|
||||
|
||||
# -- parse_version: name prefixes, leading "v", pre-releases, non-versions --
|
||||
eq("parse xz-5.8.1", parse_version("xz-5.8.1"), ((5, 8, 1), True, ()))
|
||||
eq("parse v1.3.1", parse_version("v1.3.1"), ((1, 3, 1), True, ()))
|
||||
eq("parse zstd-1.5.7", parse_version("zstd-1.5.7"), ((1, 5, 7), True, ()))
|
||||
eq(
|
||||
"parse tree-sitter-0.25.3",
|
||||
parse_version("tree-sitter-0.25.3"),
|
||||
((0, 25, 3), True, ()),
|
||||
)
|
||||
eq("parse pcre2-10.47", parse_version("pcre2-10.47"), ((10, 47), True, ()))
|
||||
eq("parse v1.11", parse_version("v1.11"), ((1, 11), True, ()))
|
||||
eq(
|
||||
"parse capstone-6.0.0-Alpha9",
|
||||
parse_version("capstone-6.0.0-Alpha9"),
|
||||
((6, 0, 0), False, ((1, 0, "alpha"), (0, 9, ""))),
|
||||
)
|
||||
# A dash-separated numeric tail (libzip's rel-* tags) must keep its leading
|
||||
# number instead of collapsing to the last one -- regression guard.
|
||||
eq(
|
||||
"parse rel-0-11-18",
|
||||
parse_version("rel-0-11-18"),
|
||||
((0,), False, ((0, 11, ""), (1, 0, "-"), (0, 18, ""))),
|
||||
)
|
||||
ok("1.0.0 is final", parse_version("1.0.0")[1] is True)
|
||||
ok("1.0.0-rc1 is not final", parse_version("1.0.0-rc1")[1] is False)
|
||||
for nonver in ("main", "dev", "master", "", None, "zydis-amalgamated.tar.gz"):
|
||||
eq("parse %r -> None" % (nonver,), parse_version(nonver), None)
|
||||
|
||||
# -- version_display --
|
||||
eq("display xz-5.8.1", version_display("xz-5.8.1"), "5.8.1")
|
||||
eq("display v5.8.3", version_display("v5.8.3"), "5.8.3")
|
||||
eq(
|
||||
"display capstone-6.0.0-Alpha9",
|
||||
version_display("capstone-6.0.0-Alpha9"),
|
||||
"6.0.0-Alpha9",
|
||||
)
|
||||
eq("display passthrough", version_display("main"), "main")
|
||||
|
||||
# -- ordering: numeric (not lexical) and final outranks pre-release --
|
||||
def newer(a, b):
|
||||
return version_key(parse_version(a)) > version_key(parse_version(b))
|
||||
|
||||
ok("5.8.3 > 5.8.1", newer("5.8.3", "5.8.1"))
|
||||
ok("1.10.0 > 1.9.2 (numeric, not string)", newer("1.10.0", "1.9.2"))
|
||||
ok("1.0.1 > 1.0", newer("1.0.1", "1.0"))
|
||||
ok("6.0.0 > 6.0.0-Alpha9 (final beats pre-release)", newer("6.0.0", "6.0.0-Alpha9"))
|
||||
ok("Alpha9 > Alpha2", newer("6.0.0-Alpha9", "6.0.0-Alpha2"))
|
||||
|
||||
# -- best_tag --
|
||||
eq(
|
||||
"best_tag newest",
|
||||
best_tag(["v1.0.0", "v1.2.0", "v1.10.0", "main"], False)[0],
|
||||
"v1.10.0",
|
||||
)
|
||||
eq(
|
||||
"best_tag skips pre-release by default",
|
||||
best_tag(["1.0.0", "2.0.0-rc1"], False)[0],
|
||||
"1.0.0",
|
||||
)
|
||||
eq(
|
||||
"best_tag includes pre-release on request",
|
||||
best_tag(["1.0.0", "2.0.0-rc1"], True)[0],
|
||||
"2.0.0-rc1",
|
||||
)
|
||||
eq("best_tag no version-like tags", best_tag(["main", "dev"], True), (None, None))
|
||||
|
||||
# -- compare_to_tags: the OUTDATED / UP-TO-DATE / UNKNOWN decision --
|
||||
def status(cur, tags, inc=False):
|
||||
return compare_to_tags(cur, set(tags), inc)[0]
|
||||
|
||||
eq("compare outdated", status("5.8.1", ["v5.8.1", "v5.8.3"]), "outdated")
|
||||
eq(
|
||||
"compare reports newest tag",
|
||||
compare_to_tags("5.8.1", {"v5.8.1", "v5.8.3"}, False)[1],
|
||||
"v5.8.3",
|
||||
)
|
||||
eq(
|
||||
"compare up-to-date (numeric)",
|
||||
status("1.10.0", ["v1.9.2", "v1.10.0"]),
|
||||
"up-to-date",
|
||||
)
|
||||
eq("compare unparseable current -> unknown", status("main", ["v1.0.0"]), "unknown")
|
||||
eq("compare no upstream tags -> unknown", status("1.0.0", []), "unknown")
|
||||
eq(
|
||||
"compare stable pin ignores newer pre-release",
|
||||
status("1.0.0", ["1.0.0", "2.0.0-rc1"], False),
|
||||
"up-to-date",
|
||||
)
|
||||
eq(
|
||||
"compare --include-prerelease flags it",
|
||||
status("1.0.0", ["1.0.0", "2.0.0-rc1"], True),
|
||||
"outdated",
|
||||
)
|
||||
eq(
|
||||
"compare pre-release pin sees newer pre-release",
|
||||
status("6.0.0-Alpha2", ["6.0.0-Alpha2", "6.0.0-Alpha9"]),
|
||||
"outdated",
|
||||
)
|
||||
|
||||
# -- github_url: owner/repo extraction from assorted URL shapes --
|
||||
eq(
|
||||
"github_url release tarball",
|
||||
github_url(
|
||||
["https://github.com/tukaani-project/xz/releases/download/v5.8.1/xz.tar.gz"]
|
||||
)[1],
|
||||
"tukaani-project/xz",
|
||||
)
|
||||
eq(
|
||||
"github_url .git suffix",
|
||||
github_url(["https://github.com/zyantific/zydis.git"])[1],
|
||||
"zyantific/zydis",
|
||||
)
|
||||
eq(
|
||||
"github_url scp syntax",
|
||||
github_url(["git@github.com:foo/bar.git"])[1],
|
||||
"foo/bar",
|
||||
)
|
||||
eq("github_url skips empties", github_url(["", "https://github.com/a/b"])[1], "a/b")
|
||||
eq(
|
||||
"github_url non-github",
|
||||
github_url(["https://zlib.net/zlib.tar.gz"]),
|
||||
(None, None),
|
||||
)
|
||||
|
||||
# -- strip_archive_ext --
|
||||
eq("strip .tar.gz", strip_archive_ext("xz-5.8.1.tar.gz"), "xz-5.8.1")
|
||||
eq("strip .zip", strip_archive_ext("zydis-amalgamated.zip"), "zydis-amalgamated")
|
||||
eq("strip .tar.xz", strip_archive_ext("file.tar.xz"), "file")
|
||||
eq("strip none", strip_archive_ext("plain"), "plain")
|
||||
|
||||
# -- current_version_from_file (a plain dict stands in for a wrap section) --
|
||||
eq(
|
||||
"cvff directory",
|
||||
current_version_from_file({"directory": "xz-5.8.1"}),
|
||||
"xz-5.8.1",
|
||||
)
|
||||
eq(
|
||||
"cvff filename fallback",
|
||||
current_version_from_file(
|
||||
{"directory": "", "source_filename": "lz4-1.10.0.tgz"}
|
||||
),
|
||||
"lz4-1.10.0",
|
||||
)
|
||||
eq(
|
||||
"cvff url fallback (zydis amalgamated)",
|
||||
current_version_from_file(
|
||||
{
|
||||
"directory": "zydis",
|
||||
"source_filename": "zydis-amalgamated.tar.gz",
|
||||
"source_url": "https://github.com/zyantific/zydis/releases/download/v4.1.0/zydis-amalgamated.tar.gz",
|
||||
}
|
||||
),
|
||||
"v4.1.0",
|
||||
)
|
||||
|
||||
# -- preferred_branch --
|
||||
heads = {"master": "a", "next": "b", "main": "c"}
|
||||
eq(
|
||||
"branch segment match",
|
||||
preferred_branch("capstone-next", heads, "master"),
|
||||
"next",
|
||||
)
|
||||
eq("branch default fallback", preferred_branch("blake3", heads, "master"), "master")
|
||||
eq("branch none available", preferred_branch("foo", {"main": "c"}, "master"), None)
|
||||
|
||||
# -- ReDoS guard: adversarial input must still parse in linear time --
|
||||
evil = "a" + "_a" * 10000
|
||||
t0 = time.perf_counter()
|
||||
parsed_evil = parse_version(evil)
|
||||
dt = time.perf_counter() - t0
|
||||
eq("adversarial input -> None", parsed_evil, None)
|
||||
ok("adversarial input parsed in <1s (took %.4fs)" % dt, dt < 1.0)
|
||||
|
||||
total = len(checks)
|
||||
failed = sum(1 for c in checks if not c)
|
||||
print("")
|
||||
if failed:
|
||||
print("self-test: %d of %d checks FAILED" % (failed, total))
|
||||
return 1
|
||||
print("self-test: all %d checks passed" % total)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Check whether Rizin's Meson subproject wraps are outdated.",
|
||||
epilog="Exit code is 1 if any wrap is OUTDATED, else 0. "
|
||||
"BEHIND/TRACKING/UNKNOWN do not affect the exit code.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"path",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="rizin checkout or its subprojects/ dir (auto-detected if omitted)",
|
||||
)
|
||||
ap.add_argument("--json", action="store_true", help="emit JSON")
|
||||
ap.add_argument(
|
||||
"--include-prerelease",
|
||||
action="store_true",
|
||||
help="flag newer alpha/beta/rc releases even for stable pins",
|
||||
)
|
||||
ap.add_argument("--jobs", type=int, default=8, help="parallel workers (default 8)")
|
||||
ap.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=30,
|
||||
help="per-repo ls-remote timeout in seconds (default 30)",
|
||||
)
|
||||
ap.add_argument("--no-color", action="store_true", help="disable ANSI colors")
|
||||
ap.add_argument(
|
||||
"--self-test",
|
||||
action="store_true",
|
||||
help="run the built-in unit tests (no network) and exit",
|
||||
)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.self_test:
|
||||
return run_self_tests()
|
||||
|
||||
sub = find_subprojects_dir(args.path)
|
||||
if sub is None:
|
||||
sys.stderr.write(
|
||||
"error: could not find a subprojects/ directory with "
|
||||
"*.wrap files. Pass the rizin path explicitly.\n"
|
||||
)
|
||||
return 2
|
||||
wraps = sorted(os.path.join(sub, f) for f in os.listdir(sub) if f.endswith(".wrap"))
|
||||
if not wraps:
|
||||
sys.stderr.write("error: no *.wrap files in %s\n" % sub)
|
||||
return 2
|
||||
|
||||
if not args.json:
|
||||
sys.stderr.write("Checking %d wraps in %s ...\n" % (len(wraps), sub))
|
||||
|
||||
workers = max(1, min(args.jobs, len(wraps)))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
results = list(ex.map(lambda p: analyze(p, args), wraps))
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2))
|
||||
return 1 if any(r["status"] == "outdated" for r in results) else 0
|
||||
|
||||
use_color = (not args.no_color) and sys.stdout.isatty()
|
||||
report, n_out = render(results, use_color)
|
||||
print(report)
|
||||
return 1 if n_out else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue