diff --git a/.github/workflows/pm-bundle.yml b/.github/workflows/pm-bundle.yml index 81a4995195..6e7c0919dc 100644 --- a/.github/workflows/pm-bundle.yml +++ b/.github/workflows/pm-bundle.yml @@ -125,7 +125,6 @@ jobs: # force-push invalidates mid-run ("not a tree object"). python -m pm.cli bundle \ --out build/agent-payload \ - --cache "$UV_CACHE_DIR" \ --ref HEAD - name: Save warmed Python dependencies even after a staging failure diff --git a/pm/cli.py b/pm/cli.py index 8eac1026c5..b4d44e7894 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -485,7 +485,7 @@ def main(argv=None) -> int: p = sub.add_parser("bundle", help="stage a payload (repo+store+facts+relocatable venv) into --out") p.add_argument("--out", required=True) p.add_argument("--ref", help="git ref for the repo snapshot (default HEAD)") - p.add_argument("--cache", type=Path, help="persistent uv build cache (default: output sibling .uv-cache)") + p.add_argument("--cache", type=Path, help="persistent build cache (default: UV_CACHE_DIR or PM's shared cache)") p.set_defaults(func=cmd_bundle) p = sub.add_parser("status", help="print the latest pm sync receipt (machine-readable)") diff --git a/scripts/bundles/native.py b/scripts/bundles/native.py index 5e15ecaee5..5d28e0bea3 100644 --- a/scripts/bundles/native.py +++ b/scripts/bundles/native.py @@ -80,11 +80,14 @@ def stage_pm_runtime(root: Path, python: Path, repo: Path, *, offline: bool = Fa def stage_native(args) -> int: """Isolate HOME and PM state, but retain the provider's reusable build cache.""" + from pm.packages import uv_cache_dir + out = Path(args.out).resolve() out.mkdir(parents=True, exist_ok=True) (out / "manifest.json").unlink(missing_ok=True) root = Path(__file__).resolve().parents[2] - cache = Path(getattr(args, "cache", None) or os.environ.get("UV_CACHE_DIR") or out.parent / ".uv-cache").resolve() + # Resolve before HOME isolation so the build warms the cache CI saves. + cache = Path(getattr(args, "cache", None) or os.environ.get("UV_CACHE_DIR") or uv_cache_dir()).resolve() base_env = dict(os.environ) if current_target() == "win32-arm64": from scripts.build.windows_deps import prepare_windows_environment diff --git a/tests/scripts/test_bundle_native.py b/tests/scripts/test_bundle_native.py index 4ab2347bcb..df58d5f2ff 100644 --- a/tests/scripts/test_bundle_native.py +++ b/tests/scripts/test_bundle_native.py @@ -191,6 +191,65 @@ def test_staged_cache_installs_built_wheel_without_unsigned_zip(tmp_path): assert probe.stdout.strip() == "installed from cached wheel" +def test_native_dispatch_reuses_pm_cache_offline(tmp_path, monkeypatch): + import pm + from pm.packages import uv_cache_dir + from tests.pm.test_workspace_build_inputs import _wheel + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "setup-pm")) + monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "setup-pm/tools")) + monkeypatch.delenv("UV_CACHE_DIR", raising=False) + monkeypatch.setattr("scripts.build.windows_deps.prepare_windows_environment", lambda **kwargs: dict(kwargs["env"])) + uv = shutil.which("uv") + assert uv, "native bundle test requires uv" + monkeypatch.setattr("pm.client.is_runtime", lambda: True) + monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: (Path(uv), Path(sys.executable))) + cache = uv_cache_dir() + wheels = tmp_path / "wheels" + wheels.mkdir() + _wheel(wheels, "cache_probe", "1.0") + wheel, = wheels.glob("*.whl") + server = ThreadingHTTPServer(("127.0.0.1", 0), partial(SimpleHTTPRequestHandler, directory=str(wheels))) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + requirement = f"cache-probe @ http://127.0.0.1:{server.server_port}/{wheel.name}" + try: + pm.build_requirements_environment( + [requirement], out=tmp_path / "first", explicit=True, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + shutil.rmtree(wheels) + pm.prune_cache(cache) + original = dict(os.environ) + run = subprocess.run + homes = [] + + def child(command, *, cwd, env): + assert Path(env["UV_CACHE_DIR"]) == cache + homes.append(Path(env["HOME"])) + # The server and source wheel are gone. Only the cache restored for + # PM can satisfy this install inside the payload's isolated HOME. + return run([sys.executable, "-c", + "import os, subprocess, sys; from pathlib import Path; import pm, pm._uv; " + "pm.client.is_runtime = lambda: True; " + f"pm._uv._toolchain = lambda **kw: (Path({uv!r}), Path(sys.executable)); " + "python = pm.build_requirements_environment([sys.argv[1]], " + "out=Path(os.environ['HOME'])/'venv', " + "cache=Path(os.environ['UV_CACHE_DIR']), offline=True, explicit=True); " + "subprocess.run([str(python), '-I', '-c', 'import cache_probe'], check=True)", + requirement], cwd=cwd, env=env, check=True) + + monkeypatch.setattr(native.subprocess, "run", child) + for name in ("first-payload", "second-payload"): + assert native.stage_native(SimpleNamespace(out=tmp_path / name, ref="HEAD")) == 0 + assert all(not home.exists() for home in homes) + assert dict(os.environ) == original + + def test_native_dispatch_isolates_process_state_on_real_child_failure(tmp_path, monkeypatch): # Compiler provisioning has its own native test; this probe must stop # at the invalid revision without installing tools on a developer host. @@ -199,7 +258,7 @@ def test_native_dispatch_isolates_process_state_on_real_child_failure(tmp_path, monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "user-tools")) before = dict(os.environ) out = tmp_path / "output" - assert native.stage_native(SimpleNamespace(out=str(out), ref="missing-build-test-ref")) != 0 + assert native.stage_native(SimpleNamespace(out=str(out), ref="missing-build-test-ref", cache=tmp_path / "cache")) != 0 assert dict(os.environ) == before assert not (tmp_path / "user-home").exists() assert not (tmp_path / "user-tools").exists()