Files
hermes-agent/scripts/bundles/desktop.py
ethernet8023 4bc38bceec fix(desktop): hand the admitted checkout back after flavored icon staging
electron-builder resolves every piece of packaging artwork from the workspace
— the app icon, the icon.ico extraResource, the MSIX logo set before-build
stages into build/appx, and the exe identity stamp — so a flavored canary or
commit build copies its rendered icon set over apps/desktop/assets before
packaging.

Those files stopped being gitignored in 4b7229d612, which turned a write that
was invisible into 15 dirty paths. The source custody check then refuses the
checkout twice: inside the same build, at the check that used to sit after the
copy, and on every later build from the first check on a now permanently dirty
tree.

Hold the render at the workspace path for the packaging window only and
restore the admitted files afterwards, whether packaging finished or failed.
The final custody check now asserts the build handed the checkout back.
2026-09-29 12:29:47 -04:00

187 lines
9.9 KiB
Python

"""Build a complete desktop bundle with the shared Python payload tools."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from contextlib import contextmanager
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def run(argv: list[str], *, cwd: Path, env: dict[str, str]) -> None:
print("bundle: " + subprocess.list2cmdline(argv), flush=True)
subprocess.run(argv, cwd=cwd, env=env, check=True)
def capture(argv: list[str], repo: Path, env: dict[str, str]) -> str:
return subprocess.check_output(argv, cwd=repo, env=env, text=True, encoding="utf-8").strip()
@contextmanager
def flavored_assets(icons: Path, assets: Path):
"""Hold the flavored icon set at its workspace path for the packaging window.
electron-builder resolves every piece of packaging artwork from the
workspace — the app icon, the icon.ico extraResource, the MSIX logo set
before-build stages into build/appx, and the exe identity stamp — so a
flavored (canary/commit) build cannot package from the rendered product
directory alone. Restore the admitted files once packaging ends: the source
custody check refuses a dirty checkout, and a build that leaves its own
render behind breaks every later build in that tree and `hermes update`.
"""
admitted = {path.relative_to(assets): path.read_bytes()
for path in assets.rglob("*") if path.is_file()}
try:
shutil.copytree(icons, assets, dirs_exist_ok=True)
yield
finally:
for path in assets.rglob("*"):
if path.is_file() and path.relative_to(assets) not in admitted:
path.unlink()
for relative, data in admitted.items():
path = assets / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def release_version(_repo: Path, tag: str) -> str:
from scripts.termux.deb_version import channel_for_tag
channel_for_tag(tag) # shared release tag grammar, not a second version parser
return tag[1:]
def npm_command(node: str) -> list[str]:
# Dependency preparation and packaging must resolve the same npm identity.
return [node, str(ROOT / "scripts/build/node-deps.mjs"), "--npm"]
def build(repo: Path, tag: str | None, variant: str, builder_args: list[str],
commit_build: str | None = None) -> None:
from scripts.bundles.desktop_prepare import BuildRequest, prepare
from scripts.releases.bundle_env import decode
request = BuildRequest.create(repo, tag=tag, commit=commit_build, variant=variant,
work=repo / ".build/desktop-job", cache=repo / ".cache/desktop-inputs",
bundle_env=decode(os.environ.get("HERMES_BUNDLE_ENV_JSON", "")))
build_prepared(prepare(request), builder_args)
def build_prepared(path: Path, builder_args: list[str], variant: str | None = None) -> None:
from scripts.bundles.desktop_prepare import PreparedDesktop
from scripts.bundles.desktop_inputs import build_lock
prepared = PreparedDesktop.load(path)
with build_lock(prepared.request.source):
_build_prepared(prepared, builder_args, variant)
def _build_prepared(prepared, builder_args: list[str], variant: str | None) -> None:
prepared.validate()
from scripts.bundles.desktop_inputs import build_environment, packaging_environment, select_variant, validate_builder_identity
from scripts.bundles.native import finish_native
request = prepared.request
validate_builder_identity(request, builder_args)
if request.channel_request is not None and not request.target.startswith(("darwin-", "win32-")):
raise ValueError("channel builds require a supported native macOS or Windows target")
variant = select_variant(prepared, variant)
repo, node = request.source, str(prepared.node)
env = build_environment(prepared, variant, os.environ)
if request.release_epoch is not None:
env["HERMES_RELEASE_EPOCH"] = str(request.release_epoch)
desktop = repo / "apps/desktop"
targets = {"win32": ["--win", "msix"], "darwin": ["--mac", "dmg", "zip"], "linux": ["--linux", "AppImage"]}[sys.platform]
package_args = ["--prepared", str(prepared.packager), "--native-deps", str(prepared.native),
*targets, f"-c.extraMetadata.version={request.version}"]
run([node, "scripts/run-electron-builder.mjs", "--validate-only", *package_args, *builder_args],
cwd=desktop, env=env)
run([node, "scripts/build/node-deps.mjs", "--source", str(repo), "--reuse", "--no-install",
"--native-toolchain", prepared.native_toolchain,
*[arg for name in request.workspaces() for arg in ("--workspace", name)]], cwd=repo, env=env)
products = repo / "apps/desktop/build/products"
icons = products / "icons"
run([str(prepared.icon_python), "-I", str(repo / "scripts/generate_icons.py"),
"--source", str(repo), "--out", str(icons)], cwd=repo, env=env)
if variant != "light":
run([node, "scripts/build/tui.mjs", "--source", str(repo), "--out", str(products / "tui")], cwd=repo, env=env)
run([node, "scripts/build/web.mjs", "--source", str(repo), "--icons", str(products / "icons"),
"--out", str(products / "web")], cwd=repo, env=env)
if prepared.payload is None:
raise ValueError("payload dependencies were not prepared")
if finish_native(prepared.payload, {"tui": products / "tui", "web": products / "web"}):
raise RuntimeError("prepared payload assembly failed")
from scripts.bundles.desktop_prepare import require_source
require_source(repo, request.commit)
# The admitted checkout stays clean across the whole build: packaging reads
# its artwork from the workspace, so the render lives there only while
# electron-builder holds it, and the final check proves it was handed back.
with flavored_assets(icons / "apps/desktop/assets", desktop / "assets"):
run([node, "scripts/write-build-stamp.mjs"], cwd=desktop, env=env)
run([node, "scripts/build/desktop.mjs", "--source", str(repo), "--icons", str(icons),
"--stamp", str(desktop / "build/install-stamp.json"), "--native-deps", str(prepared.native),
"--out", str(desktop / "dist")], cwd=repo, env=env)
version_args = []
if sys.platform == "win32" and request.channel_request is None and request.tag is not None:
script = "const m=require('./scripts/msix-shared.mjs');console.log(m.nativeQuad(process.argv[1], Number(process.env.HERMES_RELEASE_EPOCH)))"
quad = capture([node, "-e", script, request.tag], repo, env)
version_args = [f'-c.extraMetadata.shortVersion={quad}', f'-c.extraMetadata.shortVersionWindows={quad}']
run([node, "scripts/run-electron-builder.mjs", *package_args, *version_args, *builder_args], cwd=desktop,
env=packaging_environment(env, os.environ, request.target))
require_source(repo, request.commit)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tag", required=False,
help="Release tag (vX.Y.Z / canary), exclusive with --commit/--channel-request")
parser.add_argument("--commit", dest="commit_build", default=None,
help="Commit-only build: exact full 40-char SHA the checkout is at; "
"version comes from the target pyproject, no tag is referenced")
parser.add_argument("--release-commit", help="Admitted commit for a stable tag not created until green")
parser.add_argument("--channel-request", type=Path, help="Immutable admitted channel request JSON")
parser.add_argument("--variant", choices=["bundled", "store", "light"])
parser.add_argument("--repo", type=Path, default=ROOT)
parser.add_argument("--work", type=Path)
parser.add_argument("--cache", type=Path)
parser.add_argument("--prepare-only", action="store_true")
parser.add_argument("--prepared", type=Path)
parser.add_argument("builder_args", nargs=argparse.REMAINDER)
args = parser.parse_args()
builder_args = [v for v in args.builder_args if v != "--"]
try:
if args.prepared:
if (args.tag or args.commit_build or args.release_commit or args.channel_request
or args.prepare_only or args.work or args.cache):
parser.error("--prepared supplies the complete build request")
build_prepared(args.prepared, builder_args, args.variant)
else:
from scripts.bundles.desktop_prepare import BuildRequest, prepare
from scripts.releases.bundle_env import decode
request = BuildRequest.create(args.repo, tag=args.tag, commit=args.commit_build,
variant=args.variant or "bundled",
work=args.work or args.repo / ".build/desktop-job",
cache=args.cache or args.repo / ".cache/desktop-inputs",
bundle_env=decode(os.environ.get("HERMES_BUNDLE_ENV_JSON", "")),
channel_request=json.loads(args.channel_request.read_text(encoding="utf-8-sig"))
if args.channel_request else None,
release_commit=args.release_commit)
if args.prepare_only and builder_args:
parser.error("builder arguments belong to the build phase")
result = prepare(request)
if args.prepare_only:
print(result)
else:
build_prepared(result, builder_args)
except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as exc:
parser.exit(1, f"desktop build: {exc}\n")
if __name__ == "__main__":
main()