From 4b11a43a863659d07434d278b2845c4e0ac178a1 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:16:31 +0100 Subject: [PATCH] feat: version check reads tags and numbers A project that tags without releases (eightyone, higan) was never compared, and a core_version naming no tag (parallel-launcher 9.0.5 beside v9.0-6) silenced the passed-release warning. The newest tag stands in when it is a version number, and an untagged core_version is compared by number within one scheme. --emulator is repeatable: a stored value kept only the last. --- scripts/profile_sync.py | 97 +++++++++++++++++++++++++++++++------- tests/test_profile_sync.py | 73 +++++++++++++++++++++++++++- wiki/tools.md | 6 ++- 3 files changed, 156 insertions(+), 20 deletions(-) diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 86dc1a8cf..18a80cec2 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -2335,15 +2335,17 @@ def version_warning( ``passed_release`` is a later release the pin already contains, which core_version should name instead. """ - if report is None or not report.tag_commit or not pin: - return None - if report.tag_commit == pin and not passed_release: + if report is None or not pin: return None + # Before the tag guard: parallel-launcher declared 9.0.5, a name no tag + # carries (v9.0-6), and the release its HEAD contains went unsaid. if passed_release: return ( f"core_version {report.declared} is older than the code: the pin " f"{pin[:7]} already contains release {passed_release}" ) + if not report.tag_commit or report.tag_commit == pin: + return None where = "" if relation is not None: ahead, behind = relation @@ -3892,7 +3894,8 @@ def build_parser() -> argparse.ArgumentParser: description="Confront emulator profiles with their upstream source" ) group = parser.add_mutually_exclusive_group(required=True) - group.add_argument("--emulator", help="a single profile") + # Repeatable: a stored value kept only the last of several --emulator. + group.add_argument("--emulator", action="append", help="a profile, repeatable") group.add_argument("--all", action="store_true", help="every profile") group.add_argument( "--changed", action="store_true", @@ -4073,7 +4076,9 @@ def select_profiles(profiles: dict, args: argparse.Namespace) -> dict: print(f"the branch {verb} no profile against {args.base}", file=sys.stderr) return selected if args.emulator: - if args.emulator not in profiles: + names = [args.emulator] if isinstance(args.emulator, str) else args.emulator + unknown = [name for name in names if name not in profiles] + if unknown: # Suggested from what the listing it names shows: counted over the # alias and test profiles too, "lists all 553" pointed at 530 lines. listed = sorted( @@ -4081,10 +4086,10 @@ def select_profiles(profiles: dict, args: argparse.Namespace) -> dict: if profile.get("type") not in ("alias", "test") ) raise SystemExit( - f"ERROR: unknown profile {args.emulator}. " - + suggest_names([args.emulator], listed, "verify.py --list-emulators") + f"ERROR: unknown profile {', '.join(unknown)}. " + + suggest_names(unknown, listed, "verify.py --list-emulators") ) - return {args.emulator: profiles[args.emulator]} + return {name: profiles[name] for name in dict.fromkeys(names)} # --system matches as verify.py --list-systems lists and --system selects: # by normalized id, so `saturn` reaches the profiles declaring sega-saturn. @@ -4443,12 +4448,61 @@ def _attach_version( print(f"{report.name}: tag ancestry unread: {exc}", file=sys.stderr) newer = None report.version_warning = version_warning(version, report.pin, relation, passed) or ( - f"release {newer} is newer than core_version {version.declared} and the pin: " + f"{'release' if version.latest_release else 'tag'} {newer} is newer than core_version {version.declared} and the pin: " "profile it, or hold the pin with pin_frozen and the reason" if newer else None ) +# A tag that reads as a version number: 1.1, v107, v9.0-6, 2.55, 1.1d. +# Nightly, continuous and latest tags do not. +_VERSION_TAG_RE = re.compile(r"v?\d+(?:[._-]\d+)*[a-z]?", re.IGNORECASE) + + +def published_version(version: VersionReport | None) -> str | None: + """The latest version the project published: its latest release, or for + a project that publishes no release, its newest tag when that tag reads + as a version number. + + GitLab and Codeberg projects often tag without a release: eightyone sat + on v1.2 and higan on 106 while v1.3 and v107 were tagged, and nothing + compared them. + """ + if version is None: + return None + if version.latest_release: + return version.latest_release + tag = version.latest_tag + if tag and _VERSION_TAG_RE.fullmatch(tag): + return tag + return None + + +def _later_number(candidate: str, declared: str) -> bool: + """Whether both read as numbers of one scheme and the candidate's is higher. + + One scheme means the same major number: libretro forks tag RetroArch's + numbers (mame2015 0.160 beside v1.4.1) and some projects tag dates + (np2pi 0.83 beside 20120302). Trailing zeros do not count: v1 is v1.0. + """ + if not (_VERSION_TAG_RE.fullmatch(candidate) and _VERSION_TAG_RE.fullmatch(declared)): + return False + + def key(text: str) -> tuple[int, ...]: + numbers = [int(n) for n in re.findall(r"\d+", text)] + while len(numbers) > 1 and numbers[-1] == 0: + numbers.pop() + return tuple(numbers) + + high, low = key(candidate), key(declared) + if (len(high) == 1) != (len(low) == 1): + return False + if len(high) == 1: + # higan 106 and v107 are one scheme, openlara v1 and 20170424 two. + return len(str(high[0])) == len(str(low[0])) and high > low + return high[0] == low[0] and high > low + + def _newer_release( args: argparse.Namespace, repo: upstream.Repo, @@ -4461,11 +4515,12 @@ def _newer_release( 0.1.3, armsx2 on 2.8 and x1-box on 1.2.6 while 0.3.6, 2.9 and 1.3.2 were out, and nothing said so. A held pin (pin_frozen) is left alone. """ - if not version or not pin or not version.tag_commit or not version.latest_release: + published = published_version(version) + if not version or not pin or not version.tag_commit or not published: return None - if version.latest_release == version.declared: + if published == version.declared: return None - commit = upstream.tag_commit(repo, version.latest_release, args.cache_dir, args.offline) + commit = upstream.tag_commit(repo, published, args.cache_dir, args.offline) if not commit or commit == pin: return None after_tag = upstream.ancestry(repo, version.tag_commit, commit, args.cache_dir, args.offline) @@ -4474,7 +4529,7 @@ def _newer_release( after_pin = upstream.ancestry(repo, pin, commit, args.cache_dir, args.offline) if not after_pin or after_pin[0] == 0: return None - return version.latest_release + return published def _passed_release( @@ -4490,11 +4545,17 @@ def _passed_release( sort first on many forges. The latest release is a version the project published as such. """ - if not version or not pin or not version.latest_release: + published = published_version(version) + if not version or not pin or not published: return None - if version.latest_release == version.declared: + if published == version.declared: return None - commit = upstream.tag_commit(repo, version.latest_release, args.cache_dir, args.offline) + if not version.tag_commit and not _later_number(published, version.declared): + # No tag to order them: only a release numbered past the declared + # version says it is older. "latest", "continuous" or 1.53 under a + # declared 1.61 say nothing. + return None + commit = upstream.tag_commit(repo, published, args.cache_dir, args.offline) if not commit: return None if version.tag_commit and version.tag_commit != commit: @@ -4504,10 +4565,10 @@ def _passed_release( if older and older[1] == 0: return None if commit == pin: - return version.latest_release + return published relation = upstream.ancestry(repo, commit, pin, args.cache_dir, args.offline) if relation and relation[1] == 0: - return version.latest_release + return published return None diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index c1b9892c6..71fb1c741 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -2209,6 +2209,9 @@ def _args(**kwargs): flag = "--" + key.replace("_", "-") if value is True: argv.append(flag) + elif isinstance(value, list): + for item in value: + argv.extend([flag, str(item)]) else: argv.extend([flag, str(value)]) return build_parser().parse_args(argv) @@ -2220,6 +2223,13 @@ class TestSelectProfiles(unittest.TestCase): list(select_profiles(PROFILES, _args(emulator="alpha"))), ["alpha"] ) + def test_emulator_repeats(self): + # A stored --emulator kept only the last of several. + self.assertEqual( + list(select_profiles(PROFILES, _args(emulator=["alpha", "beta", "alpha"]))), + ["alpha", "beta"], + ) + def test_all_excludes_aliases(self): self.assertEqual( sorted(select_profiles(PROFILES, _args(all=True))), ["alpha", "beta"] @@ -2886,7 +2896,9 @@ class TestDeliberatelyFrozenPin(TestBuildReport): # A frozen revision whose tree lacks a build the live one documents # keeps its own type: DuckStation 56ac3d6c has no libretro frontend # under src/, only duckstation-nogui and duckstation-qt. - builds_differ = {("duckstation-legacy", "duckstation")} + # RetroPie builds ppsspp-1.5.4 as PPSSPPSDL alone: the frozen build + # is the standalone half of a project that also ships a core. + builds_differ = {("duckstation-legacy", "duckstation"), ("ppsspp-1.5.4", "ppsspp")} from common import load_emulator_profiles # noqa: PLC0415 root = Path(__file__).resolve().parents[1] / "emulators" @@ -3367,6 +3379,65 @@ class ANewerReleaseThanTheTag(unittest.TestCase): self.assertIsNone(self._newer((3, 4), (3, 4))) +class AProjectThatOnlyTags(unittest.TestCase): + """eightyone sat on v1.2 and higan on 106 while v1.3 and v107 were tagged: + with no release published, nothing compared them.""" + + def test_a_version_tag_stands_in_for_the_missing_release(self): + version = profile_sync.VersionReport("v1.2", "v1.3", None, None, True, "tag12") + self.assertEqual(profile_sync.published_version(version), "v1.3") + for tag in ("v107", "1.1d", "v9.0-6", "2.55"): + version = profile_sync.VersionReport("x", tag, None, None, False, None) + self.assertEqual(profile_sync.published_version(version), tag) + + def test_a_nightly_tag_does_not(self): + for tag in ("latest-nightly", "continuous", "nightly-20261001"): + version = profile_sync.VersionReport("v1.2", tag, None, None, True, "t") + self.assertIsNone(profile_sync.published_version(version)) + + def test_a_release_wins_over_a_newer_tag(self): + version = profile_sync.VersionReport("1.0", "1.2", "1.1", "2026-01-01", True, "t") + self.assertEqual(profile_sync.published_version(version), "1.1") + + def test_a_newer_tag_is_named(self): + from unittest import mock # noqa: PLC0415 + + version = profile_sync.VersionReport("v1.2", "v1.3", None, None, True, "tag12") + args = argparse.Namespace(cache_dir="c", offline=True) + with mock.patch.object(profile_sync.upstream, "tag_commit", return_value="tag13"), \ + mock.patch.object(profile_sync.upstream, "ancestry", return_value=(7, 0)): + self.assertEqual(profile_sync._newer_release(args, "repo", version, "tag12"), "v1.3") + + +class APassedReleaseUnderAnUntaggedVersion(unittest.TestCase): + """parallel-launcher declared 9.0.5, a name no tag carries, and its HEAD + already held release v9.0-6: the missing tag silenced the warning.""" + + def test_the_passed_release_is_named_without_a_declared_tag(self): + version = profile_sync.VersionReport("9.0.5", "v9.0-6", "v9.0-6", "2026-08-08", False, None) + warning = version_warning(version, "head", None, "v9.0-6") + self.assertIn("already contains release v9.0-6", warning) + + def test_only_a_higher_number_counts_without_a_tag(self): + from unittest import mock # noqa: PLC0415 + + args = argparse.Namespace(cache_dir="c", offline=True) + cases = {("9.0.5", "v9.0-6"): "v9.0-6", ("1.61", "1.53"): None, + ("v0.1-12070", "latest"): None, ("Git", "v2.7"): None, ("5.2", "v5.2"): None, + ("v1", "v1.0"): None, ("0.83", "20120302"): None, ("0.160", "v1.4.1"): None, + ("0.0.35", "v0.0.43"): "v0.0.43", ("106", "v107"): "v107", ("v1", "20170424"): None} + for (declared, release), expected in cases.items(): + version = profile_sync.VersionReport(declared, release, release, "d", False, None) + with self.subTest(declared=declared, release=release), \ + mock.patch.object(profile_sync.upstream, "tag_commit", return_value="rel"), \ + mock.patch.object(profile_sync.upstream, "ancestry", return_value=(3, 0)): + self.assertEqual(profile_sync._passed_release(args, "repo", version, "pin"), expected) + + def test_no_tag_and_nothing_passed_stays_quiet(self): + version = profile_sync.VersionReport("9.0.5", None, None, None, False, None) + self.assertIsNone(version_warning(version, "head")) + + class TestAncestry(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() diff --git a/wiki/tools.md b/wiki/tools.md index 03e137563..e3e35f76a 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -573,7 +573,11 @@ only after the path as written has failed everywhere, and the result is reported as `RENAMED` so `--rebase-refs` cleans the profile. `--check-version` compares `core_version` with the latest upstream tag and -release. `--detect-new-files` lists filename literals at HEAD the profile +release. A project that publishes no release is read by its newest tag when +that tag is a version number. It warns when the pin already contains a later +release than `core_version` names, or when a later one is out and the pin is +not held. A `core_version` that names no tag is compared by number, within +one numbering scheme. `--detect-new-files` lists filename literals at HEAD the profile does not declare. `--watch-hashes` lists hash literals added upstream that match no entry. Both land in the `--json` report (`new_files`, with `since_pin` true for a name the pinned file did not carry, and