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:
Teknium
2026-10-10 04:16:02 -07:00
parent 4070f011a2
commit baab4808d0
2 changed files with 110 additions and 4 deletions

View File

@@ -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

View 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)