From 2b7c5f9a643090ba1c35597360a64ed5c17d25ca Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:55:35 +0200 Subject: [PATCH] feat: follow the branch a port was read from --- emulators/mkxp_z.yml | 3 +- schemas/emulator.schema.json | 573 ++++++++++++++++++++++++++++------- scripts/profile_sync.py | 7 +- scripts/upstream.py | 28 +- tests/test_profile_sync.py | 16 + 5 files changed, 503 insertions(+), 124 deletions(-) diff --git a/emulators/mkxp_z.yml b/emulators/mkxp_z.yml index f8f5ba86..57611e6d 100644 --- a/emulators/mkxp_z.yml +++ b/emulators/mkxp_z.yml @@ -2,7 +2,8 @@ emulator: mkxp-z type: libretro core_classification: game_engine source: "https://github.com/white-axe/mkxp-z" -source_ref: "branch: libretro (PR #255 to mkxp-z/mkxp-z)" +source_branch: libretro +source_ref: "PR #255 to mkxp-z/mkxp-z" upstream: "https://github.com/mkxp-z/mkxp-z" profiled_date: "2026-03-24" source_commit: "f4bc5aacee7875526ac7053cb61c0dc2357cac17" diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index 31dce9a0..e1e766ef 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -4,21 +4,42 @@ "description": "One YAML per emulator core or engine, documenting every external file the code loads. Field reference: https://abdess.github.io/retrobios/wiki/profiling/", "type": "object", "additionalProperties": false, - "required": ["emulator", "type"], + "required": [ + "emulator", + "type" + ], "allOf": [ { "comment": "An alias profile points at another profile and carries no files of its own.", "if": { - "properties": { "type": { "const": "alias" } }, - "required": ["type"] + "properties": { + "type": { + "const": "alias" + } + }, + "required": [ + "type" + ] }, - "then": { "required": ["alias_of"] }, - "else": { "required": ["files"] } + "then": { + "required": [ + "alias_of" + ] + }, + "else": { + "required": [ + "files" + ] + } } ], "properties": { - "emulator": { "type": "string" }, - "display_name": { "type": "string" }, + "emulator": { + "type": "string" + }, + "display_name": { + "type": "string" + }, "type": { "description": "Operational: resolve_platform_cores() filters on it. Not a classification.", "type": "string", @@ -49,135 +70,384 @@ "other" ] }, - "source": { "type": ["string", "object"] }, - "upstream": { "type": ["string", "object", "null"] }, - "source_commit": { "type": "string" }, - "upstream_commit": { "type": "string" }, + "source": { + "type": [ + "string", + "object" + ] + }, + "upstream": { + "type": [ + "string", + "object", + "null" + ] + }, + "source_commit": { + "type": "string" + }, + "upstream_commit": { + "type": "string" + }, "profiled_date": { "description": "Quote it: an unquoted YAML date parses to a date object.", - "type": ["string", "object"] + "type": [ + "string", + "object" + ] + }, + "core_version": { + "type": [ + "string", + "number", + "object", + "null" + ] + }, + "alias_of": { + "type": "string" }, - "core_version": { "type": ["string", "number", "object", "null"] }, - "alias_of": { "type": "string" }, "cores": { "description": "Upstream names the core is known by. A dict maps each name to per-variant metadata. Names are strings: an unquoted 81 or 2048 parses as a number and stops matching the buildbot name.", "oneOf": [ - { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, - { "type": "object", "propertyNames": { "minLength": 1 } } + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + { + "type": "object", + "propertyNames": { + "minLength": 1 + } + } ] }, - "systems": { "type": "array", "items": { "type": "string" } }, - "mode": { "type": "string", "enum": ["standalone", "libretro", "both"] }, - "verification": { "type": "string", "enum": ["existence", "md5", "sha1", "crc32"] }, - "exclusion_note": { "type": "string" }, - "analysis": { "type": "object" }, - "analysis_commit": { "type": "string" }, - "analysis_date": { "type": "string" }, - "archive_prefix": { "type": "string" }, - "author": { "type": "string" }, - "based_on": { "type": "string" }, - "bios_directory": { "type": "string" }, - "bios_identical_to": { "type": "string" }, - "bios_mode": { "type": "string" }, - "bios_size": { "type": "integer", "minimum": 0 }, - "builtin_boot_roms": { "type": "object" }, - "core": { "type": "string" }, - "core_name": { "type": "string" }, + "systems": { + "type": "array", + "items": { + "type": "string" + } + }, + "mode": { + "type": "string", + "enum": [ + "standalone", + "libretro", + "both" + ] + }, + "verification": { + "type": "string", + "enum": [ + "existence", + "md5", + "sha1", + "crc32" + ] + }, + "exclusion_note": { + "type": "string" + }, + "analysis": { + "type": "object" + }, + "analysis_commit": { + "type": "string" + }, + "analysis_date": { + "type": "string" + }, + "archive_prefix": { + "type": "string" + }, + "author": { + "type": "string" + }, + "based_on": { + "type": "string" + }, + "bios_directory": { + "type": "string" + }, + "bios_identical_to": { + "type": "string" + }, + "bios_mode": { + "type": "string" + }, + "bios_size": { + "type": "integer", + "minimum": 0 + }, + "builtin_boot_roms": { + "type": "object" + }, + "core": { + "type": "string" + }, + "core_name": { + "type": "string" + }, "data_directories": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["ref"], + "required": [ + "ref" + ], "properties": { - "ref": { "type": "string" }, - "destination": { "type": "string" }, - "source_ref": { "type": "string" } + "ref": { + "type": "string" + }, + "destination": { + "type": "string" + }, + "source_ref": { + "type": "string" + } } } }, - "dev_flash": { "type": "object" }, - "dev_flash2": { "type": "object" }, - "dev_flash3": { "type": "object" }, - "firmware_titles": { "type": "array", "items": { "type": "object" } }, - "game_count": { "type": "integer", "minimum": 0 }, - "logo": { "type": "string" }, - "machine_directories": { "type": "object" }, - "machine_properties": { "type": "object" }, - "mame_version": { "type": "string" }, - "memory_layout": { "type": "object" }, - "mlc_structure": { "type": "array", "items": { "type": "string" } }, - "model_kickstart_map": { "type": "object" }, - "note": { "type": "string" }, - "notes": { "type": ["string", "array", "object"] }, - "pack_structure": { "type": "object" }, - "platform_details": { "type": "object" }, - "resources_directory": { "type": "string" }, - "rom_path": { "type": "string" }, - "source_ref": { "type": "string" }, - "valid_bios_crc32": { "type": "object" }, - "whdload_kickstarts": { "type": "array", "items": { "type": "string" } }, + "dev_flash": { + "type": "object" + }, + "dev_flash2": { + "type": "object" + }, + "dev_flash3": { + "type": "object" + }, + "firmware_titles": { + "type": "array", + "items": { + "type": "object" + } + }, + "game_count": { + "type": "integer", + "minimum": 0 + }, + "logo": { + "type": "string" + }, + "machine_directories": { + "type": "object" + }, + "machine_properties": { + "type": "object" + }, + "mame_version": { + "type": "string" + }, + "memory_layout": { + "type": "object" + }, + "mlc_structure": { + "type": "array", + "items": { + "type": "string" + } + }, + "model_kickstart_map": { + "type": "object" + }, + "note": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "array", + "object" + ] + }, + "pack_structure": { + "type": "object" + }, + "platform_details": { + "type": "object" + }, + "resources_directory": { + "type": "string" + }, + "rom_path": { + "type": "string" + }, + "source_ref": { + "type": "string" + }, + "valid_bios_crc32": { + "type": "object" + }, + "whdload_kickstarts": { + "type": "array", + "items": { + "type": "string" + } + }, "files": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["name"], + "required": [ + "name" + ], "properties": { - "name": { "type": "string" }, - "required": { "type": "boolean" }, - "system": { "type": "string" }, - "path": { "type": ["string", "null"] }, - "destination": { "type": "string" }, - "aliases": { "type": "array", "items": { "type": "string" } }, + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "system": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "destination": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, "archive": { "description": "ROM set the file lives in, for arcade entries.", "type": "string" }, "category": { "type": "string", - "enum": ["bios", "game_data", "bios_zip"] + "enum": [ + "bios", + "game_data", + "bios_zip" + ] + }, + "mode": { + "type": "string", + "enum": [ + "standalone", + "libretro", + "both" + ] }, - "mode": { "type": "string", "enum": ["standalone", "libretro", "both"] }, "storage": { "type": "string", - "enum": ["embedded", "external", "user_provided", "large_file", "release"] + "enum": [ + "embedded", + "external", + "user_provided", + "large_file", + "release" + ] + }, + "hle_fallback": { + "type": "boolean" + }, + "adler32_byteswap": { + "type": "boolean" + }, + "agnostic": { + "type": "boolean" + }, + "bundled": { + "type": "boolean" + }, + "config_key": { + "type": "string" + }, + "embedded": { + "type": "boolean" + }, + "fast_boot": { + "type": "string" + }, + "has_builtin": { + "type": "boolean" + }, + "load_from": { + "type": "string" + }, + "priority": { + "type": "integer" + }, + "region_check": { + "type": "boolean" + }, + "size_note": { + "type": "string" + }, + "size_options": { + "type": "array", + "items": { + "type": "integer" + } + }, + "standalone_path": { + "type": "string" + }, + "type": { + "type": "string" + }, + "variant_group": { + "type": "string", + "minLength": 1 + }, + "size": { + "type": [ + "integer", + "array", + "null" + ] + }, + "min_size": { + "type": "integer" + }, + "max_size": { + "type": "integer" }, - "hle_fallback": { "type": "boolean" }, - "adler32_byteswap": { "type": "boolean" }, - "agnostic": { "type": "boolean" }, - "bundled": { "type": "boolean" }, - "config_key": { "type": "string" }, - "embedded": { "type": "boolean" }, - "fast_boot": { "type": "string" }, - "has_builtin": { "type": "boolean" }, - "load_from": { "type": "string" }, - "priority": { "type": "integer" }, - "region_check": { "type": "boolean" }, - "size_note": { "type": "string" }, - "size_options": { "type": "array", "items": { "type": "integer" } }, - "standalone_path": { "type": "string" }, - "type": { "type": "string" }, - "variant_group": { "type": "string", "minLength": 1 }, - "size": { "type": ["integer", "array", "null"] }, - "min_size": { "type": "integer" }, - "max_size": { "type": "integer" }, "md5": { "oneOf": [ - { "type": "null" }, - { "type": "string", "pattern": "^[a-fA-F0-9]{32}$" }, + { + "type": "null" + }, + { + "type": "string", + "pattern": "^[a-fA-F0-9]{32}$" + }, { "type": "array", - "items": { "type": "string", "pattern": "^[a-fA-F0-9]{32}$" } + "items": { + "type": "string", + "pattern": "^[a-fA-F0-9]{32}$" + } } ] }, "sha1": { "oneOf": [ - { "type": "string", "pattern": "^[a-fA-F0-9]{40}$" }, + { + "type": "string", + "pattern": "^[a-fA-F0-9]{40}$" + }, { "type": "array", - "items": { "type": "string", "pattern": "^[a-fA-F0-9]{40}$" } + "items": { + "type": "string", + "pattern": "^[a-fA-F0-9]{40}$" + } } ] }, @@ -185,13 +455,28 @@ "type": "string", "pattern": "^[a-fA-F0-9]{64}(,[a-fA-F0-9]{64})*$" }, - "crc32": { "type": "string", "pattern": "^[a-fA-F0-9]{8}$" }, - "known_hash_adler32": { "type": ["string", "integer"] }, + "crc32": { + "type": "string", + "pattern": "^[a-fA-F0-9]{8}$" + }, + "known_hash_adler32": { + "type": [ + "string", + "integer" + ] + }, "validation": { "description": "Checks the code performs. A dict splits core from upstream when they diverge.", "oneOf": [ - { "type": "array", "items": { "type": "string" } }, - { "type": "object" } + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object" + } ] }, "region": { @@ -200,14 +485,40 @@ "items": { "enum": [ "world", - "north-america", "canada", - "latin-america", "brazil", "mexico", "argentina", - "europe", "uk", "france", "germany", "italy", "spain", - "netherlands", "portugal", "greece", "poland", "russia", - "sweden", "norway", "denmark", "finland", - "asia", "japan", "south-korea", "china", "taiwan", - "hong-kong", "singapore", "india", "asia-ntsc", "asia-pal", - "oceania", "australia", "new-zealand" + "north-america", + "canada", + "latin-america", + "brazil", + "mexico", + "argentina", + "europe", + "uk", + "france", + "germany", + "italy", + "spain", + "netherlands", + "portugal", + "greece", + "poland", + "russia", + "sweden", + "norway", + "denmark", + "finland", + "asia", + "japan", + "south-korea", + "china", + "taiwan", + "hong-kong", + "singapore", + "india", + "asia-ntsc", + "asia-pal", + "oceania", + "australia", + "new-zealand" ] } }, @@ -216,26 +527,58 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["name"], + "required": [ + "name" + ], "properties": { - "name": { "type": "string", "minLength": 1 }, - "size": { "type": "integer", "minimum": 0 }, - "sha1": { "type": "string", "pattern": "^[a-fA-F0-9]{40}$" }, - "crc32": { "type": "string", "pattern": "^[a-fA-F0-9]{8}$" }, - "description": { "type": "string" }, - "bad_dump": { "type": "boolean" } + "name": { + "type": "string", + "minLength": 1 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "sha1": { + "type": "string", + "pattern": "^[a-fA-F0-9]{40}$" + }, + "crc32": { + "type": "string", + "pattern": "^[a-fA-F0-9]{8}$" + }, + "description": { + "type": "string" + }, + "bad_dump": { + "type": "boolean" + } } } }, "source_ref": { "description": "A dict splits core from upstream when the two diverge.", - "type": ["string", "array", "object"] + "type": [ + "string", + "array", + "object" + ] }, - "description": { "type": "string" }, - "note": { "type": "string" }, - "unsourceable": { "type": "string" } + "description": { + "type": "string" + }, + "note": { + "type": "string" + }, + "unsourceable": { + "type": "string" + } } } + }, + "source_branch": { + "type": "string", + "description": "Branch the port was read from, when it is not the default one." } } } diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 63952b6f..f6523d89 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -687,6 +687,9 @@ def select_views( """ views: list[RepoView] = [] seen: set[tuple[str, str]] = set() + # A port living on a branch of a fork is absent from the default tip, so + # the branch it was read from is followed when the profile names one. + branch = str(profile.get("source_branch") or "") or None for field in ("source", "upstream"): repo = upstream.parse_repo(str(profile.get(field) or "")) if repo is None or (repo.host, repo.slug) in seen: @@ -694,7 +697,9 @@ def select_views( pin, origin = resolve_pin(profile, repo, cache_dir, offline, field) if not pin: continue - head = upstream.resolve_head(repo, cache_dir, offline) + head = upstream.resolve_head( + repo, cache_dir, offline, branch if field == "source" else None + ) if not head: continue seen.add((repo.host, repo.slug)) diff --git a/scripts/upstream.py b/scripts/upstream.py index 6c49ba26..c60a1333 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -262,16 +262,22 @@ def _project(repo: Repo) -> str: return urllib.parse.quote(f"{repo.owner}/{repo.name}", safe="") -def _commits_url(repo: Repo, date: str | None) -> str: +def _commits_url(repo: Repo, date: str | None, branch: str | None = None) -> str: if repo.family == "github": base = f"{repo.api_base}/repos/{repo.slug}/commits?per_page=1" + if branch: + base += f"&sha={urllib.parse.quote(branch, safe='')}" elif repo.family == "gitlab": base = ( f"{repo.api_base}/projects/{_project(repo)}" f"/repository/commits?per_page=1" ) + if branch: + base += f"&ref_name={urllib.parse.quote(branch, safe='')}" else: base = f"{repo.api_base}/repos/{repo.slug}/commits?limit=1" + if branch: + base += f"&sha={urllib.parse.quote(branch, safe='')}" return f"{base}&until={date}T23:59:59Z" if date else base @@ -283,16 +289,24 @@ def _first_sha(payload: object) -> str | None: return None -def resolve_head(repo: Repo, cache_dir: str, offline: bool = False) -> str | None: - """Sha of the default branch tip.""" - return _first_sha(_api(_commits_url(repo, None), cache_dir, offline)) +def resolve_head( + repo: Repo, cache_dir: str, offline: bool = False, branch: str | None = None +) -> str | None: + """Sha of a branch tip, the default branch unless one is named. + + A libretro port often lives on a branch of a fork rather than on the + default branch, so the code a profile documents can be absent from the + tip the forge serves by default. + """ + return _first_sha(_api(_commits_url(repo, None, branch), cache_dir, offline)) def resolve_commit_at( - repo: Repo, date: str, cache_dir: str, offline: bool = False + repo: Repo, date: str, cache_dir: str, offline: bool = False, + branch: str | None = None, ) -> str | None: - """Last default-branch commit on or before a date.""" - return _first_sha(_api(_commits_url(repo, date), cache_dir, offline)) + """Last commit on or before a date, on the default branch or a named one.""" + return _first_sha(_api(_commits_url(repo, date, branch), cache_dir, offline)) def _tags_url(repo: Repo) -> str: diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index 709c2800..9ebafef1 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -1108,6 +1108,22 @@ class TestBuildReport(unittest.TestCase): report = build_report("mame", profile, self.dir) self.assertEqual(report.entries[0].status, "ANCHORED") + def test_a_declared_branch_is_followed_for_head(self): + seen = {} + + def _head(repo, cache_dir, offline=False, branch=None): + seen[repo.slug] = branch + return "headsha" + + profile_sync.upstream.resolve_head = _head + profile = self._profile(["a.c:1"]) + profile["upstream"] = "https://github.com/o/up" + profile["source_branch"] = "libretro" + build_report("test", profile, self.dir) + # Only the port carries the branch; the upstream keeps its own tip. + self.assertEqual(seen["o/n"], "libretro") + self.assertIsNone(seen["o/up"]) + def test_pin_on_the_declared_version_tag_is_flagged(self): self.files[("pinsha", "a.c")] = ["x", "hit"] self.files[("headsha", "a.c")] = ["x", "hit"]