The takeover's new fleet-obligation arm imported `update_cmd_fleet`'s writer, which imports `update_cmd` -> `hermes_cli.config` -> ruamel. `prepare()` runs in the HISTORICAL interpreter before PM syncs the new dependencies, so every update from a release whose venv has no ruamel (the installer-script and e2e-upgrade core/handoff/pm legs) exited 1 with `Update preparation failed: No module named 'ruamel'`. The arm now writes the host obligation through the stdlib-only `update_host_obligation`, with the same per-home breadcrumb fallback. Also: - The syntax rollback's mixed reset un-tracked the files the broken commit ADDED, and `reset --hard` never touches untracked files, so they stayed in the tree (`?? hermes_cli/`). They are now removed by name after the reset. - A git that EXITED on another git's `index.lock` no longer has that lock deleted by the in-process settle: the lock is foreign by construction, the scan only reads, and a tree with writes to put back keeps its marker. - `_early_recovery`'s git runs carry `cwd` as well as `-C`. - `_early_recovery._git_executable` gets a reasoned resolution-allowlist row (stdlib-only launch repair; cannot import `hermes_platform`). Tests rewritten to the new contract: - test_source_release_channels zip fallback: the download stub takes the target SHA the ZIP swap now journals, and asserts it is passed through. - test_update_target_identity: the fault injection keys the upstream sync by its remote (`upstream`/`upstream/main`), since the sync now merges the fetched ref instead of a second `pull`; the origin-target syntax case expects the pre-move preflight refusal. - test_hostile_commit ZIP cell kills right after a tracked file landed, not at the N-th swap (listing order made it land on unchanged entries only). (cherry picked from commit b6adf96ca6988262fe7c408ea652d3b390c7be8d) (cherry picked from commit 028cb933271626d21003c255bb397deb8998b7da)
248 lines
12 KiB
Python
248 lines
12 KiB
Python
"""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'<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(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", encoding="utf-8")
|
|
git(releases.root, "add", ".")
|
|
git(releases.root, "commit", "-m", "local work")
|
|
(releases.root / "notes.txt").write_text("uncommitted notes\n", encoding="utf-8")
|
|
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(encoding="utf-8") == "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, target_sha=None):
|
|
seen.append((url, target_sha))
|
|
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",
|
|
releases.commits[2])]
|
|
|
|
|
|
@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
|