Files
hermes-agent/scripts/bundles/desktop.py
Siddharth Balyan 86f54bfdaa Flavored desktop builds no longer stamp themselves dirty (#129540)
* fix(bundles): write the build stamp before flavored icons land

A flavored build (commit, canary, preview channel) copies its icons over the
committed apps/desktop/assets before packaging. write-build-stamp ran after
that copy, so its git check saw modified tracked files and stamped the
bundle dirty, and the Windows smoke refused it (stamp.dirty: true != false),
which keeps a channel from promoting the build. Stamp first, in both the CI
driver (scripts/bundles/desktop.py) and the source build (build.mjs). The
stamp reads no artwork, so its content is unchanged.

* test(bundles): pin the build stamp ahead of the flavored icon copy

buildSourceDesktop copies flavored icons over committed files in
apps/desktop/assets. write-build-stamp asks git whether tracked files
changed, so it has to run while the committed bytes are still there. The
test records what the stamp step sees through the injectable run seam.
It fails on the old order (sees the flavored icon) and passes on the new one.
2026-10-01 01:30:18 +05:30

189 lines
10 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.
# Stamp before the render lands: the stamp's git check would call the
# checkout dirty while the flavored icons sit over committed files.
run([node, "scripts/write-build-stamp.mjs"], cwd=desktop, env=env)
with flavored_assets(icons / "apps/desktop/assets", desktop / "assets"):
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()