Files
hermes-agent/hermes_cli/source_stamp.py
ethernet 54ad8c7fd2 fix(update): every published checkout identity moves the bootstrap receipt
ece7c172f8 refreshed .hermes-bootstrap-complete only from
complete_source_checkout, but two other paths publish a new checkout identity
through write_source_stamp without it: the PM updater's finish
(update_finish.py, the path an old updater's handoff takes) and boot-time
blessed-checkout adoption (post_update.py). The Windows E2E leg
installer-script -> hermes-update (v2026.6.19 -> HEAD) takes the handoff, so
it still failed "pinnedCommit 2bd1977d8f != installed HEAD ece7c172f8d8".

The refresh now lives inside write_source_stamp, so every caller moves the
receipt with the identity it publishes. The tests pin that seam (red on
ece7c172f8 with the CI message, green here).
2026-09-23 22:05:57 -04:00

105 lines
3.9 KiB
Python

"""Atomically publish identity for a mutable source checkout."""
from __future__ import annotations
from contextlib import suppress
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import tempfile
from hermes_cli.version_info import _git_version_info, _reset_version_info_cache
def write_source_stamp(root: Path) -> dict | None:
"""Replace ``install-stamp.json`` with identity read from ``root`` itself.
A root git cannot identify -- the ZIP update fallback runs precisely because
git is unusable -- publishes no identity: the old stamp is removed rather
than left naming the commit that was just replaced. Returns None then.
"""
root = Path(root).resolve()
info = _git_version_info(root, include_untracked=True)
if info.commit is None:
with suppress(FileNotFoundError):
(root / "install-stamp.json").unlink()
_reset_version_info_cache()
return None
stamp = {
"schemaVersion": 2,
"commit": info.commit,
"commitDate": info.commit_date,
"branch": info.branch,
"builtAt": datetime.now(timezone.utc).isoformat(),
"dirty": info.dirty,
"source": "git",
"distribution": None,
"updateMechanism": "self",
"baseVersion": info.base_version,
"displayVersion": info.derived_version,
"distance": info.distance,
"payload": "bootstrap",
"tag": None,
}
stamp_path = root / "install-stamp.json"
fd, tmp_name = tempfile.mkstemp(dir=root, prefix=".install-stamp.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(stamp, indent=2) + "\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, stamp_path)
with suppress(OSError):
directory_fd = os.open(root, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
with suppress(OSError):
os.unlink(tmp_name)
_reset_version_info_cache()
# Every path that publishes a new checkout identity (completion handoff, the PM
# updater's finish, boot-time adoption) moves the installers' receipt with it.
refresh_bootstrap_receipt(root, stamp)
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)