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