fix(update): rebuild Desktop for an installed app with no checkout build

`hermes update` only rebuilt Desktop when apps/desktop/release/ or dist/
existed, so an installed Hermes.app that only the checkout update refreshes
(a bootstrap build) never got newer once release/ was gone or never built.

Count such bundles as a Desktop to keep current. Ownership is read from the
bundle's install-stamp.json: `updateMechanism: self` (or a stamp older than
the field). Self-updating releases and commit builds are never rebuilt or
copied over, and only the checkout an installed app actually runs (the one
under the default Hermes home) claims it. The post-build install now uses
the same set.
This commit is contained in:
Brooklyn Nicholson
2026-09-25 00:04:46 -05:00
committed by brooklyn!
parent a7c080ca66
commit 53a785449f
4 changed files with 67 additions and 3 deletions

View File

@@ -871,6 +871,7 @@ from hermes_cli.main_desktop import ( # frozen updater surface: update_cmd*.py
_desktop_macos_relaunchable_fixup,
_desktop_packaged_executable,
_install_rebuilt_desktop_app,
_installed_desktop_apps,
)
from hermes_cli.main_web_build import (
_sweep_stale_bytecode_if_checkout_changed,

View File

@@ -8,6 +8,7 @@ import logging
import contextlib
import argparse
import hashlib
import json
import os
import platform
import re
@@ -947,10 +948,44 @@ def _install_rebuilt_desktop_app(desktop_dir: Path) -> tuple[list[Path], list[st
rebuilt_exe = _desktop_packaged_executable(desktop_dir)
if rebuilt_exe is None:
return [], []
from hermes_cli.gui_uninstall import packaged_gui_app_paths # noqa: PLC0415
# .../Hermes.app/Contents/MacOS/Hermes -> .../Hermes.app
return _install_rebuilt_macos_bundles(
rebuilt_exe.parents[2], packaged_gui_app_paths(), running=_running_macos_app_bundles())
rebuilt_exe.parents[2], _installed_desktop_apps(), running=_running_macos_app_bundles())
def _update_owned_macos_bundles(candidates: list[Path]) -> list[Path]:
"""The existing bundles in *candidates* that only ``hermes update`` keeps current (#52339).
Ownership comes from the bundle's own ``install-stamp.json``. ``updateMechanism: self`` is a
bootstrap build (a local pack or the bootstrap download), and stamps older than the field
predate every self-updating kind. Bundled/light releases update themselves and commit builds
are external, so a local build must never be copied over them. No readable stamp, no claim.
"""
owned = []
for app in candidates:
try:
stamp = json.loads((app / "Contents" / "Resources" / "install-stamp.json").read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
continue
if isinstance(stamp, dict) and stamp.get("updateMechanism", "self") == "self":
owned.append(app)
return owned
def _installed_desktop_apps() -> list[Path]:
"""Installed macOS ``Hermes.app`` bundles this checkout's update owns (none off macOS).
A packaged app runs the checkout under the default Hermes home, so only that checkout may
build for it: a bundle from any other tree (a dev worktree) would split shell from backend.
"""
if sys.platform != "darwin":
return []
from hermes_cli.gui_uninstall import packaged_gui_app_paths # noqa: PLC0415
from hermes_cli.main import PROJECT_ROOT # noqa: PLC0415
from hermes_constants import get_default_hermes_root # noqa: PLC0415
if Path(PROJECT_ROOT).resolve() != (get_default_hermes_root() / "hermes-agent").resolve():
return []
return _update_owned_macos_bundles(packaged_gui_app_paths())
def _install_rebuilt_macos_bundles(

View File

@@ -1286,9 +1286,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
desktop_dir = _m().PROJECT_ROOT / "apps" / "desktop"
# An installed Hermes.app only this update refreshes counts even with no release/ build
# beside it: without one it was never rebuilt, so it never got newer (#52339).
had_desktop_app_before_update = (
_m()._desktop_packaged_executable(desktop_dir) is not None
or _m()._desktop_dist_exists(desktop_dir))
or _m()._desktop_dist_exists(desktop_dir)
or bool(_m()._installed_desktop_apps()))
use_zip_update, git_cmd, is_fork = _prepare_git_command()

View File

@@ -313,3 +313,28 @@ def test_venv_layout_explicit_and_native(tmp_path, windows, folder, executable):
assert venv_python(str(tmp_path), windows=windows) == tmp_path / folder / executable
if windows == (os.name == "nt"):
assert venv_python(tmp_path) == tmp_path / folder / executable
@pytest.mark.platforms("macos")
@pytest.mark.parametrize(("mechanism", "rebuilt"), [("self", True), ("electron-updater", False)])
def test_installed_app_without_a_checkout_build_is_still_rebuilt(zip_update, monkeypatch, tmp_path, mechanism, rebuilt):
"""#52339: an installed Hermes.app only ``hermes update`` refreshes needs a Desktop build even
when release/ is gone, or it never gets newer. A self-updating release is not ours to rebuild."""
installed = tmp_path / "Applications" / "Hermes.app"
(installed / "Contents" / "Resources").mkdir(parents=True)
(installed / "Contents" / "Resources" / "install-stamp.json").write_text(
json.dumps({"updateMechanism": mechanism}), encoding="utf-8")
monkeypatch.setattr("hermes_cli.gui_uninstall.packaged_gui_app_paths", lambda: [installed])
# The checkout under the default Hermes home is the one an installed app runs.
(tmp_path / "default-home").mkdir()
(tmp_path / "default-home" / "hermes-agent").symlink_to(zip_update.root)
monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda **kw: tmp_path / "default-home")
built = []
monkeypatch.setattr("hermes_cli.source_build.build_update_products",
lambda selected, *, desktop: built.append(desktop))
monkeypatch.setattr(update_cmd, "_prepare_git_command", lambda: (True, ["git"], False))
monkeypatch.setattr(main, "_warn_orphaned_update_autostashes", lambda *args: None)
update_cmd._cmd_update_impl(SimpleNamespace(branch="main", yes=True), False)
assert built == [rebuilt]