diff --git a/scripts/upstream.py b/scripts/upstream.py index ec047995..ae294aed 100644 --- a/scripts/upstream.py +++ b/scripts/upstream.py @@ -258,13 +258,32 @@ def fetch_file( return None if text is None else text.splitlines() -def _api(url: str, cache_dir: str, offline: bool) -> object | None: - """Cached API call. The cache stores absence as well as payloads.""" +# How long an answer about a moving reference stays trusted. A branch tip, a +# tag list or a release list changes under the profile: libretro/hatari grew +# a new main branch after its tip was first cached, and every comparison +# then ran against the old branch. Content addressed by a sha or a date +# never changes and is cached for good. +MOVING_TTL = 6 * 3600 + + +def _api( + url: str, cache_dir: str, offline: bool, max_age: float | None = None +) -> object | None: + """Cached API call. The cache stores absence as well as payloads. + + ``max_age`` bounds the trust in a cached answer, in seconds; a stale + answer is refreshed unless the run is offline, when it still serves. + """ key = hashlib.sha256(url.encode()).hexdigest() target = Path(cache_dir) / "_api" / f"{key}.json" if target.is_file(): - raw = target.read_text(encoding="utf-8") - return None if raw == ABSENT else json.loads(raw) + fresh = ( + max_age is None + or time.time() - target.stat().st_mtime <= max_age + ) + if fresh or offline: + raw = target.read_text(encoding="utf-8") + return None if raw == ABSENT else json.loads(raw) if offline: return None payload = _http_json(url) @@ -312,7 +331,9 @@ def resolve_head( 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)) + return _first_sha( + _api(_commits_url(repo, None, branch), cache_dir, offline, MOVING_TTL) + ) def resolve_commit_at( @@ -334,7 +355,7 @@ def _tags_url(repo: Repo) -> str: def list_tags(repo: Repo, cache_dir: str, offline: bool = False) -> list[str]: """Tag names, newest first as the forge orders them.""" - payload = _api(_tags_url(repo), cache_dir, offline) + payload = _api(_tags_url(repo), cache_dir, offline, MOVING_TTL) if not isinstance(payload, list): return [] return [t["name"] for t in payload if isinstance(t, dict) and t.get("name")] @@ -344,7 +365,7 @@ def resolve_tag_commit( repo: Repo, tag: str, cache_dir: str, offline: bool = False ) -> str | None: """Commit a tag points at.""" - payload = _api(_tags_url(repo), cache_dir, offline) + payload = _api(_tags_url(repo), cache_dir, offline, MOVING_TTL) if not isinstance(payload, list): return None for entry in payload: @@ -399,7 +420,7 @@ def latest_release( repo: Repo, cache_dir: str, offline: bool = False ) -> Release | None: """Most recent release the forge exposes.""" - payload = _api(_releases_url(repo), cache_dir, offline) + payload = _api(_releases_url(repo), cache_dir, offline, MOVING_TTL) if isinstance(payload, list): payload = payload[0] if payload else None if not isinstance(payload, dict): diff --git a/tests/test_upstream.py b/tests/test_upstream.py index 5637d42f..51ab6133 100644 --- a/tests/test_upstream.py +++ b/tests/test_upstream.py @@ -365,6 +365,51 @@ class TestRevisions(unittest.TestCase): upstream.resolve_commit_at(self.repo, "2026-03-29", self.dir) self.assertEqual(len(self.calls), 1) + def test_a_branch_tip_is_refreshed_once_stale(self): + import os as _os + import hashlib as _hashlib + self.responses["/commits"] = [{"sha": "old"}] + self.assertEqual(upstream.resolve_head(self.repo, self.dir), "old") + url = upstream._commits_url(self.repo, None, None) + cached = ( + Path(self.dir) / "_api" + / f"{_hashlib.sha256(url.encode()).hexdigest()}.json" + ) + stale = _os.path.getmtime(cached) - upstream.MOVING_TTL - 1 + _os.utime(cached, (stale, stale)) + self.responses["/commits"] = [{"sha": "new"}] + self.assertEqual(upstream.resolve_head(self.repo, self.dir), "new") + self.assertEqual(len(self.calls), 2) + + def test_a_stale_tip_still_serves_offline(self): + import os as _os + import hashlib as _hashlib + self.responses["/commits"] = [{"sha": "old"}] + upstream.resolve_head(self.repo, self.dir) + url = upstream._commits_url(self.repo, None, None) + cached = ( + Path(self.dir) / "_api" + / f"{_hashlib.sha256(url.encode()).hexdigest()}.json" + ) + stale = _os.path.getmtime(cached) - upstream.MOVING_TTL - 1 + _os.utime(cached, (stale, stale)) + self.assertEqual(upstream.resolve_head(self.repo, self.dir, offline=True), "old") + self.assertEqual(len(self.calls), 1) + + def test_a_dated_lookup_is_cached_for_good(self): + import os as _os + import hashlib as _hashlib + self.responses["/commits"] = [{"sha": "abc"}] + upstream.resolve_commit_at(self.repo, "2026-03-29", self.dir) + url = upstream._commits_url(self.repo, "2026-03-29", None) + cached = ( + Path(self.dir) / "_api" + / f"{_hashlib.sha256(url.encode()).hexdigest()}.json" + ) + _os.utime(cached, (0, 0)) + upstream.resolve_commit_at(self.repo, "2026-03-29", self.dir) + self.assertEqual(len(self.calls), 1) + def test_offline_without_cache_returns_none(self): self.assertIsNone(upstream.resolve_head(self.repo, self.dir, offline=True)) self.assertEqual(self.calls, [])