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.
290 lines
13 KiB
Python
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
|