diff --git a/emulators/nethersx2-turnip-classic.yml b/emulators/nethersx2-turnip-classic.yml index d220a779..940066ba 100644 --- a/emulators/nethersx2-turnip-classic.yml +++ b/emulators/nethersx2-turnip-classic.yml @@ -6,6 +6,7 @@ source: "https://github.com/nckstwrt/NetherSX2-Turnip" upstream: "https://github.com/Trixarian/AetherSX2" author: "nckstwrt" profiled_date: "2026-08-12" +pin_frozen: "the classic build is this revision. v0.7 ships a newer shim, and its rules are described in the notes; recaling would repoint these refs at code this build never had." source_commit: "8e17e584884e19b0a912f1988730d576d6d5aa84" upstream_commit: "1d1f795dfc70e38da67724c7298779260f513889" core_version: "2.2n-3668-Turnip-v0.7" diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index eef582be..9f6ba90d 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -598,6 +598,10 @@ "type": "string", "description": "Branch the port was read from, when it is not the default one." }, + "pin_frozen": { + "type": "string", + "description": "Why source_commit is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag: comparing it to HEAD says nothing and recaling would repoint its refs at code the build it documents never had." + }, "upstream_gone": { "type": "string", "description": "Why the declared upstream can no longer be reached, when that is settled rather than transient. Records the death instead of leaving the profile failing every pass; profile_sync reports the contradiction if the forge answers again." diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index d3daa2fd..be662886 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -1272,7 +1272,14 @@ def build_report( report.repos = [v.repo.slug for v in views] report.pin, report.pin_origin = primary.pin, primary.origin report.head = primary.head - report.pinned_tag = detect_pinned_tag(profile, views, cache_dir, offline) + # A profile can hold its pin on purpose, documenting a build that is that + # revision. Comparing it to HEAD says nothing and recaling would repoint + # its refs at code the build never had, so it is judged like a frozen tag: + # against itself. + frozen = str(profile.get("pin_frozen") or "").strip() + report.pinned_tag = frozen or detect_pinned_tag( + profile, views, cache_dir, offline + ) # A profile carrying no source_ref still has a pin worth writing and a # version worth checking, so the revisions above are resolved first. diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index d5e281f9..5dd870db 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -2160,6 +2160,43 @@ class TestWriteDryRun(unittest.TestCase): self.assertEqual(self.path.read_text(), before) +class TestDeliberatelyFrozenPin(TestBuildReport): + """A pin held on purpose is judged against itself, like a frozen tag. + + nethersx2-turnip-classic documents the shim rules of the revision it + pins and says so: recaling would repoint its refs at rules the classic + build never had. Without a way to say the freeze is deliberate, it + reports the same drift every pass and nobody can act on it. + """ + + def _frozen(self, reason="the classic build is this revision"): + profile = self._profile(["a.c:2"]) + profile["source_commit"] = "pinned" + profile["pin_frozen"] = reason + return profile + + def test_a_frozen_pin_is_checked_against_itself(self): + self.files[("pinned", "a.c")] = ["x", "the cited line"] + self.files[("headsha", "a.c")] = ["y"] * 40 + report = build_report("test", self._frozen(), self.dir) + self.assertTrue(report.pinned_tag) + self.assertEqual(report.entries[0].parts[0].status, "ANCHORED") + + def test_a_frozen_profile_is_never_recaled(self): + self.files[("pinned", "a.c")] = ["x", "the cited line"] + report = build_report("test", self._frozen(), self.dir) + path = Path(self.dir) / "p.yml" + path.write_text(SAMPLE, encoding="utf-8") + self.assertEqual(rebase_refs(path, report), []) + self.assertFalse(bump_commit(path, report)) + + def test_an_ordinary_profile_is_unaffected(self): + self.files[("pinsha", "a.c")] = ["x", "hit"] + self.files[("headsha", "a.c")] = ["pad", "x", "hit"] + report = build_report("test", self._profile(["a.c:2"]), self.dir) + self.assertFalse(report.pinned_tag) + + class TestProseSpacingIsNotADifference(unittest.TestCase): """A recale keeps the author's spacing; the check has to allow for it. diff --git a/wiki/profiling.md b/wiki/profiling.md index 3fb56ffc..2308e202 100644 --- a/wiki/profiling.md +++ b/wiki/profiling.md @@ -353,6 +353,7 @@ which CI validates every profile against. | `min_size`, `max_size` | size range when the code accepts a range | | `md5`, `sha1`, `crc32`, `sha256` | expected hashes from source code | | `known_hash_adler32` | expected Adler-32 hash (Dolphin's DSP ROMs); pair with `adler32_byteswap` when the code hashes 16-bit byte-swapped data | +| `pin_frozen` | why `source_commit` is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag | | `upstream_gone` | why the declared upstream can no longer be reached, when that is settled rather than transient. Records the death instead of leaving the profile failing every pass; profile_sync reports it as declared, and reports the contradiction if the forge answers again | | `source_mirror` | a repository carrying the same tree, consulted after `source` and `upstream`. What keeps a profile checkable once its own forge stops answering | | `validation` | checks the code performs: `size`, `crc32`, `md5`, `sha1`, `adler32`, `signature`, `crypto`. Can be a list or dict `{core: [...], upstream: [...]}` for divergent checks |