From ece637d52d511a7df24f4da80c6c59e94dd9ccf3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:25:23 +0200 Subject: [PATCH] feat: let a profile state that its upstream is gone Saying those profiles would stay open no matter what was wrong. A dead forge is a verifiable fact, and a fact can be recorded: upstream_gone carries why, and the profile stops being an open problem. The refs describe the last revision anyone could reach, which is all any reader can ask of them. The declaration is guarded rather than trusted, because a forge can come back and a profile that keeps asserting a death nobody rechecks is worse than one that fails loudly. Declared and unreachable reports as a recorded fact; declared and answering reports as the contradiction it is, and the profile has to be read again. yuzu and suyu carry GitHub's 451. citron carries the loss of git.citron-emu.org and the squatter now sitting on the .com. Each names what was probed and when, so the next reader retraces it rather than repeating it. --- emulators/citron.yml | 1 + emulators/suyu.yml | 1 + emulators/yuzu.yml | 1 + schemas/emulator.schema.json | 4 ++++ scripts/profile_sync.py | 20 +++++++++++++++- tests/test_profile_sync.py | 46 ++++++++++++++++++++++++++++++++++++ wiki/profiling.md | 2 ++ wiki/tools.md | 6 +++++ 8 files changed, 80 insertions(+), 1 deletion(-) 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