diff --git a/hermes_cli/source_releases.py b/hermes_cli/source_releases.py index df26088685..67abd99086 100644 --- a/hermes_cli/source_releases.py +++ b/hermes_cli/source_releases.py @@ -159,14 +159,30 @@ def _resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=N version=request["sourceVersion"], build_id=request["buildId"]) +# Why the last release lookup returned nothing, so callers name the real cause +# (a rate limit is not "no release"). +_last_release_failure: dict[str, BaseException] = {} + + +def _describe_release_failure(failure: BaseException | None) -> str: + if isinstance(failure, (urllib.error.URLError, TimeoutError)): + from hermes_cli.github_api import describe_github_failure, github_token + + return describe_github_failure(failure, authenticated=github_token() is not None) + return f"No published stable release could be verified: {failure}" if failure else ( + "No published stable release could be verified") + + def _resolve_stable(repository: str, git_cmd=None, cwd=None) -> SourceTarget: """Stable IS the repository's latest published GitHub release: the tag a release publishes (non-draft, non-prerelease, strict vX.Y.Z), verified against origin. No R2 record or pointer is consulted; publishing the release is the only promotion. """ + _last_release_failure.pop("stable", None) tag, commit = resolve_source_release("stable", git_cmd, cwd, repository=repository, pointer=False) if commit is None: - raise ValueError("No published stable release could be verified") + failure = _last_release_failure.pop("stable", None) + raise ValueError(_describe_release_failure(failure)) return SourceTarget("stable", "stable", repository, commit=commit, version=str(tag).removeprefix("v")) @@ -254,9 +270,14 @@ def _published(release, channel: str) -> bool: def _json(url: str): - text = _read(url) - assert text is not None - return json.loads(text) + """GitHub API JSON with the updater's credential ladder (token, gh, anonymous). + + Anonymous api.github.com is 60 requests/hour per address, so an unauthenticated + stable lookup fails for everyone behind one office, CI or container exit. + """ + from hermes_cli.source_check import _request + + return json.loads(_request(url)) def _published_fallback(channel: str, base: str) -> dict: @@ -348,4 +369,5 @@ def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=N return tag, sha except (OSError, ValueError, subprocess.SubprocessError) as exc: logger.warning("Could not resolve the %s source release: %s", channel, exc) + _last_release_failure[channel] = exc return None, None diff --git a/tests/hermes_cli/test_stable_lookup_github_auth.py b/tests/hermes_cli/test_stable_lookup_github_auth.py new file mode 100644 index 0000000000..64fed2bff7 --- /dev/null +++ b/tests/hermes_cli/test_stable_lookup_github_auth.py @@ -0,0 +1,84 @@ +"""The stable lookup spends the user's GitHub credential, not the shared anonymous budget.""" +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import threading +import urllib.request + +import pytest + +pytestmark = pytest.mark.real_release_channels + +from hermes_cli import github_api, source_releases + +SHA = "818c13be1dc4fd28987e1e881a9408224afd4535" + + +@pytest.fixture +def github(monkeypatch): + """A local api.github.com: answers releases/latest + commits/, or 403s the quota.""" + seen: list[tuple[str, str | None]] = [] + state = {"limited": False} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + seen.append((self.path, self.headers.get("Authorization"))) + if state["limited"]: + body = b'{"message":"API rate limit exceeded"}' + self.send_response(403) + self.send_header("x-ratelimit-remaining", "0") + self.send_header("x-ratelimit-reset", "9999999999") + elif self.path.endswith("/releases/latest"): + body = json.dumps({"tag_name": "v0.21.6", "draft": False, "prerelease": False}).encode() + self.send_response(200) + elif self.path.endswith("/commits/v0.21.6"): + body = json.dumps({"sha": SHA}).encode() + self.send_response(200) + else: + body = b"{}" + self.send_response(404) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + real = urllib.request.urlopen + + def local(request, *args, **kwargs): + url = request.full_url if isinstance(request, urllib.request.Request) else request + assert url.startswith("https://api.github.com/"), url # the token must never leave GitHub + rerouted = urllib.request.Request( + url.replace("https://api.github.com", f"http://127.0.0.1:{server.server_port}"), + headers=dict(request.header_items()) if isinstance(request, urllib.request.Request) else {}) + return real(rerouted, *args, **kwargs) + + monkeypatch.setattr(urllib.request, "urlopen", local) + monkeypatch.setattr(github_api, "_gh_cli_token", lambda: None) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + yield seen, state + server.shutdown() + + +def test_stable_lookup_sends_the_configured_token(github, monkeypatch): + seen, _ = github + monkeypatch.setenv("GH_TOKEN", "ghp_user") + target = source_releases._resolve_stable("NousResearch/hermes-agent") + assert (target.commit, target.version) == (SHA, "0.21.6") + assert seen and all(auth == "Bearer ghp_user" for _, auth in seen) + + +@pytest.mark.parametrize("token", [None, "ghp_user"]) +def test_a_rate_limit_is_reported_as_one_not_as_a_missing_release(github, monkeypatch, token): + _, state = github + state["limited"] = True + if token: + monkeypatch.setenv("GH_TOKEN", token) + else: + monkeypatch.delenv("GH_TOKEN", raising=False) + with pytest.raises(ValueError) as caught: + source_releases._resolve_stable("NousResearch/hermes-agent") + message = str(caught.value) + assert "rate limit" in message and "No published stable release" not in message + assert ("GITHUB_TOKEN in the environment" in message) is (token is None)