diff --git a/emulators/citron.yml b/emulators/citron.yml index 31a766fd..98dc5e68 100644 --- a/emulators/citron.yml +++ b/emulators/citron.yml @@ -4,6 +4,7 @@ core_classification: community_fork source: "https://git.citron-emu.org/citron-emu/Citron" upstream: "https://git.citron-emu.org/citron-emu/Citron" profiled_date: "2026-03-26" +upstream_gone: "git.citron-emu.org no longer resolves, and git.citron-emu.com redirects to an unrelated survey site, the domain having been taken. Observed 2026-09-04; no mirror found." core_version: "2026.03.12" display_name: "Citron (Nintendo Switch)" cores: diff --git a/emulators/suyu.yml b/emulators/suyu.yml index 755dba98..aec61328 100644 --- a/emulators/suyu.yml +++ b/emulators/suyu.yml @@ -4,6 +4,7 @@ core_classification: community_fork source: "https://github.com/suyu-emu/suyu" upstream: "https://github.com/suyu-emu/suyu" profiled_date: "2026-03-26" +upstream_gone: "GitHub withdrew the repository for legal reasons: the API answers HTTP 451. git.suyu.dev still serves a Forgejo page, but its API answers 403 and its raw paths 404, so it carries no readable tree. Observed 2026-09-04." core_version: "Yuzu EA 4176 based" display_name: "Suyu (Nintendo Switch)" cores: diff --git a/emulators/yuzu.yml b/emulators/yuzu.yml index d73823e3..0d2a28ff 100644 --- a/emulators/yuzu.yml +++ b/emulators/yuzu.yml @@ -4,6 +4,7 @@ core_classification: official_port source: "https://github.com/yuzu-emu-mirror/yuzu" upstream: "https://github.com/yuzu-emu/yuzu" profiled_date: "2026-03-26" +upstream_gone: "GitHub withdrew the repository for legal reasons: the API answers HTTP 451 for both yuzu-emu/yuzu and the yuzu-emu-mirror copy. Observed 2026-09-04; no mirror found that serves file content." core_version: "EA 4176" display_name: "Yuzu (Nintendo Switch)" cores: diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index ea965900..eef582be 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." }, + "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." + }, "source_mirror": { "type": "string", "description": "Repository carrying the same tree, consulted after source and upstream. The only thing that keeps a profile checkable once its own forge is withdrawn or its host stops resolving." diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 23963cc6..77fc23cc 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -1218,7 +1218,25 @@ def build_report( report.skipped = f"unsupported host: {declared or 'none declared'}" return report - views = select_views(profile, cache_dir, offline) + stated_gone = str(profile.get("upstream_gone") or "").strip() + + try: + views = select_views(profile, cache_dir, offline) + except upstream.GoneError as exc: + if stated_gone: + # The profile says the forge is gone and the forge agrees. That is + # a recorded fact, not an open problem: the refs describe the last + # revision anyone can reach, and nothing further is possible. + report.skipped = f"upstream gone (declared): {stated_gone}" + return report + raise + if stated_gone and views: + # Declared dead, yet it answered. A profile that keeps saying so stops + # being read, so the contradiction is the finding. + report.skipped = ( + f"upstream_gone is declared but the forge answers: {stated_gone}" + ) + return report if not views: report.skipped = ( "no source_commit and no resolvable profiled_date " diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index ab46d728..603407d5 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -1435,6 +1435,52 @@ def _sample_report(): ) +class TestDeclaredDeadUpstream(TestBuildReport): + """A forge that is gone can be stated, which closes the question. + + yuzu and suyu were withdrawn, citron's domain was taken by a squatter. + Leaving those profiles to fail every pass reports an open problem where + there is a settled fact. Declaring it records the death; the guard is + that a declaration must stop being true the moment the forge answers. + """ + + def _profile_with(self, note): + profile = self._profile(["a.c:1"]) + profile["upstream_gone"] = note + return profile + + def test_a_declared_death_is_reported_as_declared(self): + def boom(*a, **k): + raise profile_sync.upstream.GoneError("https://h/x: HTTP 451") + + profile_sync.upstream.resolve_head = boom + report = build_report( + "test", self._profile_with("withdrawn, HTTP 451"), self.dir + ) + self.assertIsNotNone(report.skipped) + self.assertIn("declared", report.skipped) + self.assertIn("withdrawn, HTTP 451", report.skipped) + self.assertEqual(report.needs_review(), 0) + + def test_a_declaration_the_forge_contradicts_is_reported(self): + """The forge answered: the declaration has outlived its truth.""" + self.files[("pinsha", "a.c")] = ["hit"] + self.files[("headsha", "a.c")] = ["hit"] + report = build_report( + "test", self._profile_with("withdrawn, HTTP 451"), self.dir + ) + self.assertIsNotNone(report.skipped) + self.assertIn("answers", report.skipped) + + def test_an_undeclared_death_is_unchanged(self): + def boom(*a, **k): + raise profile_sync.upstream.GoneError("https://h/x: HTTP 451") + + profile_sync.upstream.resolve_head = boom + with self.assertRaises(profile_sync.upstream.GoneError): + build_report("test", self._profile(["a.c:1"]), self.dir) + + class TestRefsAheadOfTheirPin(TestBuildReport): """Say when a ref describes HEAD and the pin still names an older tree. diff --git a/wiki/profiling.md b/wiki/profiling.md index 045770cb..3fb56ffc 100644 --- a/wiki/profiling.md +++ b/wiki/profiling.md @@ -353,6 +353,8 @@ 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 | +| `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 | | `aliases` | alternate filenames for the same file | | `mode` | `libretro`, `standalone`, or `both` | diff --git a/wiki/tools.md b/wiki/tools.md index 6b093319..10c61833 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -304,6 +304,12 @@ where nothing could be done about it. A 403 is not that: small Forgejo instances behind anti-bot filters answer 403 to a script and 200 to a browser, so it stays a refusal. +When no mirror serves it either, `upstream_gone` states why, and the profile +stops being an open problem: the refs describe the last revision anyone could +reach and nothing further is possible. The declaration is guarded rather than +trusted, since a forge can come back: a profile that declares it while the +forge answers is reported as the contradiction it is. + `source_mirror` names a repository carrying the same tree, consulted after `source` and `upstream` so a live primary always decides attribution. It is what keeps a profile checkable once its own forge stops answering: eden