Follow-up to the lost-completion settle (review P2): - A retired channel is adopted only after a VERIFIED completion: a lost one exits 0 with the owed `completion` follow-up but leaves the config on the retired channel; the next `hermes update` resolves the retirement again and adopts it (the same retry the adoption-failure follow-up already names). - The settle triggers on a non-zero answer without a receipt (what run_completion returns for every answer that is not a correlated terminal receipt) and runs at most once per run (a spawn failure that could not finalize does not settle twice). Test changes (test_source_channel_integration.py), each pinning the failure invariant 3 forbids: - test_retirement_adopts_destination_only_after_success[failed]: the completion answered exit 1 without a receipt and the test expected SystemExit; it is now an owed completion (exit 0). The channel assertion is unchanged: the destination is still not adopted. - test_retirement_waits_for_correlated_completion_process[uncorrelated| missing]: same, an uncorrelated/missing answer was SystemExit and is now owed; the channel still stays on the retired one. [failure] (the child answered a correlated failed receipt) still raises. Review P2.
504 lines
26 KiB
Python
504 lines
26 KiB
Python
"""Source channel tracers through real HTTP, Git, and completion boundaries."""
|
|
import argparse
|
|
from copy import deepcopy
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import main, source_releases, update_cmd
|
|
from hermes_cli.subcommands.update import build_update_parser
|
|
from hermes_cli.update_channel import channel_record, set_install_channel
|
|
from hermes_cli.config import require_readable_config_before_write
|
|
|
|
# These tests model channel archives and the reader's own transport.
|
|
pytestmark = pytest.mark.real_release_channels
|
|
|
|
|
|
def git(root, *args):
|
|
return subprocess.run(["git", *args], cwd=root, check=True, capture_output=True,
|
|
text=True, stdin=subprocess.DEVNULL).stdout.strip()
|
|
|
|
|
|
@pytest.fixture
|
|
def source(tmp_path, monkeypatch):
|
|
home, origin, checkout = (tmp_path / name for name in ("home", "origin", "checkout"))
|
|
home.mkdir()
|
|
origin.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_INSTALL_ROOT", str(checkout))
|
|
monkeypatch.delenv("HERMES_MANAGED", raising=False)
|
|
git(origin, "init", "-b", "main")
|
|
git(origin, "config", "user.name", "Channel Fixture")
|
|
git(origin, "config", "user.email", "fixture@example.invalid")
|
|
git(origin, "config", "commit.gpgsign", "false")
|
|
commits = []
|
|
for label in ("installed", "published", "unpublished"):
|
|
(origin / "content.txt").write_text(label)
|
|
git(origin, "add", "content.txt")
|
|
git(origin, "commit", "-m", label)
|
|
commits.append(git(origin, "rev-parse", "HEAD"))
|
|
git(tmp_path, "clone", str(origin), str(checkout))
|
|
git(checkout, "checkout", "--detach", commits[0])
|
|
monkeypatch.setattr(main, "PROJECT_ROOT", checkout)
|
|
monkeypatch.setattr("hermes_cli.config.get_project_root", lambda: checkout)
|
|
parser = argparse.ArgumentParser()
|
|
build_update_parser(parser.add_subparsers(), cmd_update=main.cmd_update)
|
|
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))
|
|
return SimpleNamespace(home=home, origin=origin, root=checkout, commits=commits, parser=parser)
|
|
|
|
|
|
def record(name, *, repository="NousResearch/hermes-agent", state="active", destination=None):
|
|
return {"schema": 1, "name": name, "repository": repository, "policy": "preview",
|
|
"state": state, "identity": {}, "nextSequence": 2, "head": None,
|
|
**({"destination": destination} if destination else {})}
|
|
|
|
|
|
def reader_result(source, name, destination=None, repository="NousResearch/hermes-agent"):
|
|
requested = record(name, repository=repository,
|
|
state="retired" if destination else "active", destination=destination)
|
|
terminal = record(destination or name, repository=repository)
|
|
terminal["head"] = {"buildId": "build-fixture", "sequence": 1}
|
|
return SimpleNamespace(requested=requested, terminal=terminal, manifest={
|
|
"schema": 1, "request": {"buildId": "build-fixture", "channel": terminal["name"],
|
|
"sequence": 1, "repository": repository, "commit": source.commits[1],
|
|
"sourceVersion": "1.2.3", "version": "0.0.1", "identity": {}, "bundleEnv": {},
|
|
"publicBase": "https://hermes-assets.nousresearch.com"}, "packages": []})
|
|
|
|
|
|
def install_reader(monkeypatch, result):
|
|
# This is the single documented adapter to ChannelReader.resolve's validated
|
|
# result. No source caller should depend on tag tuples or protocol internals.
|
|
monkeypatch.setattr(source_releases, "_resolve_channel", lambda name, repository: deepcopy(result), raising=False)
|
|
|
|
|
|
def saved(source):
|
|
return channel_record(require_readable_config_before_write(source.home / "config.yaml"), source.root)
|
|
|
|
|
|
def test_tagless_channel_check_apply_is_pinned_not_branch_tip(source, monkeypatch, capsys):
|
|
name = "preview-unknown-at-build"
|
|
set_install_channel(name, source.root)
|
|
install_reader(monkeypatch, reader_result(source, name))
|
|
update_cmd._cmd_update_check()
|
|
assert name in capsys.readouterr().out
|
|
assert git(source.root, "rev-parse", "HEAD") == source.commits[0]
|
|
completed = []
|
|
monkeypatch.setattr(update_cmd, "_complete_source_update", lambda request: completed.append(deepcopy(request)))
|
|
args = source.parser.parse_args(["update", "--channel", name, "--yes"])
|
|
update_cmd._cmd_update_impl(args, False)
|
|
assert git(source.root, "rev-parse", "HEAD") == source.commits[1]
|
|
assert (source.root / "content.txt").read_text() == "published"
|
|
assert git(source.root, "tag", "--list") == ""
|
|
assert completed[0]["expected_sha"] == source.commits[1]
|
|
assert saved(source)["channel"] == name
|
|
|
|
|
|
@pytest.mark.parametrize("outcome", ["success", "failed", "concurrent", "transient", "already-current"])
|
|
def test_retirement_adopts_destination_only_after_success(source, monkeypatch, outcome):
|
|
name = "preview-retiring"
|
|
set_install_channel(name, source.root)
|
|
original = deepcopy(saved(source))
|
|
install_reader(monkeypatch, reader_result(source, name, "stable"))
|
|
if outcome == "already-current":
|
|
git(source.root, "checkout", "--detach", source.commits[1])
|
|
requests = []
|
|
|
|
def completion(request):
|
|
requests.append(deepcopy(request))
|
|
assert saved(source) == original
|
|
assert git(source.root, "rev-parse", "HEAD") == source.commits[1]
|
|
if outcome == "concurrent":
|
|
set_install_channel("my-new-choice", source.root)
|
|
return {"exit_code": 1 if outcome == "failed" else 0, "receipt": None,
|
|
"windows_resume": None}
|
|
|
|
monkeypatch.setattr(update_cmd, "run_completion", completion)
|
|
monkeypatch.setattr(update_cmd, "_write_fleet_restart_pending_marker", lambda **kw: None)
|
|
flags = ["--channel", name] if outcome == "transient" else []
|
|
args = source.parser.parse_args(["update", "--yes", *flags])
|
|
# A completion that answered no terminal receipt ("failed") was SystemExit: after the commit
|
|
# point it is an owed completion, exit 0 (review P2) -- and still never adopts the destination.
|
|
update_cmd._cmd_update_impl(args, False)
|
|
expected = {"success": "stable", "already-current": "stable", "failed": name,
|
|
"transient": name, "concurrent": "my-new-choice"}[outcome]
|
|
assert saved(source)["channel"] == expected
|
|
assert len(requests) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("channel", ["preview-not-registered", "stable", "canary", "main"])
|
|
def test_missing_channel_cannot_fall_back_to_main(source, monkeypatch, channel):
|
|
from hermes_cli import source_check
|
|
|
|
set_install_channel(channel, source.root)
|
|
def missing(name, repository):
|
|
raise ValueError("Channel does not exist")
|
|
monkeypatch.setattr(source_releases, "_resolve_channel", missing)
|
|
status = source_check.check_for_updates(install_root=source.root, home=source.home, force=True)
|
|
assert status["error"] == "release-unavailable"
|
|
assert "targetSha" not in status
|
|
before = git(source.root, "rev-parse", "HEAD")
|
|
with pytest.raises(SystemExit):
|
|
update_cmd._cmd_update_impl(source.parser.parse_args(["update", "--yes"]), False)
|
|
assert git(source.root, "rev-parse", "HEAD") == before
|
|
|
|
|
|
def test_unpublished_main_record_keeps_following_the_git_branch(source, monkeypatch):
|
|
"""main IS the source branch: until R2 publishes its record, a checkout
|
|
still updates via git instead of failing on a missing channel object."""
|
|
from hermes_cli import source_check
|
|
from hermes_cli.release_channels import ChannelNotFound
|
|
|
|
set_install_channel("main", source.root)
|
|
def unpublished(name, repository):
|
|
raise ChannelNotFound(f"Channel object not found: releases/channels/{name}.json")
|
|
monkeypatch.setattr(source_releases, "_resolve_channel", unpublished)
|
|
target = source_releases.resolve_source_target("main", ["git"], source.root)
|
|
assert target.branch == "main" and target.commit is None
|
|
status = source_check.check_for_updates(install_root=source.root, home=source.home, force=True)
|
|
assert "error" not in status, status
|
|
assert status["targetSha"] == source.commits[2]
|
|
with pytest.raises(ChannelNotFound):
|
|
source_releases.resolve_source_target("stable", ["git"], source.root)
|
|
|
|
|
|
def test_passive_check_reports_retirement_without_adopting_it(source, monkeypatch):
|
|
from hermes_cli import source_check, banner
|
|
|
|
name = "preview-retiring"
|
|
set_install_channel(name, source.root)
|
|
before = saved(source)
|
|
install_reader(monkeypatch, reader_result(source, name, "stable"))
|
|
status = source_check.check_for_updates(install_root=source.root, home=source.home, force=True)
|
|
assert status["channel"] == name
|
|
assert status["targetSha"] == source.commits[1]
|
|
assert status["retirement"] == {"destination": "stable", "sourceOnly": True}
|
|
assert status["updateAvailable"] is True
|
|
assert saved(source) == before
|
|
assert name in banner.format_banner_version_label()
|
|
|
|
|
|
def test_fork_channel_cannot_select_official_source(source, monkeypatch):
|
|
url = "https://github.com/Fixture/fork.git"
|
|
git(source.root, "config", "remote.origin.url", url)
|
|
git(source.root, "config", f"url.{source.origin}.insteadOf", url)
|
|
install_reader(monkeypatch, reader_result(source, "preview"))
|
|
with pytest.raises(ValueError, match="repository"):
|
|
source_releases.resolve_source_target("preview", ["git"], source.root)
|
|
install_reader(monkeypatch, reader_result(source, "preview", repository="Fixture/fork"))
|
|
target = source_releases.resolve_source_target("preview", ["git"], source.root)
|
|
assert target.repository == "Fixture/fork"
|
|
assert target.commit == source.commits[1]
|
|
|
|
|
|
@pytest.fixture
|
|
def channel_archive(source, monkeypatch):
|
|
from functools import partial
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
from threading import Thread
|
|
|
|
archive = source.home / "archive"
|
|
archive.mkdir()
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(archive)))
|
|
thread = Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
base = f"http://127.0.0.1:{server.server_port}"
|
|
monkeypatch.setattr(source_releases, "_PUBLIC_BASE", base)
|
|
try:
|
|
yield archive, base
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join()
|
|
|
|
|
|
def publish_channel_build(channel_archive, name, build_id, commit, *, sequence=1, stable=False):
|
|
from hashlib import sha256
|
|
from hermes_cli.release_channels import canonical_json
|
|
|
|
archive, base = channel_archive
|
|
prefix = f"releases/channel-builds/{build_id}/"
|
|
identity = {"token": "a" * 16, "displayName": "Preview fixture", "appNamePascal": "Fixture",
|
|
"artifactNamePascal": "Fixture", "appId": "ai.fixture.preview",
|
|
"msixAppIdWithOrg": "Fixture.Preview", "cliName": "fixture-preview",
|
|
"windowsExecutableName": "Fixture"}
|
|
version = f"1.2.{sequence}" if stable else f"0.0.{sequence}"
|
|
request = {"schema": 1, "channel": name, "buildId": build_id, "sequence": sequence,
|
|
"repository": "NousResearch/hermes-agent", "commit": commit,
|
|
"sourceVersion": f"1.2.{sequence}", "version": version, "windowsVersion": version + ".0",
|
|
"identity": identity, "bundleEnv": {}, "publicBase": base}
|
|
if stable:
|
|
request["releaseTag"] = "v" + version
|
|
manifest = {"schema": 1, "receiverProtocol": 1, "request": request, "packages": [{
|
|
"platform": "darwin", "arch": "arm64", "variant": "bundled",
|
|
"artifact": {"key": prefix + "fixture.zip", "size": 1, "sha256": "d" * 64},
|
|
"identity": identity["appId"], "version": version, "teamId": "ABCDEFGHIJ",
|
|
"feed": {"key": prefix + "darwin/stable.yml", "channel": "stable"}}]}
|
|
body = canonical_json(manifest)
|
|
channel = {"schema": 1, "name": name, "repository": request["repository"],
|
|
"policy": "stable-release" if stable else "preview", "state": "active", "identity": identity,
|
|
"revision": sequence, "nextSequence": sequence + 1, "head": {"buildId": build_id,
|
|
"sequence": sequence, "manifestKey": prefix + "build.json", "sha256": sha256(body).hexdigest()}}
|
|
for key, content in [(prefix + "build.json", body),
|
|
(f"releases/channels/{name}.json", canonical_json(channel))]:
|
|
path = archive / key
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(content)
|
|
return channel
|
|
|
|
|
|
def test_real_http_reader_resolves_tagless_build_through_cli(source, monkeypatch, channel_archive):
|
|
archive, _ = channel_archive
|
|
name = "http-preview-351"
|
|
channel = publish_channel_build(channel_archive, name, "b" * 32, source.commits[1])
|
|
from hermes_cli import source_check
|
|
status = source_check.check_for_updates(install_root=source.root, home=source.home,
|
|
channel=name, force=True)
|
|
assert status.get("targetSha") == source.commits[1], status
|
|
completed = []
|
|
monkeypatch.setattr(update_cmd, "_complete_source_update", lambda req: completed.append(req))
|
|
update_cmd._cmd_update_impl(source.parser.parse_args(["update", "--channel", name]), False)
|
|
assert git(source.root, "rev-parse", "HEAD") == source.commits[1]
|
|
assert completed[0]["expected_sha"] == source.commits[1]
|
|
# A mutated immutable manifest is refused, never substituted by main.
|
|
path = archive / channel["head"]["manifestKey"]
|
|
path.write_bytes(path.read_bytes() + b" ")
|
|
with pytest.raises(ValueError, match="SHA256"):
|
|
source_releases.resolve_source_target(name, ["git"], source.root)
|
|
|
|
|
|
@pytest.fixture
|
|
def retired_channel_archive(source, channel_archive):
|
|
from hermes_cli.release_channels import canonical_json
|
|
|
|
archive, _ = channel_archive
|
|
name = "offline-preview"
|
|
preview = publish_channel_build(channel_archive, name, "a" * 32, source.commits[0])
|
|
qualified = publish_channel_build(channel_archive, "stable", "b" * 32, source.commits[1], stable=True)
|
|
retired = dict(preview, state="retired", destination="stable", minimumVersion="1.0.0",
|
|
destinationHead=qualified["head"], receiverProtocol=1,
|
|
receiver={"kind": "discontinued"}, lastHead=preview["head"])
|
|
(archive / f"releases/channels/{name}.json").write_bytes(canonical_json(retired))
|
|
publish_channel_build(channel_archive, "stable", "c" * 32, source.commits[2], sequence=2, stable=True)
|
|
set_install_channel(name, source.root)
|
|
return SimpleNamespace(archive=archive, name=name, retired=retired,
|
|
qualified=qualified)
|
|
|
|
|
|
@pytest.mark.parametrize("fault", [None, "binding", "manifest"])
|
|
def test_offline_retirement_uses_qualified_build_before_current_stable(
|
|
source, monkeypatch, retired_channel_archive, fault):
|
|
from hermes_cli import source_check
|
|
from hermes_cli.release_channels import canonical_json
|
|
|
|
fixture = retired_channel_archive
|
|
original = deepcopy(saved(source))
|
|
completed = []
|
|
def completion(request):
|
|
completed.append(deepcopy(request))
|
|
assert git(source.root, "rev-parse", "HEAD") == request["expected_sha"]
|
|
return {"exit_code": 0, "receipt": None, "windows_resume": None}
|
|
monkeypatch.setattr(update_cmd, "run_completion", completion)
|
|
monkeypatch.setattr(update_cmd, "_write_fleet_restart_pending_marker", lambda **kw: None)
|
|
if fault == "binding":
|
|
fixture.retired["destinationHead"]["sequence"] += 1
|
|
(fixture.archive / f"releases/channels/{fixture.name}.json").write_bytes(canonical_json(fixture.retired))
|
|
elif fault == "manifest":
|
|
path = fixture.archive / fixture.qualified["head"]["manifestKey"]
|
|
path.write_bytes(path.read_bytes() + b" ")
|
|
status = source_check.check_for_updates(install_root=source.root, home=source.home, force=True)
|
|
args = source.parser.parse_args(["update", "--yes"])
|
|
if fault:
|
|
assert status["error"] == "release-unavailable"
|
|
with pytest.raises(SystemExit):
|
|
update_cmd._cmd_update_impl(args, False)
|
|
assert git(source.root, "rev-parse", "HEAD") == source.commits[0]
|
|
assert saved(source) == original and not completed
|
|
return
|
|
assert status.get("targetSha") == source.commits[1], status
|
|
assert status["buildId"] == fixture.qualified["head"]["buildId"]
|
|
assert saved(source) == original
|
|
update_cmd._cmd_update_impl(args, False)
|
|
assert git(source.root, "rev-parse", "HEAD") == source.commits[1]
|
|
assert saved(source)["channel"] == "stable"
|
|
status = source_check.check_for_updates(install_root=source.root, home=source.home, force=True)
|
|
assert status["targetSha"] == source.commits[2], status
|
|
update_cmd._cmd_update_impl(args, False)
|
|
assert git(source.root, "rev-parse", "HEAD") == source.commits[2]
|
|
assert [request["expected_sha"] for request in completed] == source.commits[1:]
|
|
assert "channel_retirement" not in completed[1]
|
|
|
|
|
|
@pytest.mark.parametrize("transport", ["git", "shallow", "zip"])
|
|
def test_retirement_refuses_to_downgrade_newer_source(
|
|
source, monkeypatch, retired_channel_archive, transport):
|
|
git(source.root, "checkout", "--detach", source.commits[2])
|
|
if transport == "shallow":
|
|
import shutil
|
|
shutil.rmtree(source.root)
|
|
git(source.root.parent, "clone", "--depth=1", source.origin.as_uri(), str(source.root))
|
|
if transport == "zip":
|
|
(source.root / "pyproject.toml").write_text('[project]\nversion = "1.2.2"\n')
|
|
monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (True, ["git"], False))
|
|
original = deepcopy(saved(source))
|
|
completed = []
|
|
monkeypatch.setattr(update_cmd, "_complete_source_update", lambda request: completed.append(request))
|
|
with pytest.raises(ValueError, match="newer|downgrade"):
|
|
source_releases.resolve_source_target(retired_channel_archive.name,
|
|
None if transport == "zip" else ["git"], source.root)
|
|
with pytest.raises(SystemExit):
|
|
update_cmd._cmd_update_impl(source.parser.parse_args(["update", "--yes"]), False)
|
|
assert git(source.root, "rev-parse", "HEAD") == source.commits[2]
|
|
assert saved(source) == original and not completed
|
|
|
|
|
|
@pytest.mark.parametrize("dirty", [False, True])
|
|
def test_tagless_zip_apply_uses_pinned_source_archive(source, monkeypatch, dirty):
|
|
import urllib.request
|
|
from hermes_cli import update_cmd_zip
|
|
|
|
name = "zip-preview"
|
|
set_install_channel(name, source.root)
|
|
install_reader(monkeypatch, reader_result(source, name, "stable"))
|
|
archive = source.home / "source.zip"
|
|
git(source.origin, "archive", "--format=zip", "--prefix=hermes-agent-fixture/",
|
|
"--output=" + str(archive), source.commits[1])
|
|
urls = []
|
|
def download(url, filename):
|
|
import shutil
|
|
urls.append(url)
|
|
shutil.copyfile(archive, filename)
|
|
monkeypatch.setattr(urllib.request, "urlretrieve", download)
|
|
monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (True, ["git"], False))
|
|
completed = []
|
|
monkeypatch.setattr(update_cmd, "_complete_source_update", lambda req: completed.append(deepcopy(req)))
|
|
if dirty:
|
|
(source.root / "notes.txt").write_text("do not remove")
|
|
with pytest.raises(SystemExit):
|
|
update_cmd._cmd_update_impl(source.parser.parse_args(["update"]), False)
|
|
assert not urls and not completed
|
|
assert (source.root / "content.txt").read_text() == "installed"
|
|
assert (source.root / "notes.txt").read_text() == "do not remove"
|
|
else:
|
|
update_cmd._cmd_update_impl(source.parser.parse_args(["update"]), False)
|
|
assert urls == [f"https://github.com/NousResearch/hermes-agent/archive/{source.commits[1]}.zip"]
|
|
assert (source.root / "content.txt").read_text() == "published"
|
|
assert completed[0]["expected_sha"] == source.commits[1]
|
|
assert completed[0]["channel_retirement"]["destination"] == "stable"
|
|
assert saved(source)["channel"] == name
|
|
|
|
|
|
@pytest.mark.parametrize("outcome", ["success", "failure", "uncorrelated", "missing"])
|
|
def test_retirement_waits_for_correlated_completion_process(source, monkeypatch, outcome):
|
|
from hermes_cli import update_receipt
|
|
|
|
name = "process-retiring"
|
|
set_install_channel(name, source.root)
|
|
original = deepcopy(saved(source))
|
|
# This child is the completion transport fixture, not a simulated PM install.
|
|
# The existing completion-process suite exercises fresh PM/selected Python.
|
|
script = source.root / "hermes_cli/update_completion.py"
|
|
script.parent.mkdir()
|
|
script.write_text(
|
|
"import json, pathlib, sys\n"
|
|
"request = json.loads(pathlib.Path(sys.argv[1]).read_text())\n"
|
|
"pathlib.Path(request['home'], 'child-request.json').write_text(json.dumps(request))\n"
|
|
f"outcome = {outcome!r}\n"
|
|
"code = 17 if outcome == 'failure' else 0\n"
|
|
"receipt = dict(request['receipt'], finished_at='fixture', outcome='failed' if code else 'success')\n"
|
|
"result = dict(schema=1, exit_code=code, receipt=receipt, windows_resume=None, update_id=receipt['update_id'])\n"
|
|
"if outcome == 'uncorrelated': result['update_id'] = 'another-update'\n"
|
|
"if outcome != 'missing': pathlib.Path(sys.argv[2]).write_text(json.dumps(result))\n"
|
|
"raise SystemExit(code)\n"
|
|
)
|
|
update_receipt.begin_update_receipt()
|
|
request = {"schema": 1, "source": str(source.root), "home": str(source.home),
|
|
"expected_sha": source.commits[1], "windows_resume": None,
|
|
"receipt": deepcopy(update_receipt._current.get().data),
|
|
"channel_retirement": {"original": original, "destination": "stable"}}
|
|
monkeypatch.setattr(update_cmd, "_write_fleet_restart_pending_marker", lambda **kw: None)
|
|
if outcome == "failure": # the child itself answered a correlated failure
|
|
with pytest.raises(SystemExit):
|
|
update_cmd._complete_source_update(request)
|
|
else:
|
|
# An uncorrelated or missing answer was SystemExit: after the commit point it is an owed
|
|
# completion, exit 0 (review P2). Adoption still waits for a verified completion.
|
|
update_cmd._complete_source_update(request)
|
|
assert saved(source)["channel"] == ("stable" if outcome == "success" else name)
|
|
child_request = json.loads((source.home / "child-request.json").read_text())
|
|
assert child_request["channel_retirement"]["original"] == original
|
|
assert child_request["expected_sha"] == source.commits[1]
|
|
|
|
|
|
def test_source_branch_record_has_no_bundle_and_explicit_branch_is_separate(source, monkeypatch):
|
|
from hermes_cli import source_check
|
|
|
|
branch_record = record("main")
|
|
branch_record.update(policy="source-branch", identity=None,
|
|
delivery={"kind": "source-branch", "branch": "main"})
|
|
install_reader(monkeypatch, SimpleNamespace(requested=branch_record,
|
|
terminal=branch_record, manifest=None))
|
|
target = source_releases.resolve_source_target("main", ["git"], source.root)
|
|
assert target.branch == "main" and target.commit is None
|
|
status = source_check.check_for_updates(install_root=source.root, home=source.home, force=True)
|
|
assert status["targetSha"] == source.commits[2], status
|
|
assert "buildId" not in status
|
|
# Explicit --branch remains usable without a channel record or release host.
|
|
def forbidden(*args):
|
|
pytest.fail("explicit source branch contacted channel authority")
|
|
monkeypatch.setattr(source_releases, "_resolve_channel", forbidden)
|
|
set_install_channel("unavailable-preview", source.root)
|
|
completed = []
|
|
monkeypatch.setattr(update_cmd, "_complete_source_update", lambda request: completed.append(request))
|
|
update_cmd._cmd_update_impl(source.parser.parse_args(["update", "--branch", "main"]), False)
|
|
assert git(source.root, "rev-parse", "HEAD") == source.commits[2]
|
|
assert "channel_retirement" not in completed[0]
|
|
assert saved(source)["channel"] == "unavailable-preview"
|
|
|
|
|
|
def test_changed_checkout_cannot_repin_selected_channel_during_apply(source, monkeypatch):
|
|
set_install_channel("preview", source.root)
|
|
install_reader(monkeypatch, reader_result(source, "preview", "stable"))
|
|
pull = update_cmd._pull_updates
|
|
def moved(*args, **kwargs):
|
|
before = pull(*args, **kwargs)
|
|
git(source.root, "checkout", "--detach", source.commits[2])
|
|
return before
|
|
monkeypatch.setattr(update_cmd, "_pull_updates", moved)
|
|
completed = []
|
|
monkeypatch.setattr(update_cmd, "_complete_source_update", lambda req: completed.append(req))
|
|
with pytest.raises(SystemExit):
|
|
update_cmd._cmd_update_impl(source.parser.parse_args(["update"]), False)
|
|
assert not completed
|
|
assert saved(source)["channel"] == "preview"
|
|
|
|
|
|
@pytest.mark.parametrize("fault", ["floor", "protocol", "build", "chain", "missing"])
|
|
def test_source_retirement_rejects_invalid_archive_constraints(source, retired_channel_archive, fault):
|
|
from hermes_cli.release_channels import canonical_json
|
|
fixture = retired_channel_archive
|
|
if fault == "floor":
|
|
fixture.retired["minimumVersion"] = "9.0.0"
|
|
elif fault == "protocol":
|
|
fixture.retired["receiverProtocol"] = 2
|
|
elif fault == "build":
|
|
fixture.retired["destinationHead"]["buildId"] = "f" * 32
|
|
elif fault == "chain":
|
|
fixture.retired["destination"] = fixture.name
|
|
else:
|
|
del fixture.retired["destinationHead"]
|
|
(fixture.archive / f"releases/channels/{fixture.name}.json").write_bytes(canonical_json(fixture.retired))
|
|
with pytest.raises(ValueError):
|
|
source_releases.resolve_source_target(fixture.name, ["git"], source.root)
|