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.
141 lines
6.2 KiB
Python
141 lines
6.2 KiB
Python
"""Job-local native preparation admission; never a portable environment cache."""
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from dataclasses import asdict
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
from pm.store import current_target, tree_digest
|
|
from scripts.build.inputs import AgentInputs, RESOURCE_ENV
|
|
|
|
|
|
def prepared_path(out: Path) -> Path:
|
|
return out.with_name(out.name + ".prepared.json")
|
|
|
|
|
|
@contextmanager
|
|
def preparation_lock(out: Path):
|
|
from hermes_cli.runtime_state import _lock
|
|
from pm.filesystem import is_junction
|
|
|
|
if out != out.resolve():
|
|
raise ValueError("symlinked native output")
|
|
for name in ("hermes-agent", "tools", "venv", "pm-runtime", "uv-cache", "bin",
|
|
"enabled-features.json", "manifest.json"):
|
|
path = out / name
|
|
if path.is_symlink() or (path.exists() and is_junction(path)):
|
|
raise ValueError(f"symlinked native output: {path}")
|
|
with out.with_name(out.name + ".prepare.lock").open("a+b") as lock:
|
|
if not _lock(lock.fileno(), wait=False):
|
|
raise ValueError("native output is already in use")
|
|
yield
|
|
|
|
|
|
def _digest(path: Path) -> str:
|
|
return tree_digest(path) if path.is_dir() else hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def _source_digest(code: Path) -> str:
|
|
"""Ignore only assembly-owned products, leaving admitted source immutable."""
|
|
project = tomllib.loads((code / "pyproject.toml").read_text(encoding="utf-8-sig"))["project"]
|
|
dist = re.sub(r"[-_.]+", "_", project["name"])
|
|
generated = {"install-stamp.json", "hermes_cli/tui_dist", "hermes_cli/web_dist",
|
|
f"{dist}-{project['version']}.dist-info"}
|
|
files = {}
|
|
for directory, dirs, names in os.walk(code):
|
|
dirs[:] = [name for name in dirs if name != "__pycache__"
|
|
and (Path(directory) / name).relative_to(code).as_posix() not in generated]
|
|
for name in dirs:
|
|
path = Path(directory) / name
|
|
if path.is_symlink():
|
|
files[path.relative_to(code).as_posix()] = os.readlink(path)
|
|
for name in names:
|
|
path = Path(directory) / name
|
|
relative = path.relative_to(code).as_posix()
|
|
if relative not in generated:
|
|
files[relative] = os.readlink(path) if path.is_symlink() else _digest(path)
|
|
return hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()
|
|
|
|
|
|
def _owned(path: Path, out: Path) -> None:
|
|
if not path.is_absolute() or not path.exists() or not path.resolve().is_relative_to(out):
|
|
raise ValueError(f"missing or escaped prepared path: {path}")
|
|
|
|
|
|
def _check_links(path: Path, out: Path) -> None:
|
|
from pm.filesystem import is_junction
|
|
|
|
for directory, dirs, files in os.walk(path):
|
|
for name in dirs + files:
|
|
entry = Path(directory) / name
|
|
if entry.is_symlink() or is_junction(entry):
|
|
_owned(entry, out)
|
|
|
|
|
|
def _input_paths(inputs: AgentInputs, out: Path) -> list[Path]:
|
|
expected = {"code": out / "hermes-agent", "project": out / "hermes-agent/pyproject.toml",
|
|
"tools": out / "tools", "environment": out / "venv", "pm_runtime": out / "pm-runtime",
|
|
"features": out / "enabled-features.json"}
|
|
if (any(getattr(inputs, key) != path for key, path in expected.items())
|
|
or inputs.placement != "contained" or inputs.repo != "hermes-agent"
|
|
or inputs.frontends or inputs.stamp or inputs.command_dir or inputs.env
|
|
or inputs.resources != {name: inputs.code / name for name in RESOURCE_ENV}
|
|
or not inputs.site_packages.is_relative_to(inputs.environment)):
|
|
raise ValueError("native prepared input layout changed")
|
|
paths = [inputs.code, inputs.tools, inputs.environment, inputs.pm_runtime,
|
|
inputs.python, inputs.features, out / "uv-cache"]
|
|
for path in [inputs.project, inputs.site_packages, *inputs.resources.values(), *paths]:
|
|
_owned(path, out)
|
|
return paths
|
|
|
|
|
|
def publish_prepared(out: Path, source: Path, revision: str, inputs: AgentInputs) -> Path:
|
|
from hermes_cli.runtime_state import _atomic_bytes
|
|
from pm.paths import lockfile_path
|
|
|
|
inputs.validate(out)
|
|
paths = _input_paths(inputs, out)
|
|
digests = {}
|
|
for path in paths:
|
|
_owned(path, out)
|
|
_check_links(path, out)
|
|
digests[path.relative_to(out).as_posix()] = _source_digest(path) if path == inputs.code else _digest(path)
|
|
data = {"schema": 1, "out": str(out), "source": str(source), "revision": revision,
|
|
"lock": _digest(lockfile_path()), "inputs": asdict(inputs), "digests": digests}
|
|
prepared = prepared_path(out)
|
|
_atomic_bytes(prepared, (json.dumps(data, default=str, indent=2) + "\n").encode())
|
|
return prepared
|
|
|
|
|
|
def load_prepared(prepared: Path) -> AgentInputs:
|
|
from pm.paths import lockfile_path
|
|
|
|
try:
|
|
data = json.loads(prepared.read_text(encoding="utf-8-sig"))
|
|
out = Path(data["out"])
|
|
if (prepared.is_symlink() or out != out.resolve() or data["schema"] != 1
|
|
or prepared != prepared_path(out) or data["lock"] != _digest(lockfile_path())
|
|
or not re.fullmatch(r"[a-f0-9]{40}", data["revision"])):
|
|
raise ValueError("native preparation identity changed")
|
|
inputs = AgentInputs.from_dict(data["inputs"])
|
|
if inputs.target != current_target() or not inputs.ref:
|
|
raise ValueError("native preparation target/ref changed")
|
|
inputs.validate(out)
|
|
required = {path.relative_to(out).as_posix() for path in _input_paths(inputs, out)}
|
|
if set(data["digests"]) != required:
|
|
raise ValueError("native preparation inventory changed")
|
|
for name, digest in data["digests"].items():
|
|
path = out / name
|
|
_owned(path, out)
|
|
_check_links(path, out)
|
|
actual = _source_digest(path) if path == inputs.code else _digest(path)
|
|
if actual != digest:
|
|
raise ValueError(f"prepared bytes changed: {name}")
|
|
return inputs
|
|
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
raise ValueError(f"invalid native preparation; run preparation again: {exc}") from exc |