fix(update): the stable lookup sends the user's GitHub token and names a rate limit as one
`_resolve_stable` read releases/latest + commits/<tag> through a bare urllib call with no credential, so it spent the anonymous 60/hour per-IP budget even with GH_TOKEN set or gh logged in (the rest of the update check already uses github_api's token ladder). Behind one office, CI or container exit that budget runs out, and the failure was then reported as "No published stable release could be verified". GitHub API reads now go through source_check._request (token, gh CLI, anonymous; a rejected token retries anonymously). The R2 reads keep their own path, so the token never leaves api.github.com. A lookup that fails on transport now raises describe_github_failure's line (rate limit + reset time + the GITHUB_TOKEN hint when anonymous) instead of "no release".
This commit is contained in:
@@ -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
|
||||
|
||||
84
tests/hermes_cli/test_stable_lookup_github_auth.py
Normal file
84
tests/hermes_cli/test_stable_lookup_github_auth.py
Normal file
@@ -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/<tag>, 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)
|
||||
Reference in New Issue
Block a user