From d207b0607d5647b138aab92b6fb2b95b50594c80 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 11 Sep 2026 19:52:07 -0400 Subject: [PATCH] fix: track published source release channels at exact commits --- hermes_cli/main.py | 1 + hermes_cli/source_releases.py | 168 ++++++++++++ hermes_cli/subcommands/update.py | 12 +- hermes_cli/update_channel.py | 66 ++--- hermes_cli/update_cmd.py | 249 +++++++----------- hermes_cli/update_cmd_zip.py | 8 +- tests/compat/old_updater_surface.json | 4 +- .../test_source_release_channels.py | 233 ++++++++++++++++ tests/hermes_cli/test_update_channel.py | 82 +++--- .../hermes_cli/test_update_channel_stable.py | 2 +- .../hermes_cli/test_update_target_identity.py | 31 ++- 11 files changed, 614 insertions(+), 242 deletions(-) create mode 100644 hermes_cli/source_releases.py create mode 100644 tests/hermes_cli/test_source_release_channels.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f1a5dbd0a4..df7dadeb5d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/hermes_cli/source_releases.py b/hermes_cli/source_releases.py new file mode 100644 index 0000000000..cc937fcb19 --- /dev/null +++ b/hermes_cli/source_releases.py @@ -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 diff --git a/hermes_cli/subcommands/update.py b/hermes_cli/subcommands/update.py index 4d5e9c5602..ae1b431cdc 100644 --- a/hermes_cli/subcommands/update.py +++ b/hermes_cli/subcommands/update.py @@ -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) diff --git a/hermes_cli/update_channel.py b/hermes_cli/update_channel.py index eddeacf68b..9c50c03e82 100644 --- a/hermes_cli/update_channel.py +++ b/hermes_cli/update_channel.py @@ -21,10 +21,9 @@ same path, and the channel opt-in must survive that. * Written by ``hermes update --set-channel `` 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..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 diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 5bcae4fc67..7f55efb5d9 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -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 ` 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) diff --git a/hermes_cli/update_cmd_zip.py b/hermes_cli/update_cmd_zip.py index db014642b2..c0c46d04f0 100644 --- a/hermes_cli/update_cmd_zip.py +++ b/hermes_cli/update_cmd_zip.py @@ -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() diff --git a/tests/compat/old_updater_surface.json b/tests/compat/old_updater_surface.json index 7d7e09e795..5f905451bd 100644 --- a/tests/compat/old_updater_surface.json +++ b/tests/compat/old_updater_surface.json @@ -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", diff --git a/tests/hermes_cli/test_source_release_channels.py b/tests/hermes_cli/test_source_release_channels.py new file mode 100644 index 0000000000..9fe42f6339 --- /dev/null +++ b/tests/hermes_cli/test_source_release_channels.py @@ -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'' + ) + 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 diff --git a/tests/hermes_cli/test_update_channel.py b/tests/hermes_cli/test_update_channel.py index 37bf6f66c0..ea3edceb95 100644 --- a/tests/hermes_cli/test_update_channel.py +++ b/tests/hermes_cli/test_update_channel.py @@ -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 diff --git a/tests/hermes_cli/test_update_channel_stable.py b/tests/hermes_cli/test_update_channel_stable.py index d8730517a0..19ff7f92a9 100644 --- a/tests/hermes_cli/test_update_channel_stable.py +++ b/tests/hermes_cli/test_update_channel_stable.py @@ -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): diff --git a/tests/hermes_cli/test_update_target_identity.py b/tests/hermes_cli/test_update_target_identity.py index c36d9dd9ae..1574bd5084 100644 --- a/tests/hermes_cli/test_update_target_identity.py +++ b/tests/hermes_cli/test_update_target_identity.py @@ -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)