Files
hermes-agent/scripts/bundles/native_build.py
ethernet 2efa4ff94f refactor(desktop): prepare dependencies before saving build caches
Dependency acquisition during packaging left native wheels and packager
inputs outside the pre-build cache save. Compose PM and existing providers
into a preparation phase, then require builds to consume admitted inputs.

Share native preparation with PM Bundle. Keep path-bound environments and
signing outputs separate from reusable caches. Use read-only cache tokens
for commit builds and preserve the one-command local build path.

Verify pinned tools through PM, probe PTYs under the prepared Electron,
and supply dmgbuild through a build-only PM package. Resolve bundled tool
stores from their payload manifest so relocation preserves discovery.

Validation: focused Python and JS tests, checkJs, Ruff, Windows checks,
anti-slop, cache relocation, and network-denied Linux AppImage builds.
Relocated runtime smoke passed with NixOS host libraries supplied.
Native Windows/macOS signing and live GitHub cache behavior remain untested.
2026-09-13 14:28:31 -04:00

115 lines
5.1 KiB
Python

"""Prepare or consume the standalone PM payload, without desktop products."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def prepare(source: Path, work: Path, cache: Path, out: Path, ref: str) -> Path:
from hermes_cli.runtime_state import _lock
from scripts.bundles.desktop_prepare import git, require_source
from scripts.bundles.desktop_toolchain import run_preparation
from scripts.bundles.native_prepared import prepared_path
from pm.lock import _write
for path in (work, cache, out):
if path.absolute() != path.resolve():
raise ValueError("native build roots must not be symlinked")
source, work, cache, out = (path.resolve() for path in (source, work, cache, out))
revision = git(source, "rev-parse", "--verify", f"{ref}^{{commit}}")
require_source(source, revision)
for destination in (work, cache, out):
if source.is_relative_to(destination):
raise ValueError("native build output must not contain the source checkout")
for left, right in ((work, cache), (out, cache), (work, out)):
if left.is_relative_to(right) or right.is_relative_to(left):
raise ValueError("native work, cache and output must be separate directories")
owner = work / ".native-preparation"
if work.exists():
if not owner.is_file() or owner.read_text(encoding="utf-8-sig") != str(source):
raise ValueError("native work directory is not owned by this checkout")
else:
work.mkdir(parents=True)
owner.write_text(str(source), encoding="utf-8")
with (work / ".lock").open("a+b") as lock:
if not _lock(lock.fileno(), wait=False):
raise ValueError("native preparation work directory is already in use")
result = prepared_path(out)
result.unlink(missing_ok=True)
request = work / "request.json"
_write(request, {"source": str(source), "work": str(work), "cache": str(cache),
"out": str(out), "ref": revision})
status = run_preparation(source, work, cache, request, worker=Path(__file__).resolve())
if status:
result.unlink(missing_ok=True)
raise RuntimeError(f"native preparation failed (exit {status})")
if not result.is_file():
raise RuntimeError("native preparation did not publish its result")
return result
def prepare_in_worker(request: dict) -> Path:
from scripts.bundles.desktop_prepare import require_source
from scripts.bundles.desktop_toolchain import prepare_tools
from scripts.bundles.native import prepare_native
source, work, cache, out = (Path(request[name]) for name in ("source", "work", "cache", "out"))
require_source(source, request["ref"])
_, _, env = prepare_tools(source, work, cache, os.environ)
return prepare_native(out=out, ref=request["ref"], source=source,
cache=Path(env["UV_CACHE_DIR"]), tools=cache / "tools", env=env)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path)
parser.add_argument("--work", type=Path)
parser.add_argument("--cache", type=Path)
parser.add_argument("--out", type=Path)
selection = parser.add_mutually_exclusive_group()
selection.add_argument("--ref")
selection.add_argument("--commit")
parser.add_argument("--prepare-only", action="store_true")
parser.add_argument("--prepared", type=Path)
parser.add_argument("--request", type=Path, help=argparse.SUPPRESS)
parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
args = parser.parse_args()
try:
if args.worker:
if not args.request:
parser.error("worker requires --request")
prepare_in_worker(json.loads(args.request.read_text(encoding="utf-8-sig")))
return 0
if args.request:
parser.error("--request is worker-only")
if args.prepared:
if any((args.source, args.work, args.cache, args.out, args.ref, args.commit, args.prepare_only)):
parser.error("--prepared supplies the complete native build request")
prepared = args.prepared.absolute()
else:
if not all((args.source, args.work, args.cache, args.out)):
parser.error("preparation requires --source, --work, --cache and --out")
if args.commit:
from scripts.releases.commit_build import require_commit
require_commit(args.commit)
prepared = prepare(args.source, args.work, args.cache, args.out, args.commit or args.ref or "HEAD")
if args.prepare_only:
print(prepared)
return 0
from scripts.bundles.native import finish_native
return finish_native(prepared, {})
except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as exc:
parser.exit(1, f"native build: {exc}\n")
if __name__ == "__main__":
raise SystemExit(main())