diff --git a/hermes_cli/source_completion.py b/hermes_cli/source_completion.py index 518f892154..8dbbb0b962 100644 --- a/hermes_cli/source_completion.py +++ b/hermes_cli/source_completion.py @@ -60,9 +60,11 @@ def complete_source_checkout( completion_message=completion_message, ) if complete: - from hermes_cli.source_stamp import write_source_stamp + from hermes_cli.source_stamp import refresh_bootstrap_receipt, write_source_stamp - write_source_stamp(root) + stamp = write_source_stamp(root) + if stamp is not None: + refresh_bootstrap_receipt(root, stamp) return complete diff --git a/hermes_cli/source_stamp.py b/hermes_cli/source_stamp.py index b60b51dbe9..c34b6d33db 100644 --- a/hermes_cli/source_stamp.py +++ b/hermes_cli/source_stamp.py @@ -60,4 +60,43 @@ def write_source_stamp(root: Path) -> dict | None: with suppress(OSError): os.unlink(tmp_name) _reset_version_info_cache() - return stamp \ No newline at end of file + return stamp + + +BOOTSTRAP_RECEIPT = ".hermes-bootstrap-complete" + + +def refresh_bootstrap_receipt(root: Path, stamp: dict) -> None: + """Move the installers' bootstrap receipt to the commit ``stamp`` just published. + + install.sh / install.ps1 write the receipt once, at the commit they installed; + an update that moves the checkout has to move it too, or it keeps naming the + replaced release. Only an existing receipt is refreshed: its presence is what + marks a script install, so a manual clone never grows one. + """ + receipt_path = Path(root) / BOOTSTRAP_RECEIPT + try: + # install.ps1 writes it with Windows PowerShell's UTF-8 BOM. + previous = json.loads(receipt_path.read_text(encoding="utf-8-sig")) + except FileNotFoundError: + return + except (OSError, ValueError): + previous = {} + if not isinstance(previous, dict): + previous = {} + receipt = { + **previous, + "schemaVersion": 1, + "pinnedCommit": stamp["commit"], + # A detached checkout keeps the branch it was installed from. + "pinnedBranch": stamp["branch"] or previous.get("pinnedBranch"), + "completedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + } + fd, tmp_name = tempfile.mkstemp(dir=root, prefix=".hermes-bootstrap-complete.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(receipt, indent=2) + "\n") + os.replace(tmp_name, receipt_path) + finally: + with suppress(OSError): + os.unlink(tmp_name) \ No newline at end of file diff --git a/tests/hermes_cli/test_source_completion_stamp.py b/tests/hermes_cli/test_source_completion_stamp.py index ffda7ff0e1..6556ffdd56 100644 --- a/tests/hermes_cli/test_source_completion_stamp.py +++ b/tests/hermes_cli/test_source_completion_stamp.py @@ -1,8 +1,10 @@ """A source checkout publishes identity only after successful completion.""" +import json import os from pathlib import Path import subprocess +import sys from hermes_cli.source_completion import complete_source_checkout @@ -50,3 +52,37 @@ def test_failed_source_completion_does_not_publish_identity(tmp_path, monkeypatc assert not complete_source_checkout(root, desktop=False, assume_yes=True) assert not (root / "install-stamp.json").exists() + + +def _verify_bootstrap_receipt(root: Path) -> subprocess.CompletedProcess: + """The same verifier the Windows install/update E2E runs after an update.""" + script = Path(__file__).resolve().parents[2] / "scripts" / "verify-bootstrap-version-stamp.py" + return subprocess.run([sys.executable, "-B", str(script), "--stamp", str(root / ".hermes-bootstrap-complete"), + "--repo", str(root)], capture_output=True, text=True, encoding="utf-8") + + +def test_update_completion_moves_an_installer_receipt_to_the_updated_head(tmp_path, monkeypatch): + root = _repo(tmp_path) + release = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, check=True).stdout.strip() + branch = subprocess.run(["git", "branch", "--show-current"], cwd=root, capture_output=True, text=True, check=True).stdout.strip() + # What install.sh / install.ps1's complete stage leaves behind at the installed release. + (root / ".hermes-bootstrap-complete").write_text(json.dumps({ + "schemaVersion": 1, "pinnedCommit": release, "pinnedBranch": branch, "completedAt": "2026-06-19T00:00:00.000Z", + }), encoding="utf-8") + subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "update"], cwd=root, check=True, capture_output=True, + env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"}) + _completion_dependencies(monkeypatch, lambda **_kwargs: True) + + assert complete_source_checkout(root, desktop=False, assume_yes=True) + result = _verify_bootstrap_receipt(root) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_completion_never_invents_an_installer_receipt(tmp_path, monkeypatch): + # The receipt's presence is what marks a script install; a manual clone stays one. + root = _repo(tmp_path) + _completion_dependencies(monkeypatch, lambda **_kwargs: True) + + assert complete_source_checkout(root, desktop=False, assume_yes=True) + assert not (root / ".hermes-bootstrap-complete").exists()