fix: track published source release channels at exact commits

This commit is contained in:
ethernet
2026-09-11 19:52:07 -04:00
parent 66b5cf155d
commit d207b0607d
11 changed files with 614 additions and 242 deletions

View File

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

View 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

View File

@@ -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)

View File

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

View File

@@ -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)

View File

@@ -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()

View File

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

View 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

View File

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

View File

@@ -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):

View File

@@ -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)