Every `hermes update` on the main channel first fetched hermes-assets.nousresearch.com/releases/channels/main.json. That record has never been published; a 404 fell through to the git branch. Some regional WAFs answer that same request 403 instead, which raised and stopped the update outright (#128295; patched around in #128323 and #128305). main IS the git branch, and the only thing its record could ever carry is a retirement notice nobody has published. resolve_source_target now returns the main branch target directly, the way stable reads GitHub releases directly. Preview channels (e.g. pm-preview) keep their R2 records.
376 lines
17 KiB
Python
376 lines
17 KiB
Python
"""Resolve promoted source releases, never infer publication from a Git tag."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from html.parser import HTMLParser
|
|
import json
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from hermes_cli.update_channel import STABLE_TAG_RE, is_canary_tag
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_PUBLIC_BASE = "https://hermes-assets.nousresearch.com"
|
|
OFFICIAL_REPOSITORY = "NousResearch/hermes-agent"
|
|
_GITHUB_ORIGIN = re.compile(
|
|
r"^(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)"
|
|
r"([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?/?$", re.IGNORECASE,
|
|
)
|
|
_SHA = re.compile(r"[0-9a-f]{40}")
|
|
|
|
|
|
def source_repository(git_cmd=None, cwd=None) -> str:
|
|
"""GitHub forks own their releases; other origins must mirror official tags."""
|
|
if git_cmd is not None:
|
|
from hermes_cli.source_check import source_git_env
|
|
|
|
result = subprocess.run(
|
|
[*git_cmd, "config", "--get", "remote.origin.url"], cwd=cwd,
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
|
|
stdin=subprocess.DEVNULL, env=source_git_env(),
|
|
check=False,
|
|
)
|
|
match = _GITHUB_ORIGIN.fullmatch(result.stdout.strip())
|
|
if result.returncode == 0 and match:
|
|
return match[1]
|
|
return OFFICIAL_REPOSITORY
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceTarget:
|
|
"""A pinned source build or an explicitly declared source-branch delivery."""
|
|
|
|
requested_channel: str
|
|
channel: str
|
|
repository: str
|
|
commit: str | None = None
|
|
branch: str | None = None
|
|
version: str | None = None
|
|
build_id: str | None = None
|
|
|
|
@property
|
|
def retired(self) -> bool:
|
|
return self.requested_channel != self.channel
|
|
|
|
ahead: bool = False
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
if self.ahead:
|
|
return (f"{self.channel} v{self.version}; this checkout is newer and waits for the next release"
|
|
" (`hermes update --set-channel main` follows every commit)")
|
|
return f"{self.channel} v{self.version} ({self.commit[:12]})" if self.commit else self.channel
|
|
|
|
|
|
def _resolve_channel(name: str, repository: str):
|
|
"""The only adapter to the validated requested/terminal records and manifest.
|
|
|
|
ChannelReader owns HTTPS, authority and digests. The source adapter below
|
|
admits its retirement constraints before any checkout operation. No legacy
|
|
GitHub fallback is allowed when a record is unavailable; the one exception
|
|
is an unpublished ``main`` record, which resolves to the main branch.
|
|
"""
|
|
from hermes_cli.release_channels import ChannelReader
|
|
|
|
return ChannelReader(_PUBLIC_BASE, repository=repository).resolve(name)
|
|
|
|
|
|
def resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=None,
|
|
forward_only: bool = False) -> SourceTarget:
|
|
"""Resolve a subscription: stable from GitHub releases, main from the git branch,
|
|
preview channels (e.g. ``pm-preview``) through their R2 records.
|
|
|
|
``forward_only`` (an unchosen default subscription) pins a checkout that already
|
|
contains the release to its own HEAD, so the update is a no-op instead of a downgrade.
|
|
"""
|
|
target = _resolve_source_target(channel, git_cmd, cwd, repository=repository)
|
|
if forward_only and target.commit and git_cmd is not None and cwd is not None:
|
|
head = _head_containing(git_cmd, cwd, target.commit, target.repository)
|
|
if head is not None and head != target.commit:
|
|
from dataclasses import replace
|
|
|
|
return replace(target, commit=head, ahead=True)
|
|
return target
|
|
|
|
|
|
def _head_containing(git_cmd, cwd, commit: str, repository: str) -> str | None:
|
|
"""HEAD's sha unless HEAD is PROVEN not to contain ``commit``, then None.
|
|
|
|
An unchosen default must never move a checkout backward on a guess, so an
|
|
unknown relation (shallow history, GitHub unreachable) keeps HEAD.
|
|
"""
|
|
from hermes_cli.source_check import _github_compare, source_git_env
|
|
|
|
def run(*args):
|
|
return subprocess.run(
|
|
[*git_cmd, *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8",
|
|
errors="replace", timeout=10, stdin=subprocess.DEVNULL, env=source_git_env(), check=False)
|
|
|
|
head = run("rev-parse", "HEAD").stdout.strip()
|
|
if not _SHA.fullmatch(head):
|
|
return None
|
|
ancestry = run("merge-base", "--is-ancestor", commit, head).returncode
|
|
if ancestry == 0:
|
|
return head
|
|
# A shallow boundary makes exit 1 a guess; full history makes it proof.
|
|
if ancestry == 1 and run("rev-parse", "--is-shallow-repository").stdout.strip() == "false":
|
|
return None
|
|
status = (_github_compare(commit, head, repository) or {}).get("status")
|
|
return None if status in ("behind", "diverged") else head
|
|
|
|
|
|
def _resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=None) -> SourceTarget:
|
|
from hermes_cli.release_channels import validate_name
|
|
|
|
validate_name(channel)
|
|
repository = repository or source_repository(git_cmd, cwd)
|
|
if channel == "stable":
|
|
return _resolve_stable(repository, git_cmd, cwd)
|
|
if channel == "main":
|
|
# main IS the git branch. No R2 record is read: none was ever published, and a
|
|
# network that answers it with 403 instead of 404 (regional WAFs) must not stop
|
|
# an install from following the branch it is on.
|
|
return SourceTarget(channel, channel, repository, branch="main")
|
|
resolved = _resolve_channel(channel, repository)
|
|
terminal = resolved.terminal
|
|
if terminal["repository"].lower() != repository.lower():
|
|
raise ValueError("Channel repository does not match this source installation")
|
|
destination = validate_name(terminal["name"])
|
|
if terminal["policy"] == "source-branch":
|
|
if resolved.requested["state"] == "retired":
|
|
raise ValueError("Source retirement requires a published destination commit")
|
|
delivery = terminal["delivery"]
|
|
if delivery["kind"] != "source-branch":
|
|
raise ValueError("Channel has no source-branch delivery")
|
|
return SourceTarget(channel, destination, repository, branch=delivery["branch"])
|
|
if resolved.manifest is None:
|
|
raise ValueError(f"No build published for channel {destination}")
|
|
request = resolved.manifest["request"]
|
|
|
|
commit = request["commit"]
|
|
if not isinstance(commit, str) or not _SHA.fullmatch(commit):
|
|
raise ValueError("Channel build has no exact source commit")
|
|
if resolved.requested["state"] == "retired":
|
|
_refuse_retirement_downgrade(request, terminal, git_cmd, cwd)
|
|
return SourceTarget(channel, destination, repository, commit=commit,
|
|
version=request["sourceVersion"], build_id=request["buildId"])
|
|
|
|
|
|
def _describe_release_failure(failure: BaseException) -> str:
|
|
"""A transport failure names its cause (a rate limit is not "no release")."""
|
|
if isinstance(failure, (urllib.error.URLError, TimeoutError)):
|
|
from hermes_cli.github_api import describe_github_failure
|
|
|
|
# The request that failed, not the configuration: a rejected token retries anonymously.
|
|
return describe_github_failure(failure, authenticated=getattr(failure, "hermes_authenticated", False))
|
|
return f"No published stable release could be verified: {failure}"
|
|
|
|
|
|
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.
|
|
"""
|
|
try:
|
|
tag, commit = _resolve_release("stable", git_cmd, cwd, repository=repository, pointer=False)
|
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
logger.warning("Could not resolve the stable source release: %s", exc)
|
|
raise ValueError(_describe_release_failure(exc)) from exc
|
|
return SourceTarget("stable", "stable", repository, commit=commit, version=str(tag).removeprefix("v"))
|
|
|
|
|
|
def _refuse_retirement_downgrade(request: dict, terminal: dict, git_cmd, cwd) -> None:
|
|
"""Qualification of preview data is not permission to roll back newer source."""
|
|
from pathlib import Path
|
|
import tomllib
|
|
|
|
if cwd is None:
|
|
return
|
|
version_file = Path(cwd) / "pyproject.toml"
|
|
if version_file.exists():
|
|
with version_file.open("rb") as file:
|
|
project = tomllib.load(file).get("project")
|
|
installed_version = project.get("version") if isinstance(project, dict) else None
|
|
if not isinstance(installed_version, str) or not re.fullmatch(r"\d+\.\d+\.\d+", installed_version, re.ASCII):
|
|
raise ValueError("Source retirement cannot verify the installed source version")
|
|
if tuple(map(int, installed_version.split("."))) > tuple(map(int, request["sourceVersion"].split("."))):
|
|
raise ValueError("Source retirement would downgrade a newer source version; select the destination channel explicitly")
|
|
if git_cmd is not None:
|
|
from hermes_cli.source_check import source_git_env
|
|
|
|
result = subprocess.run(
|
|
[*git_cmd, "rev-list", "--ancestry-path", f"{request['commit']}..HEAD"], cwd=cwd,
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
|
|
stdin=subprocess.DEVNULL, env=source_git_env(),
|
|
check=False,
|
|
)
|
|
# The target need not exist locally before the updater's fetch. When it
|
|
# does, any descendants prove that this pinned build would roll us back.
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
raise ValueError("Source retirement would downgrade a newer source commit; select the destination channel explicitly")
|
|
if terminal["head"]["sequence"] > request["sequence"]:
|
|
# Shallow checkouts may lack the qualified commit, even when HEAD is
|
|
# today's stable build. Read it with the protocol's full digest checks.
|
|
current_manifest = _resolve_channel(terminal["name"], request["repository"]).manifest
|
|
if current_manifest is None:
|
|
raise ValueError("Source retirement cannot verify the current destination build")
|
|
current = current_manifest["request"]
|
|
installed = subprocess.run(
|
|
[*git_cmd, "rev-parse", "HEAD"], cwd=cwd, check=True,
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
|
|
stdin=subprocess.DEVNULL, env=source_git_env(),
|
|
).stdout.strip()
|
|
if installed == current["commit"] and installed != request["commit"]:
|
|
raise ValueError("Source retirement would downgrade the newer destination build; select the destination channel explicitly")
|
|
|
|
|
|
def _read(url: str, *, missing_ok: bool = False) -> str | None:
|
|
request = urllib.request.Request(url, headers={
|
|
"User-Agent": "hermes-update", "Cache-Control": "no-cache",
|
|
"Accept": "application/json, text/html",
|
|
})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
return response.read(2 * 1024 * 1024).decode("utf-8-sig")
|
|
except urllib.error.HTTPError as exc:
|
|
if missing_ok and exc.code == 404:
|
|
return None
|
|
raise
|
|
|
|
|
|
class _BuildMetadata(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.tags = []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
fields = dict(attrs)
|
|
if tag == "meta" and fields.get("name") == "hermes-build":
|
|
self.tags.append(fields.get("content"))
|
|
|
|
|
|
def _valid_tag(tag, channel: str) -> bool:
|
|
if not isinstance(tag, str):
|
|
return False
|
|
return bool(STABLE_TAG_RE.fullmatch(tag)) if channel == "stable" else (
|
|
tag == tag.strip() and is_canary_tag(tag)
|
|
)
|
|
|
|
|
|
def _published(release, channel: str) -> bool:
|
|
return (isinstance(release, dict) and release.get("draft") is False
|
|
and release.get("prerelease") is (channel == "canary")
|
|
and _valid_tag(release.get("tag_name"), channel))
|
|
|
|
|
|
def _json(url: str):
|
|
"""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:
|
|
if channel == "stable":
|
|
release = _json(f"{base}/releases/latest")
|
|
if _published(release, channel):
|
|
return release
|
|
else:
|
|
# GitHub lists newest releases first. Bound the scan; failure must
|
|
# never turn into an arbitrary Git-tag update.
|
|
for page in range(1, 11):
|
|
entries = _json(f"{base}/releases?per_page=100&page={page}")
|
|
if not isinstance(entries, list):
|
|
break
|
|
for release in entries:
|
|
if _published(release, channel):
|
|
return release
|
|
if len(entries) < 100:
|
|
break
|
|
raise ValueError(f"No published {channel} release")
|
|
|
|
|
|
def _release_pointer(channel: str) -> tuple[str | None, str | None]:
|
|
# Stable's completion job writes this before publishing the GitHub draft.
|
|
# Publication is checked separately, so that interval fails closed.
|
|
if channel == "stable":
|
|
text = _read(f"{_PUBLIC_BASE}/releases/stable/release-candidates.json", missing_ok=True)
|
|
if text is not None:
|
|
data = json.loads(text)
|
|
if (not isinstance(data, dict) or not _valid_tag(data.get("tag"), channel)
|
|
or not isinstance(data.get("commit"), str) or not _SHA.fullmatch(data["commit"])):
|
|
raise ValueError("Invalid stable release pointer")
|
|
return data["tag"], data["commit"]
|
|
text = _read(f"{_PUBLIC_BASE}/releases/{channel}/index.html", missing_ok=True)
|
|
if text is None:
|
|
return None, None
|
|
page = _BuildMetadata()
|
|
page.feed(text)
|
|
if len(page.tags) != 1 or not _valid_tag(page.tags[0], channel):
|
|
raise ValueError(f"Invalid {channel} release pointer")
|
|
return page.tags[0], None
|
|
|
|
|
|
def _resolve_release(channel: str, git_cmd=None, cwd=None, *, repository=None,
|
|
pointer: bool = True) -> tuple[str, str]:
|
|
"""``resolve_source_release`` that raises the failure instead of returning ``(None, None)``."""
|
|
if channel not in ("stable", "canary"):
|
|
raise ValueError(f"Not a release channel: {channel}")
|
|
repository = repository or source_repository(git_cmd, cwd)
|
|
base = f"https://api.github.com/repos/{repository}"
|
|
tag, pinned_sha = (_release_pointer(channel)
|
|
if pointer and repository.lower() == OFFICIAL_REPOSITORY.lower() else (None, None))
|
|
if tag is None:
|
|
release = _published_fallback(channel, base)
|
|
tag = release["tag_name"]
|
|
else:
|
|
release = _json(f"{base}/releases/tags/{tag}")
|
|
if not _published(release, channel) or release["tag_name"] != tag:
|
|
raise ValueError(f"{tag} is not a published {channel} release")
|
|
commit = _json(f"{base}/commits/{tag}")
|
|
sha = commit.get("sha") if isinstance(commit, dict) else None
|
|
if not isinstance(sha, str) or not _SHA.fullmatch(sha):
|
|
raise ValueError(f"No published commit for release {tag}")
|
|
if git_cmd is not None:
|
|
from hermes_cli.source_check import source_git_env
|
|
|
|
ref = f"refs/tags/{tag}"
|
|
result = subprocess.run(
|
|
[*git_cmd, "ls-remote", "--tags", "origin", ref, ref + "^{}"],
|
|
cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
check=True, timeout=60, stdin=subprocess.DEVNULL,
|
|
env=source_git_env(),
|
|
)
|
|
refs = dict((parts[1], parts[0]) for line in result.stdout.splitlines()
|
|
if len(parts := line.split()) == 2)
|
|
if refs.get(ref + "^{}", refs.get(ref)) != sha:
|
|
raise ValueError(f"Origin tag {tag} does not match the published release commit")
|
|
if pinned_sha is not None and sha != pinned_sha:
|
|
raise ValueError(f"Release {tag} no longer matches its published commit")
|
|
return tag, sha
|
|
|
|
|
|
def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=None,
|
|
pointer: bool = True) -> tuple[str | None, str | None]:
|
|
"""Read published stable/canary release metadata (not channel discovery).
|
|
|
|
The source updater's ``stable`` reads it with ``pointer=False``: GitHub's latest
|
|
published release only. With ``pointer`` the R2 channel pointers outrank GitHub's
|
|
release listing. A malformed pointer,
|
|
draft, or tag/commit mismatch is not permission to select a different build.
|
|
``git_cmd`` resolves the selected tag on origin; ZIP callers omit it and
|
|
resolve the same tag through GitHub's commit endpoint.
|
|
"""
|
|
try:
|
|
return _resolve_release(channel, git_cmd, cwd, repository=repository, pointer=pointer)
|
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
logger.warning("Could not resolve the %s source release: %s", channel, exc)
|
|
return None, None
|