Files
hermes-agent/scripts/ci/desktop_build_cache.py
T
ahrazzle a9376424e5 fix(desktop): prepare the locked Electron runtime explicitly before use
Electron 42 dropped the postinstall that unpacked its binary into
node_modules/electron/dist. The binary now appears on the first
require('electron'), so parallel vitest workers race to unpack it
("Text file busy" in #128267's CI), and `hermes desktop --source` finds
no path.txt to launch.

electron-runtime.mjs runs Electron's own install.js once: after npm ci
for the desktop workspace, and in CI before the parallel checks. It first
checks the installed package against the desktop pin and the lockfile,
and does nothing when the runtime is already in place. On the current
40.10.2 pin, whose postinstall still unpacks the binary, it is a no-op.

Salvaged from #137648 (the runtime-preparation subset only).
2026-10-22 16:17:57 -04:00

119 lines
5.8 KiB
Python

"""Describe the desktop dependency snapshot using only the runner's stdlib.
Actions transports provider directories directly, including npm's existing
receipt. Restored bytes are candidates: preparation must still admit them.
This module never provisions tools or certifies a cache hit as prepared.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import re
import sys
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from scripts.ci.setup_toolchain import current_target, file_commands
CACHE_DOMAINS = ("tools", "python/runtime", "python/build", "npm/_cacache", "native", "packager")
_INPUT_FILES = (
"package-lock.json", "pyproject.toml", "uv.lock", "pm/lock.json", "pm/pyproject.toml", "pm/uv.lock",
"scripts/ci/desktop_build_cache.py", "scripts/ci/setup_toolchain.py",
"scripts/bundles/desktop_prepare.py", "scripts/bundles/desktop_toolchain.py",
"scripts/bundles/desktop_inputs.py", "scripts/bundles/native.py", "scripts/bundles/native_prepared.py",
"scripts/bundles/native_build.py",
"scripts/build/node-deps.mjs", "scripts/build/electron-runtime.mjs", "scripts/build/icon_environment.py",
"pm/native_build.py", "scripts/windows-build-deps.ps1",
"apps/desktop/scripts/stage-native-deps.mjs", "apps/desktop/scripts/prepare-packaging-tools.mjs",
"apps/desktop/scripts/build-command-screenshot-monitor.mjs",
"apps/desktop/scripts/build-hud-modifier-monitor.mjs",
"apps/desktop/electron/native/command-screenshot-monitor.m",
"apps/desktop/electron/native/hud-modifier-gesture.h",
"apps/desktop/electron/native/hud-modifier-gesture.cs",
"apps/desktop/electron/native/hud-modifier-monitor.m",
"apps/desktop/electron/native/hud-modifier-monitor-win.cs",
"apps/desktop/electron/native/hud-modifier-monitor-x11.c",
"apps/desktop/scripts/windows-bundle-tools.mjs", "apps/desktop/scripts/prepared-native-deps.mjs",
"apps/desktop/scripts/prepared-packaging.mjs", "apps/desktop/scripts/prepare-dmgbuild.mjs",
"apps/desktop/scripts/prepare_dmgbuild.py", "apps/desktop/scripts/probe-prepared-native.mjs",
)
def _input_digest(source: Path, lock: dict) -> str:
# A coarse lookup hint, not a second receipt or dependency resolver. Owner
# admission remains necessary even when this digest matches exactly.
files: set[str] = set(_INPUT_FILES)
files.update(str(path.relative_to(source)) for path in (source / "pm").glob("*.py"))
files.update(str(Path(path) / "package.json") for path in lock["packages"]
if "node_modules" not in path.split("/"))
digest = hashlib.sha256()
for name in sorted(files):
path = source / name
digest.update(name.encode() + b"\0")
digest.update(path.read_bytes() if path.is_file() else b"<missing>")
digest.update(b"\0")
return digest.hexdigest()
def _cache_path(path: Path) -> str:
if any(char in str(path) for char in "*?[]!\r\n\0") or path.resolve() != path:
raise ValueError(f"unsafe cache path: {path}")
return str(path)
def describe_cache(source: Path, cache: Path, producer: str, *, work: Path | None = None) -> dict:
source, cache = source.resolve(), cache.resolve()
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}", producer):
raise ValueError("producer must be a nonempty cache namespace")
if os.environ.get("HERMES_HOME") and cache.is_relative_to(Path(os.environ["HERMES_HOME"]).resolve()):
raise ValueError("cache must be separate from HERMES_HOME")
payload_only = producer == "payload-test"
lock = {"packages": {}} if payload_only else json.loads(
(source / "package-lock.json").read_text(encoding="utf-8-sig"))
# node_modules is never transported: npm ci deletes it before installing, so
# only npm's content cache (npm/_cacache) saves work on the next run.
domains = ("tools", "python/runtime", "native") if payload_only else CACHE_DOMAINS
paths = [_cache_path(cache / domain) for domain in domains]
private_roots = {"source": source, "work": work,
"HERMES_HOME": Path(os.environ["HERMES_HOME"]) if os.environ.get("HERMES_HOME") else None}
for name, private in private_roots.items():
if private is not None and any(private.resolve().is_relative_to(Path(path)) for path in paths):
raise ValueError(f"{name} must be outside every reusable cache path")
target = current_target()
host = hashlib.sha256(json.dumps([
platform.platform(), os.environ.get("ImageOS", ""), os.environ.get("ImageVersion", ""),
]).encode()).hexdigest()[:16]
prefix = f"desktop-inputs-v1-{producer}-{target}-{host}-"
input_prefix = f"{prefix}{_input_digest(source, lock)}-"
run = "-".join(os.environ.get(name, default) for name, default in (
("GITHUB_RUN_ID", "local"), ("GITHUB_RUN_ATTEMPT", "1"), ("GITHUB_JOB", "desktop"),
))
return {"target": target, "paths": paths, "restore_keys": [input_prefix, prefix],
"key": input_prefix + run}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("phase", choices=["describe"])
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--cache", type=Path, required=True)
parser.add_argument("--work", type=Path)
parser.add_argument("--producer", required=True)
args = parser.parse_args()
description = describe_cache(args.source, args.cache, args.producer, work=args.work)
if os.environ.get("GITHUB_OUTPUT"):
file_commands("GITHUB_OUTPUT", {
"cache-key": description["key"], "cache-paths": json.dumps(description["paths"]),
"input-prefix": description["restore_keys"][0], "restore-prefix": description["restore_keys"][1],
})
print(json.dumps(description))
if __name__ == "__main__":
main()