"""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.source_releases import resolve_source_release 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(params=["utf-8", "utf-8-sig"]) def releases(tmp_path, monkeypatch, request): 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.3+canary.20260911T125822Z"} 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.20260912T125822Z", 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(request.param)) 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) # Legacy pointer tests below retain their HTTP/tag boundary. New CLI callers # consume the protocol reader, whose complete schema is tested independently. from hermes_cli import source_releases def resolve_channel(name, repository): record = {"name": name, "repository": repository, "state": "active", "policy": "source-branch" if name == "main" else "preview"} manifest = None if name == "main": record["delivery"] = {"kind": "source-branch", "branch": "main"} else: manifest = {"request": {"commit": commits[1 if name == "stable" else 2], "sourceVersion": tags[name].removeprefix("v"), "buildId": "legacy-fixture"}} return SimpleNamespace(requested=record, terminal=record, manifest=manifest) monkeypatch.setattr(source_releases, "_resolve_channel", resolve_channel) yield SimpleNamespace(root=checkout, origin=origin, commits=commits, tags=tags, responses=responses, requests=requests) server.shutdown() server.server_close() thread.join() @pytest.mark.parametrize("git_cmd", [["git"], None], ids=["git", "no-git"]) def test_stable_resolution_uses_promoted_pointer_not_highest_tag(releases, git_cmd): assert resolve_source_release("stable", git_cmd, 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( 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: None) monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda **_: (False, ["git"], False)) applied = [] monkeypatch.setattr(update_cmd, "_complete_source_update", lambda request: applied.append(request)) 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 len(applied) == 1 assert applied[0]["expected_sha"] == expected assert applied[0]["source"] == str(releases.root.resolve()) 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): 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", completion_request={}) assert seen == [f"https://github.com/Fixture/hermes-agent/archive/{releases.commits[2]}.zip"] @pytest.mark.parametrize("git_cmd", [["git"], None], ids=["git", "no-git"]) def test_selected_draft_never_falls_back_to_other_tags(releases, git_cmd): releases.responses[f"/repos/NousResearch/hermes-agent/releases/tags/{releases.tags['stable']}"]["draft"] = True assert resolve_source_release("stable", git_cmd, 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): 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): 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.20260912T125822Z", "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