Files
hermes-agent/hermes_cli/source_releases.py
ethernet 1dd9a9955d fix(update): follow the main branch when its channel record is unpublished
A source checkout on the main channel failed every update check with
"Could not resolve the main source channel: Channel object not found:
releases/channels/main.json" until R2 publishes that record, in the CLI
and in Desktop (which asks hermes_cli.source_check). main IS the source
branch -- its record can only add a retirement -- so a missing main record
now resolves to the main branch and the update continues via git. Other
channels, and transient read failures for main, still refuse.
2026-09-24 14:23:48 -04:00

290 lines
13 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(),
)
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
@property
def label(self) -> str:
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) -> SourceTarget:
"""Resolve every subscription, including default labels, through R2."""
from hermes_cli.release_channels import ChannelNotFound, validate_name
validate_name(channel)
repository = repository or source_repository(git_cmd, cwd)
try:
resolved = _resolve_channel(channel, repository)
except ChannelNotFound:
if channel != "main":
raise
# main IS the source branch; its record can only add a retirement.
# Until one is published, a checkout keeps following the branch via git.
return SourceTarget(channel, channel, repository, branch="main")
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 _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(),
)
# 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):
text = _read(url)
assert text is not None
return json.loads(text)
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_source_release(channel: str, git_cmd=None, cwd=None, *, repository=None) -> tuple[str | None, str | None]:
"""Read historical stable/canary release metadata (not channel discovery).
Runtime check/apply use ``resolve_source_target`` and never fall back here.
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.
"""
if channel not in ("stable", "canary"):
raise ValueError(f"Not a release channel: {channel}")
try:
repository = repository or source_repository(git_cmd, cwd)
base = f"https://api.github.com/repos/{repository}"
tag, pinned_sha = (_release_pointer(channel)
if 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
except (OSError, ValueError, subprocess.SubprocessError) as exc:
logger.warning("Could not resolve the %s source release: %s", channel, exc)
return None, None