diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index e6b9f7c7..222c172d 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -809,6 +809,16 @@ "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." }, + "version_source": { + "type": "object", + "additionalProperties": false, + "required": ["url", "pattern"], + "properties": { + "url": {"type": "string", "format": "uri"}, + "pattern": {"type": "string", "description": "Regular expression whose first group is the latest version the page announces."} + }, + "description": "For a project published outside any forge, the page that announces its releases. profile_sync --check-version compares the version read there with core_version." + }, "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/check_freshness.py b/scripts/check_freshness.py index ed7cc519..4dfd8a1f 100644 --- a/scripts/check_freshness.py +++ b/scripts/check_freshness.py @@ -1097,6 +1097,16 @@ def summarize_profile_sync(report: object) -> list[Finding]: if not isinstance(row, dict): continue name = str(row.get("name") or "?") + if row.get("skipped") and row.get("page_version"): + # Off any forge, the release page is the feed: a version there + # beyond the declared one is a release the profile does not cover. + declared, announced = str(row.get("declared_version") or ""), str(row["page_version"]) + findings.append(Finding( + "profiles", name, OK if announced == declared else STALE, + local=declared, remote=announced, + detail="" if announced == declared else "a newer release is announced", + )) + continue if row.get("skipped"): findings.append(Finding("profiles", name, UNKNOWN, detail=str(row["skipped"]))) continue diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index 739194b0..e644dfd0 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -1114,6 +1114,10 @@ class ProfileReport: # Repositories whose pin is no ancestor of the branch the profile # tracks: history was rewritten, or the pin lies on another branch. orphaned: list[str] = field(default_factory=list) + # A profile off any forge names the page announcing its releases + # (version_source): the version read there, beside the one declared. + declared_version: str | None = None + page_version: str | None = None # Skips that say the profile cannot be checked as written: a repository # that no longer answers, no pin to read, a death the forge contradicts. @@ -2032,6 +2036,11 @@ def format_report(report: ProfileReport, changed_only: bool = False) -> str: lines.append(f" not read (unsupported host): {url}") for line in report.orphaned: lines.append(f" orphaned pin: {line}") + if report.declared_version or report.page_version: + lines.append( + f" version page: declared {report.declared_version or '-'}, " + f"announced {report.page_version or 'unreadable'}" + ) if report.skipped: lines.append(f" skipped: {report.skipped}") return "\n".join(lines) @@ -2137,6 +2146,25 @@ def check_version( ) +def page_version(profile: dict, cache_dir: str, offline: bool) -> str | None: + """The latest version a profile's version_source page announces. + + Forty-odd profiles live off any forge: profile_sync could only say + "unsupported host", and a new Altirra or mednafen release went unseen. + """ + source = profile.get("version_source") or {} + url, pattern = str(source.get("url") or ""), str(source.get("pattern") or "") + if not url or not pattern: + return None + try: + text = upstream.fetch_page(url, cache_dir, offline) + except upstream.UpstreamError as exc: + print(f"{profile.get('emulator', '?')}: {url}: {exc}", file=sys.stderr) + return None + match = re.search(pattern, text or "") + return match.group(1) if match else None + + def version_warning(report: VersionReport | None, pin: str | None) -> str | None: """Flag a declared version that names a tag away from the resolved pin.""" if report is None or not report.tag_commit or not pin: @@ -3810,6 +3838,9 @@ def main() -> None: name=name, entries=[], counts={}, skipped=f"upstream error: {exc}" ) failed.add(name) + if args.check_version and profile.get("version_source"): + report.declared_version = str(profile.get("core_version") or "") or None + report.page_version = page_version(profile, args.cache_dir, args.offline) reports.append(report) if writes: try: diff --git a/scripts/upstream.py b/scripts/upstream.py index 325ec571..749bfe3f 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -400,6 +400,26 @@ def _api( return payload +def fetch_page(url: str, cache_dir: str, offline: bool) -> str | None: + """A web page's text, cached like a moving API answer (MOVING_TTL). + + A project published outside a forge (Altirra, mednafen, ePSXe) says its + version on a download page: that page is the only release feed there is. + """ + key = hashlib.sha256(url.encode()).hexdigest() + target = Path(cache_dir) / "_pages" / f"{key}.txt" + if target.is_file(): + fresh = time.time() - target.stat().st_mtime <= MOVING_TTL + if fresh or offline: + raw = target.read_text(encoding="utf-8") + return None if raw == ABSENT else raw + if offline: + return None + text = _http_text(url) + write_cache(target, ABSENT if text is None else text) + return text + + def _project(repo: Repo) -> str: return urllib.parse.quote(f"{repo.owner}/{repo.name}", safe="") diff --git a/tests/test_check_freshness.py b/tests/test_check_freshness.py index 619867c6..25719c74 100644 --- a/tests/test_check_freshness.py +++ b/tests/test_check_freshness.py @@ -422,5 +422,20 @@ class RowsSayAllTheyStandFor(unittest.TestCase): self.assertIn("--json lists all 50", row.detail) +class AReleasePageMakesAProfileStale(unittest.TestCase): + def test_a_newer_announced_version_is_stale(self): + import check_freshness + + rows = [ + {"name": "altirra", "skipped": "unsupported host: x", + "declared_version": "4.40", "page_version": "4.50"}, + {"name": "mednafen", "skipped": "unsupported host: y", + "declared_version": "1.32.1", "page_version": "1.32.1"}, + ] + found = {f.subject: f.status for f in check_freshness.summarize_profile_sync(rows)} + self.assertEqual(found["altirra"], check_freshness.STALE) + self.assertEqual(found["mednafen"], check_freshness.OK) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py index 5a1379f4..bb1d82a8 100644 --- a/tests/test_profile_sync.py +++ b/tests/test_profile_sync.py @@ -4253,5 +4253,20 @@ class StrictRefusesAnUncheckableProfile(unittest.TestCase): self.assertFalse(report("no source_ref").uncheckable()) +class AnOffForgeProfileWatchesItsReleasePage(unittest.TestCase): + """Profiles of projects outside any forge were skipped as an unsupported + host, and a new release of Altirra or mednafen went unseen.""" + + def test_the_page_version_is_read(self): + from unittest import mock + + profile = {"emulator": "Altirra", "core_version": "4.40", "version_source": { + "url": "https://example.test/altirra", "pattern": r"Altirra (\d+\.\d+) released", + }} + with mock.patch.object(profile_sync.upstream, "fetch_page", + return_value="

Altirra 4.50 released

"): + self.assertEqual(profile_sync.page_version(profile, "cache", False), "4.50") + + if __name__ == "__main__": unittest.main() diff --git a/wiki/profiling.md b/wiki/profiling.md index 616eb8dc..a1c91de5 100644 --- a/wiki/profiling.md +++ b/wiki/profiling.md @@ -384,6 +384,7 @@ partitions), and `game_count`. | `adler32_pairs_with`, `adler32_pairs` | the file hashed together with this one and the combinations the code accepts, this file's value first, in table order: Dolphin warns on a DSP ROM pair outside its `known_roms` even when each file passes alone | | `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 | | `prose_written_at` | the pin a reader confirmed the prose citations against. When the profile's history shows a text living under several pins, profile_sync takes this one as the revision the text describes, so a bump is no longer refused for a citation already reread. It never overrides a history with a single candidate | +| `version_source` | for a project published outside any forge, `{url, pattern}`: the page that announces releases and a regular expression whose first group is the latest version. `profile_sync --check-version` compares it with `core_version`, and `check_freshness --profiles` calls the profile stale when they differ | | `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 |