fix: track published source release channels at exact commits
This commit is contained in:
@@ -2253,6 +2253,7 @@ def _update_preflight_handled(args) -> bool:
|
||||
_cmd_update_check(
|
||||
branch=branch,
|
||||
branch_explicit=bool(getattr(args, "branch", None)),
|
||||
**({"channel": args.channel} if getattr(args, "channel", None) else {}),
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
168
hermes_cli/source_releases.py
Normal file
168
hermes_cli/source_releases.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Resolve promoted source releases, never infer publication from a Git tag."""
|
||||
from __future__ import annotations
|
||||
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from hermes_cli.update_channel import 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,
|
||||
)
|
||||
_STABLE_TAG = re.compile(r"v(?:0|[1-9]\d{0,2})\.\d+\.\d+")
|
||||
_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:
|
||||
result = subprocess.run(
|
||||
[*git_cmd, "config", "--get", "remote.origin.url"], cwd=cwd,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
|
||||
)
|
||||
match = _GITHUB_ORIGIN.fullmatch(result.stdout.strip())
|
||||
if result.returncode == 0 and match:
|
||||
return match[1]
|
||||
return OFFICIAL_REPOSITORY
|
||||
|
||||
|
||||
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")
|
||||
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.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]:
|
||||
"""Return the published channel's tag and exact commit, or no target on failure.
|
||||
|
||||
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:
|
||||
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={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"},
|
||||
)
|
||||
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
|
||||
@@ -74,10 +74,10 @@ def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
|
||||
help=(
|
||||
"Persist the update channel for THIS install (recorded per "
|
||||
"install in config.yaml under update.installs). 'stable' tracks "
|
||||
"tagged releases, 'main' the git main branch, 'canary' the "
|
||||
"canary prereleases (desktop bundles only; source installs "
|
||||
"normalize canary to main). Installs whose updates an external "
|
||||
"steward owns (nix, docker, app stores) have no channel."
|
||||
"published stable releases, 'main' the git main branch, and "
|
||||
"'canary' published canary prereleases. Source installs check out "
|
||||
"the selected release's exact commit. Package channels are baked "
|
||||
"into their separate stable/canary identities and cannot be changed."
|
||||
),
|
||||
)
|
||||
update_parser.add_argument(
|
||||
@@ -96,8 +96,8 @@ def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
|
||||
metavar="CHANNEL",
|
||||
help=(
|
||||
"Track CHANNEL for this run only (transient override; "
|
||||
"--set-channel persists). 'stable' updates to the newest tagged "
|
||||
"release, 'main' to the branch tip."
|
||||
"--set-channel persists). 'stable' and 'canary' select published "
|
||||
"releases, 'main' the branch tip. Source installs only."
|
||||
),
|
||||
)
|
||||
update_parser.set_defaults(func=cmd_update)
|
||||
|
||||
@@ -21,10 +21,9 @@ same path, and the channel opt-in must survive that.
|
||||
* Written by ``hermes update --set-channel <x>`` from inside an install
|
||||
(it knows its own id — the user never types a sha).
|
||||
* Shown by ``hermes update --install-id`` and the desktop About page.
|
||||
* Channels are meaningful ONLY where the mechanism is ``self`` (which git
|
||||
ref: main / stable / canary→main) or ``electron-updater`` (which feed:
|
||||
latest.yml / canary.yml). ``external`` installs have no channel; the
|
||||
steward owns updates.
|
||||
* Source installs select main or a published stable/canary release. Bundles
|
||||
derive their channel from the baked tag, never from these records.
|
||||
``external`` installs have no configurable channel; the steward owns updates.
|
||||
|
||||
Pure-stdlib leaf module (plus hermes-internal imports done lazily): the
|
||||
installers and boot paths read it before the full config machinery loads.
|
||||
@@ -118,6 +117,12 @@ def channel_record(config: Optional[dict], project_root: Optional[Path] = None)
|
||||
return record if isinstance(record, dict) else {}
|
||||
|
||||
|
||||
def _package_channel(stamp: dict) -> bool:
|
||||
return stamp.get("payload") in ("bundled", "light") or stamp.get("updateMechanism") in (
|
||||
"electron-updater", "app-installer", "microsoft-store"
|
||||
)
|
||||
|
||||
|
||||
def default_channel(project_root: Optional[Path] = None) -> str:
|
||||
"""The channel an unconfigured install tracks.
|
||||
|
||||
@@ -134,7 +139,7 @@ def default_channel(project_root: Optional[Path] = None) -> str:
|
||||
"""
|
||||
root = Path(project_root) if project_root is not None else _default_root()
|
||||
stamp = _read_stamp(root)
|
||||
if stamp.get("updateMechanism") not in ("electron-updater", "app-installer", "microsoft-store"):
|
||||
if not _package_channel(stamp):
|
||||
return CHANNEL_MAIN
|
||||
return CHANNEL_CANARY if is_canary_tag(stamp.get("tag")) else CHANNEL_STABLE
|
||||
|
||||
@@ -143,35 +148,14 @@ def resolve_update_channel(
|
||||
config: Optional[dict] = None,
|
||||
project_root: Optional[Path] = None,
|
||||
) -> str:
|
||||
"""The effective update channel for this install.
|
||||
|
||||
Resolution: the per-install record (``update.installs.<sha16>.channel``)
|
||||
when valid; otherwise the mechanism default (main for self-source,
|
||||
stable/canary for release bundles by artifact tag). Source
|
||||
installs asking for canary normalize to main — canary builds are
|
||||
release artifacts, and a git checkout tracks branches; callers print
|
||||
the note.
|
||||
"""
|
||||
configured: Any = channel_record(config, project_root).get("channel")
|
||||
"""Source records select releases or main; package tags fix bundle identity."""
|
||||
root = Path(project_root) if project_root is not None else _default_root()
|
||||
if _package_channel(_read_stamp(root)):
|
||||
return default_channel(root)
|
||||
configured: Any = channel_record(config, root).get("channel")
|
||||
if isinstance(configured, str) and configured.strip().lower() in VALID_CHANNELS:
|
||||
channel = configured.strip().lower()
|
||||
else:
|
||||
channel = default_channel(project_root)
|
||||
|
||||
if channel == CHANNEL_CANARY:
|
||||
root = Path(project_root) if project_root is not None else _default_root()
|
||||
if _read_stamp(root).get("updateMechanism") not in ("electron-updater", "app-installer", "microsoft-store"):
|
||||
# canary→main normalization for source installs.
|
||||
return CHANNEL_MAIN
|
||||
return channel
|
||||
|
||||
|
||||
def canary_normalized_note() -> str:
|
||||
"""The one-line note callers print when canary normalizes to main."""
|
||||
return (
|
||||
"→ Channel 'canary' on a source install tracks main "
|
||||
"(canary builds are desktop release artifacts)."
|
||||
)
|
||||
return configured.strip().lower()
|
||||
return default_channel(root)
|
||||
|
||||
|
||||
def set_install_channel(
|
||||
@@ -184,15 +168,19 @@ def set_install_channel(
|
||||
including Microsoft Store, rather than this configuration.
|
||||
Raises ``ValueError`` for an invalid channel or an OS-owned install.
|
||||
"""
|
||||
from hermes_cli.update_contract import COMMIT_BUILD_UPDATE_MESSAGE, is_commit_build
|
||||
|
||||
root = Path(project_root) if project_root is not None else _default_root()
|
||||
if is_commit_build(root):
|
||||
raise ValueError(COMMIT_BUILD_UPDATE_MESSAGE)
|
||||
channel = (channel or "").strip().lower()
|
||||
if channel not in VALID_CHANNELS:
|
||||
raise ValueError(
|
||||
f"unknown channel {channel!r} (one of {', '.join(VALID_CHANNELS)})"
|
||||
)
|
||||
|
||||
root = Path(project_root) if project_root is not None else _default_root()
|
||||
stamp = _read_stamp(root)
|
||||
if stamp.get("updateMechanism") in ("external", "app-installer", "microsoft-store"):
|
||||
if _package_channel(stamp) or stamp.get("updateMechanism") == "external":
|
||||
distribution = stamp.get("distribution") or "an external steward"
|
||||
raise ValueError(
|
||||
f"channels don't apply here; updates are owned by {distribution}"
|
||||
@@ -220,13 +208,7 @@ def handle_metadata_args(args, project_root: Path) -> bool:
|
||||
if channel == CHANNEL_CANARY:
|
||||
print("Canary builds can write forward-incompatible state. Back up your data before switching.")
|
||||
elif channel == CHANNEL_STABLE:
|
||||
from hermes_cli.steward import read_install_stamp
|
||||
|
||||
current = read_install_stamp(project_root).get("displayVersion", "")
|
||||
if "-canary." in current:
|
||||
stable = current.partition("-canary.")[0]
|
||||
print(f"You are on {current}. Wait for v{stable} or a newer stable release.")
|
||||
print("For a manual reinstall, see https://hermes-agent.nousresearch.com.")
|
||||
print("Switching to an older stable release may not read state written by canary. Back up your data first.")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -649,104 +649,45 @@ def _latest_release_tag_from_ls_remote(output: str):
|
||||
tag = best[1]
|
||||
return tag, shas.get(tag)
|
||||
def _resolve_latest_release_tag(git_cmd, cwd):
|
||||
"""Ask origin for the newest final release tag. Returns (tag, sha) or (None, None)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
git_cmd + ["ls-remote", "--tags", "origin", "v*"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
timeout=60,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.warning("Could not list release tags from origin: %s", exc)
|
||||
return None, None
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"git ls-remote --tags failed: %s",
|
||||
(result.stderr or "").strip().splitlines()[:1],
|
||||
)
|
||||
return None, None
|
||||
return _latest_release_tag_from_ls_remote(result.stdout)
|
||||
def _stable_channel_active(args) -> bool:
|
||||
"""Return True when this update must track tagged releases, not a branch.
|
||||
"""Resolve the published stable release (historical updater import surface)."""
|
||||
from hermes_cli.source_releases import resolve_source_release
|
||||
|
||||
``args`` is the update argparse namespace, or None when the caller has no
|
||||
flags to honor. An explicit ``--branch`` always wins (the user names the
|
||||
exact update target; a tag silently overriding it would resurrect the bug
|
||||
class --branch prevents). An explicit ``--channel`` is the transient
|
||||
per-invocation override (``--set-channel`` is the persistent one). In all
|
||||
other cases the effective channel comes from the per-install record
|
||||
(see hermes_cli.update_channel.resolve_update_channel).
|
||||
"""
|
||||
if getattr(args, "branch", None):
|
||||
return False
|
||||
transient = getattr(args, "channel", None)
|
||||
return resolve_source_release("stable", git_cmd, cwd)
|
||||
|
||||
|
||||
def _source_update_channel(args=None, *, channel=None, branch_explicit=False) -> str:
|
||||
"""Explicit branches win; otherwise transient channel, then this install's record."""
|
||||
if branch_explicit or getattr(args, "branch", None):
|
||||
return "main"
|
||||
transient = channel or getattr(args, "channel", None)
|
||||
if transient:
|
||||
from hermes_cli.update_channel import CHANNEL_STABLE
|
||||
return transient
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.update_channel import resolve_update_channel
|
||||
|
||||
# canary on a source tree normalizes to main (not stable).
|
||||
return transient == CHANNEL_STABLE
|
||||
config = None
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.update_channel import CHANNEL_STABLE, resolve_update_channel
|
||||
|
||||
config = None
|
||||
try:
|
||||
config = load_config()
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load config for channel resolution: %s", exc)
|
||||
return resolve_update_channel(config, _m().PROJECT_ROOT) == CHANNEL_STABLE
|
||||
config = load_config()
|
||||
except Exception as exc:
|
||||
logger.warning("Channel resolution failed; defaulting to main: %s", exc)
|
||||
return False
|
||||
logger.debug("Could not load config for channel resolution: %s", exc)
|
||||
return resolve_update_channel(config, _m().PROJECT_ROOT)
|
||||
|
||||
|
||||
def _stable_channel_active(args) -> bool:
|
||||
"""Historical stable-only predicate; canary is a separate release channel."""
|
||||
from hermes_cli.update_channel import CHANNEL_STABLE
|
||||
|
||||
return _source_update_channel(args) == CHANNEL_STABLE
|
||||
|
||||
|
||||
def _github_latest_release():
|
||||
"""Resolve the official release tag and commit without local Git state."""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote
|
||||
"""Resolve the same stable release when local Git is unavailable."""
|
||||
from hermes_cli.source_releases import resolve_source_release
|
||||
|
||||
def _get_json(url):
|
||||
req = urllib.request.Request(
|
||||
url, headers={"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "hermes-update"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
base = "https://api.github.com/repos/NousResearch/hermes-agent"
|
||||
try:
|
||||
data = _get_json(f"{base}/releases/latest")
|
||||
tag = data.get("tag_name") if isinstance(data, dict) else None
|
||||
if isinstance(tag, str) and _parse_release_tag(tag) is not None:
|
||||
commit = _get_json(f"{base}/commits/{quote(tag, safe='')}")
|
||||
sha = commit.get("sha") if isinstance(commit, dict) else None
|
||||
return tag, sha if isinstance(sha, str) and re.fullmatch(r"[0-9a-f]{40}", sha) else None
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
logger.debug("GitHub /releases/latest unavailable: %s", exc)
|
||||
try:
|
||||
tags = _get_json(f"{base}/tags?per_page=100")
|
||||
best = None
|
||||
for entry in tags if isinstance(tags, list) else []:
|
||||
name = entry.get("name") if isinstance(entry, dict) else None
|
||||
version = _parse_release_tag(name) if isinstance(name, str) else None
|
||||
if version is not None and (best is None or version > best[0]):
|
||||
commit = entry.get("commit")
|
||||
sha = commit.get("sha") if isinstance(commit, dict) else None
|
||||
best = (version, name, sha)
|
||||
if best:
|
||||
sha = best[2]
|
||||
return best[1], sha if isinstance(sha, str) and re.fullmatch(r"[0-9a-f]{40}", sha) else None
|
||||
return None, None
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
logger.debug("GitHub /tags unavailable: %s", exc)
|
||||
return None, None
|
||||
return resolve_source_release("stable")
|
||||
|
||||
|
||||
def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
||||
def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False, channel=None):
|
||||
"""Implement ``hermes update --check``: fetch and report without installing.
|
||||
|
||||
``branch`` selects which branch the check compares against. Default is
|
||||
@@ -796,39 +737,23 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
||||
if swept:
|
||||
print(f" (removed {len(swept)} aborted-fetch pack temp file(s))")
|
||||
|
||||
# Stable channel: if the caller did not ask for a branch, the question is
|
||||
# "is there a newer tagged release?". The question is not "are there new
|
||||
# commits on main?". Compare against the newest release tag and return.
|
||||
if not branch_explicit:
|
||||
if _stable_channel_active(None):
|
||||
print("→ Update channel: stable (tagged releases)")
|
||||
tag, tag_sha = _resolve_latest_release_tag(git_cmd, _m().PROJECT_ROOT)
|
||||
if tag is None:
|
||||
print("✗ No release tags found on origin. A check of the stable channel is not possible.")
|
||||
print(" Switch channels with: hermes update --set-channel main")
|
||||
sys.exit(1)
|
||||
head_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||||
# Newer releases possibly do not exist locally yet. "At the tag"
|
||||
# is a SHA comparison. The merge-base check tells us whether HEAD
|
||||
# contains the tag (HEAD is ahead of the release or at the release).
|
||||
at_or_past_tag = False
|
||||
if head_sha and tag_sha:
|
||||
if head_sha == tag_sha:
|
||||
at_or_past_tag = True
|
||||
else:
|
||||
contained = subprocess.run(
|
||||
git_cmd + ["merge-base", "--is-ancestor", tag_sha, "HEAD"],
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
at_or_past_tag = contained.returncode == 0
|
||||
if at_or_past_tag:
|
||||
print(f"✓ Up to date with the latest release ({tag}).")
|
||||
else:
|
||||
print(f"→ New release available: {tag}")
|
||||
print(" Run `hermes update` to install it.")
|
||||
return
|
||||
from hermes_cli.update_channel import CHANNEL_STABLE
|
||||
|
||||
selected_channel = _source_update_channel(channel=channel, branch_explicit=branch_explicit)
|
||||
if selected_channel in (CHANNEL_STABLE, "canary"):
|
||||
from hermes_cli.source_releases import resolve_source_release
|
||||
|
||||
print(f"→ Update channel: {selected_channel} (published releases)")
|
||||
tag, tag_sha = resolve_source_release(selected_channel, git_cmd, _m().PROJECT_ROOT)
|
||||
if tag is None:
|
||||
print(f"✗ Could not resolve the {selected_channel} release commit.")
|
||||
sys.exit(1)
|
||||
if _capture_head_sha(git_cmd, _m().PROJECT_ROOT) == tag_sha:
|
||||
print(f"✓ Up to date with the latest release ({tag}).")
|
||||
else:
|
||||
print(f"→ Selected release available: {tag}")
|
||||
print(" Run `hermes update` to install it.")
|
||||
return
|
||||
|
||||
# Fetch only the branch we compare against; prefer upstream as the canonical
|
||||
# reference. A bare `git fetch <remote>` pulls every ref, and this repo has
|
||||
@@ -1186,7 +1111,13 @@ def _pull_updates(
|
||||
# merge --ff-only the already-fetched ref instead of `git pull`, which would do a
|
||||
# SECOND network fetch; identical in effect given the fresh tracking ref.
|
||||
merge_ref = target_ref if target_ref is not None else f"origin/{branch}"
|
||||
if _git_run(git_cmd, ["merge", "--ff-only", merge_ref]).returncode != 0:
|
||||
if merge_ref != f"origin/{branch}":
|
||||
# Keep detached local commits reachable, too. Named branches are
|
||||
# untouched by checkout --detach; an autostash protects dirty files.
|
||||
if pre_pull_sha and not _git_run(git_cmd, ["branch", "--show-current"]).stdout.strip():
|
||||
_git_run(git_cmd, ["update-ref", f"refs/hermes/pre-release/{pre_pull_sha}", pre_pull_sha], check=True)
|
||||
_git_run(git_cmd, ["checkout", "--detach", merge_ref], check=True)
|
||||
elif _git_run(git_cmd, ["merge", "--ff-only", merge_ref]).returncode != 0:
|
||||
_reconcile_diverged_checkout(git_cmd, branch, pre_pull_sha, target_ref=merge_ref)
|
||||
_rollback_if_pulled_syntax_error(git_cmd, pre_pull_sha)
|
||||
update_succeeded = True
|
||||
@@ -1274,22 +1205,21 @@ def _prepare_checkout_for_update(
|
||||
date, -1 when tips differ but the shallow count is unrecoverable."""
|
||||
if target_ref is None:
|
||||
target_ref = f"origin/{branch}"
|
||||
stable_tag = target_ref != f"origin/{branch}"
|
||||
if stable_tag:
|
||||
# Stable channel: the checkout does NOT switch branches — the current branch
|
||||
# pointer fast-forwards (or merges) to the release tag, so the parked-branch
|
||||
# machinery is main-channel only.
|
||||
release_tag = target_ref != f"origin/{branch}"
|
||||
if release_tag:
|
||||
# A release lands detached at its exact commit, never merges into or
|
||||
# rewrites the user's branch. Branch-policy machinery is main-only.
|
||||
parked_branch_switched, in_place_update, switch_block_reason = False, True, None
|
||||
else:
|
||||
parked_branch_switched, in_place_update, switch_block_reason = _apply_parked_branch_guard(
|
||||
git_cmd, branch, current_branch, switch_branch=switch_branch,
|
||||
_windows_gateway_resume=_windows_gateway_resume)
|
||||
|
||||
if not stable_tag and not in_place_update and current_branch == "HEAD" != branch:
|
||||
if not release_tag and not in_place_update and current_branch == "HEAD" != branch:
|
||||
print(f" ⚠ Currently on detached HEAD — switching to {branch} for update...")
|
||||
auto_stash_ref = _m()._stash_local_changes_if_needed(git_cmd, _m().PROJECT_ROOT)
|
||||
if (
|
||||
not stable_tag and not in_place_update and current_branch != branch
|
||||
not release_tag and not in_place_update and current_branch != branch
|
||||
and _git_run(git_cmd, ["checkout", branch]).returncode != 0):
|
||||
track_result = _git_run(git_cmd, ["checkout", "-B", branch, f"origin/{branch}"])
|
||||
if track_result.returncode != 0:
|
||||
@@ -1307,6 +1237,14 @@ def _prepare_checkout_for_update(
|
||||
and not assume_yes
|
||||
and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())))
|
||||
|
||||
if release_tag:
|
||||
# An ancestor release still needs applying when switching channels.
|
||||
head_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||||
return _CheckoutPlan(
|
||||
auto_stash_ref=auto_stash_ref, commit_count=0 if head_sha == target_ref else -1,
|
||||
in_place_update=True, parked_branch_switched=False,
|
||||
prompt_for_restore=prompt_for_restore, switch_block_reason=None, upstream_checked=True)
|
||||
|
||||
# On shallow checkouts `rev-list --count` can report the entire remote ancestry. The
|
||||
# zero/nonzero gate is still sound; treat the shallow NUMBER as unknown and recover it
|
||||
# via the GitHub compare API when possible.
|
||||
@@ -1330,7 +1268,7 @@ def _prepare_checkout_for_update(
|
||||
# "Already up to date!" and verified nothing). Non-fork checkouts have no upstream question: origin IS
|
||||
# the official repo, so "Already up to date!" is fully verified there.
|
||||
upstream_checked = True
|
||||
if commit_count == 0 and is_fork and branch == "main" and not stable_tag:
|
||||
if commit_count == 0 and is_fork and branch == "main" and not release_tag:
|
||||
pre_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
|
||||
upstream_checked = _m()._sync_with_upstream_if_needed(
|
||||
git_cmd, _m().PROJECT_ROOT, assume_yes=assume_yes, input_fn=gw_input_fn)
|
||||
@@ -1527,7 +1465,7 @@ def _current_branch_name(git_cmd, *, check: bool = False) -> str:
|
||||
|
||||
def _handle_update_called_process_error(
|
||||
e, args, gateway_mode: bool, had_desktop_app_before_update: bool,
|
||||
*, target_sha: str | None = None) -> None:
|
||||
*, target_sha: str | None = None, target_repository: str | None = None) -> None:
|
||||
"""Git/installer failure: ZIP-fallback when safe, else report and ``sys.exit(1)``."""
|
||||
stage = _format_update_failure_stage(e)
|
||||
if _should_zip_fallback_on_update_error(e):
|
||||
@@ -1536,7 +1474,8 @@ def _handle_update_called_process_error(
|
||||
print()
|
||||
desktop_build_ok = _update_via_zip(
|
||||
args, had_desktop_app_before_update=had_desktop_app_before_update,
|
||||
target_sha=target_sha)
|
||||
target_sha=target_sha,
|
||||
**({"target_repository": target_repository} if target_repository else {}))
|
||||
if gateway_mode:
|
||||
_write_gateway_update_exit_code(desktop_build_ok)
|
||||
else:
|
||||
@@ -1701,25 +1640,38 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
||||
branch = _m()._resolve_update_branch(args)
|
||||
target_ref = f"origin/{branch}"
|
||||
stable_tag, stable_sha = None, None
|
||||
if _stable_channel_active(args):
|
||||
print("→ Update channel: stable (tagged releases)")
|
||||
stable_tag, stable_sha = (
|
||||
_github_latest_release() if use_zip_update
|
||||
else _resolve_latest_release_tag(git_cmd, _m().PROJECT_ROOT))
|
||||
if stable_tag is None or not re.fullmatch(r"[0-9a-f]{40}", stable_sha or ""):
|
||||
print("✗ Could not resolve the stable release commit. No update was applied.")
|
||||
release_tag, release_sha = None, None
|
||||
target_repository = None
|
||||
from hermes_cli.update_channel import CHANNEL_STABLE
|
||||
|
||||
selected_channel = _source_update_channel(args)
|
||||
if selected_channel in (CHANNEL_STABLE, "canary"):
|
||||
from hermes_cli.source_releases import resolve_source_release, source_repository
|
||||
|
||||
print(f"→ Update channel: {selected_channel} (published releases)")
|
||||
try:
|
||||
target_repository = source_repository(None if use_zip_update else git_cmd, _m().PROJECT_ROOT)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
print(f"✗ Could not identify the release repository: {exc}")
|
||||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
sys.exit(1)
|
||||
release_tag, release_sha = resolve_source_release(
|
||||
selected_channel, None if use_zip_update else git_cmd, _m().PROJECT_ROOT,
|
||||
repository=target_repository)
|
||||
if release_tag is None or not re.fullmatch(r"[0-9a-f]{40}", release_sha or ""):
|
||||
print(f"✗ Could not resolve the {selected_channel} release commit. No update was applied.")
|
||||
print(" Retry, or switch channels with: hermes update --set-channel main")
|
||||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
sys.exit(1)
|
||||
print(f"→ Latest release: {stable_tag}")
|
||||
target_ref = stable_sha
|
||||
print(f"→ Latest release: {release_tag}")
|
||||
target_ref = release_sha
|
||||
|
||||
if use_zip_update:
|
||||
try:
|
||||
desktop_build_ok = _update_via_zip(
|
||||
args, had_desktop_app_before_update=had_desktop_app_before_update,
|
||||
target_sha=stable_sha)
|
||||
target_sha=release_sha,
|
||||
**({"target_repository": target_repository} if target_repository else {}))
|
||||
finally:
|
||||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
if gateway_mode:
|
||||
@@ -1748,12 +1700,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_m()._warn_orphaned_update_autostashes(git_cmd, _m().PROJECT_ROOT)
|
||||
|
||||
print("→ Fetching updates...")
|
||||
if stable_tag:
|
||||
if release_tag:
|
||||
fetch_result = _git_run(git_cmd, ["fetch", "--no-tags", "origin", target_ref], network=True)
|
||||
if fetch_result.returncode != 0:
|
||||
# Older servers require a named ref. Do not change local tags.
|
||||
fetch_result = _git_run(
|
||||
git_cmd, ["fetch", "--no-tags", "origin", f"refs/tags/{stable_tag}"], network=True)
|
||||
git_cmd, ["fetch", "--no-tags", "origin", f"refs/tags/{release_tag}"], network=True)
|
||||
if fetch_result.returncode == 0:
|
||||
fetched = _git_run(git_cmd, ["rev-parse", "--verify", "FETCH_HEAD^{commit}"])
|
||||
if fetched.returncode != 0 or fetched.stdout.strip() != target_ref:
|
||||
@@ -1784,7 +1736,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_windows_gateway_resume=_windows_gateway_resume)
|
||||
return
|
||||
|
||||
if commit_count > 0:
|
||||
if release_tag:
|
||||
print(f"→ Switching to release {release_tag} ({release_sha[:10]})")
|
||||
elif commit_count > 0:
|
||||
print(f"→ Found {commit_count} new commit(s)")
|
||||
else:
|
||||
# Shallow, exact count unrecoverable — but the tips differ, so there IS an update.
|
||||
@@ -1797,14 +1751,15 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
keep_stash=opts.keep_stash, target_ref=target_ref)
|
||||
_apply_pulled_update(
|
||||
git_cmd, branch, pre_pull_sha, _plan, opts, gateway_mode=gateway_mode,
|
||||
is_fork=is_fork and not stable_tag, desktop_dir=desktop_dir,
|
||||
is_fork=is_fork and not release_tag, desktop_dir=desktop_dir,
|
||||
had_desktop_app_before_update=had_desktop_app_before_update,
|
||||
pre_update_snapshot_id=pre_update_snapshot_id, _pre_update_plan=_pre_update_plan,
|
||||
_windows_gateway_resume=_windows_gateway_resume)
|
||||
except subprocess.CalledProcessError as e:
|
||||
try:
|
||||
_handle_update_called_process_error(
|
||||
e, args, gateway_mode, had_desktop_app_before_update, target_sha=stable_sha)
|
||||
e, args, gateway_mode, had_desktop_app_before_update, target_sha=release_sha,
|
||||
target_repository=target_repository)
|
||||
finally:
|
||||
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
|
||||
|
||||
@@ -339,7 +339,7 @@ def _reinstall_python_deps_after_zip() -> None:
|
||||
|
||||
|
||||
def _update_via_zip(args, *, had_desktop_app_before_update: bool = False,
|
||||
target_sha: str | None = None) -> bool:
|
||||
target_sha: str | None = None, target_repository: str | None = None) -> bool:
|
||||
"""Update via ZIP archive; used on Windows when git file I/O is broken (antivirus / NTFS filter
|
||||
drivers causing 'Invalid argument'). Returns ``False`` when a Desktop rebuild ran and failed.
|
||||
|
||||
@@ -376,7 +376,11 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False,
|
||||
if target_sha is not None and not re.fullmatch(r"[0-9a-f]{40}", target_sha):
|
||||
raise ValueError("ZIP update requires an exact full commit SHA")
|
||||
ref = target_sha if target_sha is not None else f"refs/heads/{branch}"
|
||||
_download_and_swap_zip(branch, f"https://github.com/NousResearch/hermes-agent/archive/{ref}.zip")
|
||||
repository = target_repository or "NousResearch/hermes-agent"
|
||||
if (not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository)
|
||||
or any(part in (".", "..") for part in repository.split("/"))):
|
||||
raise ValueError("ZIP update requires a GitHub owner/repository")
|
||||
_download_and_swap_zip(branch, f"https://github.com/{repository}/archive/{ref}.zip")
|
||||
_sweep_bytecode_after_update(branch)
|
||||
print("→ Updating Python dependencies...")
|
||||
_reinstall_python_deps_after_zip()
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
"hermes_cli.runtime_state::_lock",
|
||||
"hermes_cli.runtime_state::collect_generations",
|
||||
"hermes_cli.sizefmt::format_bytes",
|
||||
"hermes_cli.source_releases::resolve_source_release",
|
||||
"hermes_cli.source_releases::source_repository",
|
||||
"hermes_cli.sqlite_runtime::probe_sqlite_runtime",
|
||||
"hermes_cli.sqlite_safe_read::LiveConnectionError",
|
||||
"hermes_cli.sqlite_safe_read::offline_file_access",
|
||||
@@ -119,6 +121,7 @@
|
||||
"hermes_cli.steward::read_install_stamp",
|
||||
"hermes_cli.tools_config::install_cua_driver",
|
||||
"hermes_cli.update_channel::CHANNEL_STABLE",
|
||||
"hermes_cli.update_channel::resolve_update_channel",
|
||||
"hermes_cli.update_cmd::_UPDATE_CRITICAL_MODULES",
|
||||
"hermes_cli.update_cmd::_abort_recovery_is_complete",
|
||||
"hermes_cli.update_cmd::_add_upstream_remote",
|
||||
@@ -261,7 +264,6 @@
|
||||
"hermes_cli.process_identity::REAPABLE_PURPOSES",
|
||||
"hermes_cli.runtime_state::recover_publication",
|
||||
"hermes_cli.runtime_state::runtime_lock",
|
||||
"hermes_cli.update_channel::resolve_update_channel",
|
||||
"hermes_cli.version_info::get_code_identity",
|
||||
"hermes_state::SessionDB",
|
||||
"pm.ensure::enabled_extras",
|
||||
|
||||
233
tests/hermes_cli/test_source_release_channels.py
Normal file
233
tests/hermes_cli/test_source_release_channels.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Release-channel checks and updates against actual repositories and HTTP feeds."""
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from threading import Thread
|
||||
from types import SimpleNamespace
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import main, update_cmd
|
||||
from hermes_cli.update_channel import set_install_channel
|
||||
|
||||
|
||||
def git(root, *args):
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=root, check=True, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def releases(tmp_path, monkeypatch):
|
||||
origin = tmp_path / "origin"
|
||||
origin.mkdir()
|
||||
git(origin, "init", "-b", "main")
|
||||
git(origin, "config", "user.name", "Release Fixture")
|
||||
git(origin, "config", "user.email", "fixture@example.invalid")
|
||||
git(origin, "config", "commit.gpgsign", "false")
|
||||
git(origin, "config", "tag.gpgsign", "false")
|
||||
commits = []
|
||||
for label in ("old", "stable", "canary", "unpublished"):
|
||||
(origin / "content.txt").write_text(label, encoding="utf-8")
|
||||
git(origin, "add", ".")
|
||||
git(origin, "commit", "-m", label)
|
||||
commits.append(git(origin, "rev-parse", "HEAD"))
|
||||
tags = {"stable": "v1.2.3", "canary": "v1.2.4-canary.20260911125822"}
|
||||
git(origin, "tag", "-a", tags["stable"], commits[1], "-m", "stable")
|
||||
git(origin, "tag", "-a", tags["canary"], commits[2], "-m", "canary")
|
||||
git(origin, "tag", "v99.0.0", commits[3])
|
||||
git(origin, "tag", "v99.0.1-canary.20260912125822", commits[3])
|
||||
checkout = tmp_path / "checkout"
|
||||
git(tmp_path, "clone", str(origin), str(checkout))
|
||||
git(checkout, "config", "user.name", "Release Fixture")
|
||||
git(checkout, "config", "user.email", "fixture@example.invalid")
|
||||
git(checkout, "config", "commit.gpgsign", "false")
|
||||
git(checkout, "checkout", "--detach", commits[0])
|
||||
monkeypatch.setattr(main, "PROJECT_ROOT", checkout)
|
||||
monkeypatch.setenv("HERMES_INSTALL_ROOT", str(checkout))
|
||||
monkeypatch.delenv("HERMES_MANAGED", raising=False)
|
||||
|
||||
responses = {}
|
||||
requests = []
|
||||
for channel, tag in tags.items():
|
||||
responses[f"/releases/{channel}/index.html"] = (
|
||||
f'<meta name="hermes-build" content="{tag}">'
|
||||
)
|
||||
responses[f"/repos/NousResearch/hermes-agent/releases/tags/{tag}"] = {
|
||||
"tag_name": tag, "draft": False, "prerelease": channel == "canary",
|
||||
}
|
||||
responses[f"/repos/NousResearch/hermes-agent/commits/{tag}"] = {
|
||||
"sha": commits[1 if channel == "stable" else 2],
|
||||
}
|
||||
responses["/releases/stable/release-candidates.json"] = {
|
||||
"tag": tags["stable"], "commit": commits[1],
|
||||
}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
requests.append(self.path)
|
||||
data = responses.get(self.path)
|
||||
self.send_response(404 if data is None else 200)
|
||||
self.end_headers()
|
||||
if data is not None:
|
||||
self.wfile.write((data if isinstance(data, str) else json.dumps(data)).encode())
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
open_url = urllib.request.urlopen
|
||||
|
||||
def local_urlopen(request, *args, **kwargs):
|
||||
url = request.full_url if isinstance(request, urllib.request.Request) else request
|
||||
parsed = urlsplit(url)
|
||||
return open_url(f"http://127.0.0.1:{server.server_port}{parsed.path}"
|
||||
+ (f"?{parsed.query}" if parsed.query else ""), *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", local_urlopen)
|
||||
yield SimpleNamespace(root=checkout, origin=origin, commits=commits,
|
||||
tags=tags, responses=responses, requests=requests)
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_stable_resolution_uses_promoted_pointer_not_highest_tag(releases):
|
||||
assert update_cmd._resolve_latest_release_tag(["git"], releases.root) == (
|
||||
releases.tags["stable"], releases.commits[1],
|
||||
)
|
||||
assert releases.requests
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", ["stable", "canary"])
|
||||
@pytest.mark.parametrize("start", ["old", "ahead", "local"])
|
||||
def test_source_check_and_apply_land_on_selected_release(releases, monkeypatch, capsys, channel, start):
|
||||
if start != "old":
|
||||
git(releases.root, "checkout", "-b", "my-work", releases.commits[3])
|
||||
if start == "local":
|
||||
(releases.root / "my-work.txt").write_text("committed local work\n")
|
||||
git(releases.root, "add", ".")
|
||||
git(releases.root, "commit", "-m", "local work")
|
||||
(releases.root / "notes.txt").write_text("uncommitted notes\n")
|
||||
branch_sha = git(releases.root, "rev-parse", "HEAD")
|
||||
set_install_channel(channel, releases.root)
|
||||
before = git(releases.root, "rev-parse", "HEAD")
|
||||
update_cmd._cmd_update_check()
|
||||
assert releases.tags[channel] in capsys.readouterr().out
|
||||
assert git(releases.root, "rev-parse", "HEAD") == before
|
||||
|
||||
# Exercise the real selection/fetch/checkout path, not dependency installation
|
||||
# or live service management. No host OS is simulated.
|
||||
opts = update_cmd._UpdateOptions(
|
||||
active_lazy_features=[], pre_update_version=None, gw_input_fn=None,
|
||||
assume_yes=True, keep_stash=False, switch_branch=False, discard_local_changes=False,
|
||||
)
|
||||
monkeypatch.setattr(update_cmd, "_resolve_update_options", lambda *_: opts)
|
||||
monkeypatch.setattr(update_cmd, "_begin_update_receipt_and_plan", lambda *_: None)
|
||||
monkeypatch.setattr(main, "_run_pre_update_backup", lambda *_: None)
|
||||
monkeypatch.setattr(main, "_pause_windows_gateways_for_update", lambda: [])
|
||||
monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (False, ["git"], False))
|
||||
applied = []
|
||||
monkeypatch.setattr(update_cmd, "_apply_pulled_update", lambda *a, **k: applied.append(git(releases.root, "rev-parse", "HEAD")))
|
||||
args = SimpleNamespace(branch=None, channel=None, force_venv=True)
|
||||
update_cmd._cmd_update_impl(args, False)
|
||||
expected = releases.commits[1 if channel == "stable" else 2]
|
||||
assert applied == [expected]
|
||||
assert git(releases.root, "rev-parse", "HEAD") == expected
|
||||
if start != "old":
|
||||
assert git(releases.root, "rev-parse", "my-work") == branch_sha
|
||||
if start == "local":
|
||||
assert (releases.root / "notes.txt").read_text() == "uncommitted notes\n"
|
||||
update_cmd._cmd_update_check()
|
||||
assert "Up to date with" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_source_check_honors_transient_channel_without_rewriting_record(releases, capsys):
|
||||
set_install_channel("stable", releases.root)
|
||||
args = SimpleNamespace(branch=None, channel="canary", check=True)
|
||||
main.cmd_update(args)
|
||||
assert releases.tags["canary"] in capsys.readouterr().out
|
||||
update_cmd._cmd_update_check()
|
||||
assert releases.tags["stable"] in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_fork_origin_uses_its_own_published_release_not_the_official_pointer(releases):
|
||||
from hermes_cli.source_releases import resolve_source_release
|
||||
|
||||
url = "https://github.com/Fixture/hermes-agent.git"
|
||||
git(releases.root, "config", "remote.origin.url", url)
|
||||
git(releases.root, "config", f"url.{releases.origin}.insteadOf", url)
|
||||
tag = releases.tags["stable"]
|
||||
git(releases.origin, "tag", "-f", tag, releases.commits[3])
|
||||
releases.responses["/repos/Fixture/hermes-agent/releases/latest"] = {
|
||||
"tag_name": tag, "draft": False, "prerelease": False,
|
||||
}
|
||||
releases.responses[f"/repos/Fixture/hermes-agent/commits/{tag}"] = {"sha": releases.commits[3]}
|
||||
assert resolve_source_release("stable", ["git"], releases.root) == (tag, releases.commits[3])
|
||||
assert not any(path.startswith("/releases/") for path in releases.requests)
|
||||
|
||||
|
||||
def test_zip_fallback_keeps_selected_repository_and_commit(releases, monkeypatch):
|
||||
from hermes_cli import update_cmd_zip
|
||||
|
||||
seen = []
|
||||
monkeypatch.setattr(update_cmd_zip, "_abort_zip_update_if_dirty_tree", lambda: None)
|
||||
class DownloadBoundary(Exception):
|
||||
pass
|
||||
def download(branch, url):
|
||||
seen.append(url)
|
||||
raise DownloadBoundary
|
||||
monkeypatch.setattr(update_cmd_zip, "_download_and_swap_zip", download)
|
||||
with pytest.raises(DownloadBoundary):
|
||||
update_cmd_zip._update_via_zip(
|
||||
SimpleNamespace(branch=None), target_sha=releases.commits[2],
|
||||
target_repository="Fixture/hermes-agent")
|
||||
assert seen == [f"https://github.com/Fixture/hermes-agent/archive/{releases.commits[2]}.zip"]
|
||||
|
||||
|
||||
def test_selected_draft_never_falls_back_to_other_tags(releases):
|
||||
releases.responses[f"/repos/NousResearch/hermes-agent/releases/tags/{releases.tags['stable']}"]["draft"] = True
|
||||
assert update_cmd._resolve_latest_release_tag(["git"], releases.root) == (None, None)
|
||||
assert not any("/tags?" in path for path in releases.requests)
|
||||
|
||||
|
||||
def test_main_check_still_uses_branch_without_release_requests(releases, capsys):
|
||||
set_install_channel("main", releases.root)
|
||||
update_cmd._cmd_update_check()
|
||||
assert "behind origin/main" in capsys.readouterr().out
|
||||
assert releases.requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", ["stable", "canary"])
|
||||
def test_origin_tag_cannot_substitute_a_fork_commit(releases, channel):
|
||||
from hermes_cli.source_releases import resolve_source_release
|
||||
|
||||
git(releases.origin, "tag", "-f", releases.tags[channel], releases.commits[3])
|
||||
assert resolve_source_release(channel, ["git"], releases.root) == (None, None)
|
||||
# Without git, the same selection remains pinned to the official commit.
|
||||
assert resolve_source_release(channel) == (
|
||||
releases.tags[channel], releases.commits[1 if channel == "stable" else 2],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", ["stable", "canary"])
|
||||
def test_missing_pointers_fall_back_only_to_published_releases(releases, channel):
|
||||
from hermes_cli.source_releases import resolve_source_release
|
||||
|
||||
releases.responses.pop("/releases/stable/release-candidates.json")
|
||||
releases.responses.pop(f"/releases/{channel}/index.html")
|
||||
published = releases.responses[f"/repos/NousResearch/hermes-agent/releases/tags/{releases.tags[channel]}"]
|
||||
releases.responses["/repos/NousResearch/hermes-agent/releases/latest"] = published
|
||||
releases.responses["/repos/NousResearch/hermes-agent/releases?per_page=100&page=1"] = [
|
||||
{"tag_name": "v99.0.1-canary.20260912125822", "draft": True, "prerelease": True},
|
||||
published,
|
||||
]
|
||||
assert resolve_source_release(channel, ["git"], releases.root) == (
|
||||
releases.tags[channel], releases.commits[1 if channel == "stable" else 2],
|
||||
)
|
||||
assert not any("/tags?" in path for path in releases.requests)
|
||||
assert f"/repos/NousResearch/hermes-agent/releases/tags/{releases.tags[channel]}" not in releases.requests
|
||||
@@ -117,11 +117,11 @@ class TestResolve:
|
||||
_stamp(root, "electron-updater", tag="v0.28.0-canary.20260818")
|
||||
assert default_channel(root) == CHANNEL_CANARY
|
||||
|
||||
def test_explicit_record_still_overrides_the_artifact_default(self, tmp_path):
|
||||
"""The tag only supplies the DEFAULT: an opt-out must still work."""
|
||||
def test_artifact_channel_ignores_obsolete_record(self, tmp_path):
|
||||
"""A record cannot change a separately installed package identity."""
|
||||
root = tmp_path / "canary-bundle"
|
||||
_stamp(root, "electron-updater", tag="v0.28.0-canary.20260819171926")
|
||||
assert resolve_update_channel(_config_for(root, "stable"), root) == CHANNEL_STABLE
|
||||
assert resolve_update_channel(_config_for(root, "stable"), root) == CHANNEL_CANARY
|
||||
|
||||
def test_a_canary_tag_on_a_source_install_is_not_a_canary_channel(self, tmp_path):
|
||||
"""Only electron-updater bundles have release feeds to track."""
|
||||
@@ -134,17 +134,28 @@ class TestResolve:
|
||||
root.mkdir()
|
||||
assert resolve_update_channel({}, root) == CHANNEL_MAIN
|
||||
|
||||
def test_canary_normalizes_to_main_for_source(self, tmp_path):
|
||||
def test_canary_remains_a_release_channel_for_source(self, tmp_path):
|
||||
root = tmp_path / "src"
|
||||
_stamp(root, "self")
|
||||
config = _config_for(root, "canary")
|
||||
assert resolve_update_channel(config, root) == CHANNEL_MAIN
|
||||
assert resolve_update_channel(config, root) == CHANNEL_CANARY
|
||||
|
||||
def test_canary_stays_for_electron_updater(self, tmp_path):
|
||||
def test_stable_bundle_ignores_canary_record(self, tmp_path):
|
||||
root = tmp_path / "bundle"
|
||||
_stamp(root, "electron-updater")
|
||||
config = _config_for(root, "canary")
|
||||
assert resolve_update_channel(config, root) == CHANNEL_CANARY
|
||||
assert resolve_update_channel(config, root) == CHANNEL_STABLE
|
||||
|
||||
@pytest.mark.parametrize("payload", ["bundled", "light"])
|
||||
@pytest.mark.parametrize("channel, tag", [
|
||||
("stable", "v1.2.3"), ("canary", "v1.2.4-canary.20260911125822"),
|
||||
])
|
||||
def test_external_packages_ignore_source_channel_records(self, tmp_path, payload, channel, tag):
|
||||
(tmp_path / "install-stamp.json").write_text(json.dumps({
|
||||
"payload": payload, "updateMechanism": "external", "tag": tag,
|
||||
}))
|
||||
assert resolve_update_channel(_config_for(tmp_path, "main"), tmp_path) == channel
|
||||
assert default_channel(tmp_path) == channel
|
||||
|
||||
def test_garbage_record_falls_to_default(self, tmp_path):
|
||||
root = tmp_path / "src"
|
||||
@@ -160,21 +171,22 @@ class TestSetChannel:
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
|
||||
def test_set_resolve_round_trip(self, tmp_path, monkeypatch):
|
||||
@pytest.mark.parametrize("channel", ["stable", "canary", "main"])
|
||||
def test_set_resolve_round_trip(self, tmp_path, monkeypatch, channel):
|
||||
import hermes_yaml as yaml
|
||||
|
||||
home = self._home(tmp_path, monkeypatch)
|
||||
root = tmp_path / "install"
|
||||
_stamp(root, "self")
|
||||
|
||||
sha16 = set_install_channel("stable", root)
|
||||
sha16 = set_install_channel(channel, root)
|
||||
assert sha16 == install_id(root)
|
||||
|
||||
written = yaml.safe_load((home / "config.yaml").read_text())
|
||||
record = written["update"]["installs"][sha16]
|
||||
assert record["channel"] == "stable"
|
||||
assert record["channel"] == channel
|
||||
assert record["path"] == str(root)
|
||||
assert resolve_update_channel(written, root) == CHANNEL_STABLE
|
||||
assert resolve_update_channel(written, root) == channel
|
||||
|
||||
def test_preserves_other_config_and_other_installs(self, tmp_path, monkeypatch):
|
||||
import hermes_yaml as yaml
|
||||
@@ -226,7 +238,7 @@ class TestSetChannel:
|
||||
assert written["model"] == {"provider": "nous"}
|
||||
assert written["update"]["installs"][install_id(root)]["channel"] == "stable"
|
||||
|
||||
@pytest.mark.parametrize("mechanism", ["external", "app-installer", "microsoft-store"])
|
||||
@pytest.mark.parametrize("mechanism", ["external", "electron-updater", "app-installer", "microsoft-store"])
|
||||
def test_os_owned_mechanism_refuses_channel_writes(self, tmp_path, monkeypatch, mechanism):
|
||||
self._home(tmp_path, monkeypatch)
|
||||
root = tmp_path / "os-owned-tree"
|
||||
@@ -234,6 +246,23 @@ class TestSetChannel:
|
||||
with pytest.raises(ValueError, match="owned by"):
|
||||
set_install_channel("stable", root)
|
||||
|
||||
@pytest.mark.parametrize("channel", ["main", "stable", "canary"])
|
||||
def test_commit_build_channel_refusal_keeps_existing_config(self, tmp_path, monkeypatch, channel):
|
||||
home = self._home(tmp_path, monkeypatch)
|
||||
config = home / "config.yaml"
|
||||
config.write_text("# preserve\nupdate: {}\n")
|
||||
before = config.read_bytes()
|
||||
root = tmp_path / "commit-build"
|
||||
root.mkdir()
|
||||
(root / "install-stamp.json").write_text(json.dumps({
|
||||
"source": "commit-build", "updateMechanism": "external",
|
||||
}))
|
||||
message = "This build doesn't get updates. Ask the developer who gave it to you for a new build."
|
||||
with pytest.raises(ValueError) as error:
|
||||
set_install_channel(channel, root)
|
||||
assert str(error.value) == message
|
||||
assert config.read_bytes() == before
|
||||
|
||||
def test_bad_channel_refuses(self, tmp_path, monkeypatch):
|
||||
self._home(tmp_path, monkeypatch)
|
||||
root = tmp_path / "install"
|
||||
@@ -393,31 +422,18 @@ class TestSetChannelCLI:
|
||||
pytest.fail("metadata command entered the real updater")
|
||||
monkeypatch.setattr(update_cmd, "_cmd_update_impl", unexpected_update)
|
||||
|
||||
def test_stable_switch_from_canary_is_an_honest_wait(self, capsys):
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_stable_switch_warns_about_state_without_requiring_a_newer_release(self, tmp_path, monkeypatch, capsys):
|
||||
from hermes_cli.main import cmd_update
|
||||
|
||||
with (
|
||||
patch("hermes_cli.config.is_managed", return_value=False),
|
||||
patch("hermes_cli.config.detect_install_method", return_value="unknown"),
|
||||
patch("hermes_cli.update_channel.set_install_channel", return_value="a" * 16),
|
||||
patch(
|
||||
"hermes_cli.steward.read_install_stamp",
|
||||
return_value={
|
||||
"updateMechanism": "electron-updater",
|
||||
"displayVersion": "0.28.0-canary.20260818",
|
||||
},
|
||||
),
|
||||
):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cmd_update(self._args(set_channel="stable"))
|
||||
self._home(tmp_path, monkeypatch)
|
||||
_stamp(tmp_path, "self", "v0.28.0-canary.20260818")
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cmd_update(self._args(set_channel="stable"))
|
||||
assert exc.value.code == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "0.28.0-canary.20260818" in out # names where you are
|
||||
assert "v0.28.0" in out # names the wait target
|
||||
assert "hermes-agent.nousresearch.com" in out # the impatient path
|
||||
|
||||
assert "Back up your data" in out
|
||||
assert "older stable release" in out
|
||||
assert "Wait" not in out
|
||||
def test_canary_optin_warns_about_forward_incompatible_state(self, capsys):
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ class TestStableChannelActive:
|
||||
no config read happens when it is present."""
|
||||
assert _stable_channel_active(_Args(channel="stable")) is True
|
||||
assert _stable_channel_active(_Args(channel="main")) is False
|
||||
# canary on a source tree normalizes to main, never stable.
|
||||
# Canary is a distinct release channel, never stable.
|
||||
assert _stable_channel_active(_Args(channel="canary")) is False
|
||||
|
||||
def test_per_install_record_activates(self, tmp_path, monkeypatch):
|
||||
|
||||
@@ -85,7 +85,17 @@ def update_tree(tmp_path, monkeypatch):
|
||||
@pytest.mark.parametrize('server', ['sha', 'tag-fallback', 'moved-sha', 'moved-fallback',
|
||||
'at-release', 'ahead-release', 'explicit-branch'])
|
||||
def test_stable_git_uses_remote_identity_without_moving_local_tags(update_tree, monkeypatch, server):
|
||||
from hermes_cli import source_releases
|
||||
|
||||
t = update_tree
|
||||
responses = {
|
||||
'/releases/stable/release-candidates.json': {'tag': 'v1.1.0', 'commit': t.wanted},
|
||||
'/repos/NousResearch/hermes-agent/releases/tags/v1.1.0': {
|
||||
'tag_name': 'v1.1.0', 'draft': False, 'prerelease': False,
|
||||
},
|
||||
'/repos/NousResearch/hermes-agent/commits/v1.1.0': {'sha': t.wanted},
|
||||
}
|
||||
monkeypatch.setattr(source_releases, '_read', lambda url, **_: json.dumps(responses[urlsplit(url).path]))
|
||||
expected = t.wanted
|
||||
if server in {'at-release', 'ahead-release'}:
|
||||
git(t.clone, 'fetch', '--no-tags', 'origin', t.wanted)
|
||||
@@ -94,7 +104,7 @@ def test_stable_git_uses_remote_identity_without_moving_local_tags(update_tree,
|
||||
(t.clone / 'local.txt').write_text('local commit\n', encoding='utf-8')
|
||||
git(t.clone, 'add', 'local.txt')
|
||||
git(t.clone, '-c', 'commit.gpgsign=false', 'commit', '-qm', 'local')
|
||||
expected = git(t.clone, 'rev-parse', 'HEAD')
|
||||
expected = t.wanted
|
||||
if server == 'explicit-branch':
|
||||
git(t.origin, 'branch', 'retained-branch', t.newer)
|
||||
t.args.branch = 'retained-branch'
|
||||
@@ -128,7 +138,7 @@ def test_stable_git_uses_remote_identity_without_moving_local_tags(update_tree,
|
||||
assert error.value.code == 1
|
||||
assert git(t.clone, 'rev-parse', 'HEAD') == t.base
|
||||
assert t.resumed
|
||||
elif server in {'at-release', 'ahead-release'}:
|
||||
elif server == 'at-release':
|
||||
cli_main.cmd_update(t.args)
|
||||
assert t.repaired == [True]
|
||||
assert git(t.clone, 'rev-parse', 'HEAD') == expected
|
||||
@@ -138,7 +148,7 @@ def test_stable_git_uses_remote_identity_without_moving_local_tags(update_tree,
|
||||
assert git(t.clone, 'rev-parse', 'HEAD') == expected
|
||||
content = 'unreleased-main\n' if server == 'explicit-branch' else 'release\n'
|
||||
assert (t.clone / 'content.txt').read_text(encoding='utf-8') == content
|
||||
assert git(t.clone, 'branch', '--show-current') == 'retained-branch'
|
||||
assert git(t.clone, 'branch', '--show-current') == ('retained-branch' if server == 'explicit-branch' else '')
|
||||
assert resolved == (server != 'explicit-branch')
|
||||
assert git(t.clone, 'rev-parse', 'v1.1.0') == t.base
|
||||
assert not git(t.clone, 'status', '--porcelain')
|
||||
@@ -146,21 +156,22 @@ def test_stable_git_uses_remote_identity_without_moving_local_tags(update_tree,
|
||||
|
||||
|
||||
@pytest.mark.platforms('windows')
|
||||
@pytest.mark.parametrize('transport', ['gitless', 'no-git', 'api-tags', 'missing-sha',
|
||||
@pytest.mark.parametrize('transport', ['gitless', 'no-git', 'missing-release', 'missing-sha',
|
||||
'git-error', 'moved-git-error', 'dirty'])
|
||||
def test_stable_zip_consumes_the_same_commit_through_the_real_swap(update_tree, monkeypatch, tmp_path, transport):
|
||||
t = update_tree
|
||||
archive = tmp_path / 'source.zip'
|
||||
git(t.origin, 'archive', '--format=zip', '--prefix=hermes-agent-source/', f'--output={archive}', t.wanted)
|
||||
archive_bytes = archive.read_bytes()
|
||||
latest = {'tag_name': 'v1.1.0'}
|
||||
latest = {'tag_name': 'v1.1.0', 'draft': False, 'prerelease': False}
|
||||
routes = {
|
||||
'/repos/NousResearch/hermes-agent/releases/latest': latest,
|
||||
'/repos/NousResearch/hermes-agent/releases/tags/v1.1.0': latest,
|
||||
'/repos/NousResearch/hermes-agent/commits/v1.1.0': {'sha': t.wanted},
|
||||
'/repos/NousResearch/hermes-agent/tags?per_page=100': [{'name': 'v1.1.0', 'commit': {'sha': t.wanted}}],
|
||||
f'/NousResearch/hermes-agent/archive/{t.wanted}.zip': archive_bytes,
|
||||
}
|
||||
if transport == 'api-tags':
|
||||
if transport == 'missing-release':
|
||||
routes.pop('/repos/NousResearch/hermes-agent/releases/latest')
|
||||
if transport == 'missing-sha':
|
||||
routes['/repos/NousResearch/hermes-agent/commits/v1.1.0'] = {'sha': 'not-a-commit'}
|
||||
@@ -191,7 +202,7 @@ def test_stable_zip_consumes_the_same_commit_through_the_real_swap(update_tree,
|
||||
def local_open(request, *args, **kwargs):
|
||||
url = request.full_url if isinstance(request, urllib.request.Request) else request
|
||||
parsed = urlsplit(url)
|
||||
assert parsed.scheme == 'https' and parsed.netloc in {'api.github.com', 'github.com'}, url
|
||||
assert parsed.scheme == 'https' and parsed.netloc in {'api.github.com', 'github.com', 'hermes-assets.nousresearch.com'}, url
|
||||
urls.append(url)
|
||||
local = f'http://127.0.0.1:{server.server_port}{parsed.path}'
|
||||
if parsed.query:
|
||||
@@ -219,7 +230,7 @@ def test_stable_zip_consumes_the_same_commit_through_the_real_swap(update_tree,
|
||||
fetched = True
|
||||
return result
|
||||
|
||||
if transport in {'gitless', 'no-git', 'api-tags', 'missing-sha'}:
|
||||
if transport in {'gitless', 'no-git', 'missing-release', 'missing-sha'}:
|
||||
(t.clone / '.git').rename(tmp_path / 'git-state')
|
||||
if transport == 'dirty':
|
||||
(t.clone / 'content.txt').write_text('local work\n', encoding='utf-8')
|
||||
@@ -227,7 +238,7 @@ def test_stable_zip_consumes_the_same_commit_through_the_real_swap(update_tree,
|
||||
monkeypatch.setattr(urllib.request, 'urlopen', local_open)
|
||||
monkeypatch.setattr(subprocess, 'run', guarded_run)
|
||||
try:
|
||||
if transport in {'missing-sha', 'dirty'}:
|
||||
if transport in {'missing-sha', 'missing-release', 'dirty'}:
|
||||
with pytest.raises(SystemExit) as error:
|
||||
cli_main.cmd_update(t.args)
|
||||
assert error.value.code == 1
|
||||
@@ -243,7 +254,7 @@ def test_stable_zip_consumes_the_same_commit_through_the_real_swap(update_tree,
|
||||
if transport in {'git-error', 'moved-git-error', 'dirty'}:
|
||||
assert failed and fetched
|
||||
assert len([cmd for cmd in git_calls if 'ls-remote' in cmd]) == 1
|
||||
assert not any('api.github.com' in url for url in urls)
|
||||
assert sum('/commits/' in url for url in urls) == 1
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=5)
|
||||
|
||||
Reference in New Issue
Block a user