"""GitHub Actions file commands around the real, stdlib-only PM bootstrap.""" from __future__ import annotations import argparse import json import os from pathlib import Path import platform import re import sys # The runner invokes this file before the checkout has been installed. if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from pm.lock import Lockfile from pm.paths import lockfile_path from pm.registry import walk from pm.store import current_target _ROOTS = {"python": ["uv"], "node": ["npm"], "all": ["npm", "uv"]} def parse_package_list(value: str) -> list[str]: """Comma-separated extra PM tools (e.g. `ffmpeg`) beyond the toolchain roots.""" if value == "": return [] for name in value.split(","): if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name.strip()): raise argparse.ArgumentTypeError(f"invalid pm tool name: {name!r}") return sorted({name.strip() for name in value.split(",")}) def packages(toolchain: str, extra: list[str] | None = None) -> list[str]: # walk() resolves the deps-first closure, so an extra tool's dependencies # (ffmpeg -> none) come along without listing them. return sorted(package.name for package in walk(_ROOTS[toolchain] + (extra or []))) def file_commands(destination: str, values: dict) -> None: """All exports are single-line values, including JSON-encoded arrays.""" lines = [] for key, value in values.items(): text = str(value) if any(character in key + text for character in "\r\n\0"): raise ValueError(f"invalid GitHub file command: {key!r}") lines.append(f"{key}={text}\n") with open(os.environ[destination], "a", encoding="utf-8") as stream: stream.writelines(lines) def parse_extras(value: str) -> list[str] | None: if value == "": return None message = "extras must be a JSON array of extra names" try: extras = json.loads(value) except ValueError as exc: raise argparse.ArgumentTypeError(message) from exc if not isinstance(extras, list) or any( not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name) for name in extras ): raise argparse.ArgumentTypeError(message) return sorted(set(extras)) def prepare(args) -> None: home = args.home.resolve() lock = Lockfile(lockfile_path()) target = current_target() names = packages(args.toolchain, args.packages) values = { "packages": json.dumps(names), "target": target, "arch": target.split("-")[1], "store": str(home / "tools"), } for name in names: version = lock.version(name) if not version or not lock.artifacts(name, target): raise ValueError(f"{name} has no pinned artifact for {target}") values[f"{name}-version"] = version.partition("+")[0] if name == "python" else version # Cache-key fragment for the tools cache: empty when no extras, so every # existing key stays byte-identical. values["extra-packages"] = "".join(f"-{name}" for name in (args.packages or [])) # The OS image belongs in the uv cache identity: built wheels can link # against its system libraries. Unlike npm's cache, these are not just JS. values["os-version"] = platform.platform() values["bootstrap-python"] = sys.executable file_commands("GITHUB_OUTPUT", values) file_commands("GITHUB_ENV", { "HERMES_HOME": home, "HERMES_RUNTIME_DIR": home / "tools", "PYTHONUTF8": "1", }) def add_path(directories: list[str]) -> None: # The runner prepends each line, so write PM's dependent-first PATH in # reverse. npm must shadow the different npm bundled inside Node. with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as stream: for directory in reversed(list(dict.fromkeys(directories))): if any(character in directory for character in "\r\n\0"): raise ValueError("invalid PATH directory") stream.write(directory + "\n") def python3_alias(python: Path) -> None: if os.name == "nt": import shutil alias = python.with_name("python3.exe") if not alias.exists(): shutil.copy2(python, alias) def archive_inputs(args) -> None: from pm.paths import repo_root from pm.store import Store from scripts.ci.archive_inputs import Archive, pinned_inputs, stage_inputs from scripts.releases import r2 pins = pinned_inputs(repo_root(), target=current_target(), packages=set(packages(args.toolchain, args.packages))) stage_inputs(pins, archive=Archive(*r2.credentials()), store=Store(args.home.resolve() / "tools")) def install(args) -> None: import subprocess from pm import build_requirements_environment from pm.cli import _live_progress from pm.install import ensure, env_for from pm.lock import Facts from pm.package import compose_env from pm.packages import uv_cache_dir from pm.paths import facts_path, store_root from pm.registry import get_package names = packages(args.toolchain, args.packages) for name in _ROOTS[args.toolchain] + args.packages: ensure(name, explicit=True, progress=_live_progress(name)) facts = Facts(facts_path()) target = current_target() public_names = [name for name in names if name != "uv"] binaries = { name: get_package(name).binary(store_root() / facts.get(name)["entry"], target) for name in public_names } environment = env_for(*public_names, base_env={}) path = environment["PATH"].split(os.pathsep) environment = compose_env([environment]) exported = {} outputs = {f"{name}-path": str(binaries[name]) for name in public_names} if "python" in names: # uv is PM-internal (never on a user's PATH), but the test suites this # toolchain serves drive real uv through shutil.which("uv"). uv = get_package("uv").binary(store_root() / facts.get("uv")["entry"], target) path.append(str(uv.parent)) outputs["uv-path"] = str(uv) # Keep third-party CI tooling out of the verified interpreter store. # PM prepares the empty command environment through its normal builder. commands = args.home.resolve() / "python" / facts.get("python")["entry"] if not (commands / "pyvenv.cfg").is_file(): build_requirements_environment([], out=commands, python=binaries["python"], env=environment, explicit=True) python = commands / ("Scripts/python.exe" if os.name == "nt" else "bin/python") python3_alias(python) path.insert(0, str(python.parent)) outputs["python-path"] = str(python) exported.update({ "HERMES_PYTHON": python, }) outputs["uv-cache-path"] = str(uv_cache_dir()) if "npm" in names: cache = subprocess.check_output( [str(binaries["npm"]), "config", "get", "cache"], env=environment, text=True, encoding="utf-8", timeout=60, ).strip() outputs["npm-cache-path"] = cache file_commands("GITHUB_ENV", exported) file_commands("GITHUB_OUTPUT", outputs) add_path(path) print("PM toolchain ready: " + ", ".join(f"{name} {facts.get(name)['version']}" for name in names)) def dependencies(args) -> None: if args.extras is None and not args.test_environment: return import tomllib from pm.environments import selected_venv from pm import check_project_lock, sync_venv from pm.plugin_inputs import Members from pm.paths import repo_root project = repo_root() metadata = tomllib.loads((project / "pyproject.toml").read_text(encoding="utf-8-sig")) extras = args.extras or [] unknown = set(extras) - metadata["project"].get("optional-dependencies", {}).keys() if unknown: raise ValueError(f"unknown project extras: {sorted(unknown)}") # Frozen sync must not turn a stale project lock into a green job. check_project_lock(project, explicit=True) if args.test_environment: from pm.testenv import ensure_testenv # CI and activation select the same side environment, never PM facts. python = ensure_testenv(project, extras) venv = python.parent.parent else: sync_venv(extras, explicit=True, plugins=Members([])) venv = selected_venv(project) bindir = venv / ("Scripts" if os.name == "nt" else "bin") python = bindir / ("python.exe" if os.name == "nt" else "python") python3_alias(python) file_commands("GITHUB_ENV", { "HERMES_PYTHON": python, "VIRTUAL_ENV": venv, "PYTHONPATH": project, }) file_commands("GITHUB_OUTPUT", {"python-path": python, "venv": venv}) add_path([str(bindir)]) print(f"PM dependencies ready: {args.extras} in {venv}") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) phases = {"prepare": prepare, "archive-inputs": archive_inputs, "install": install, "dependencies": dependencies} parser.add_argument("phase", choices=list(phases)) parser.add_argument("--toolchain", choices=["python", "node", "all"], default="python") parser.add_argument("--home", type=Path, required=True) parser.add_argument("--extras", type=parse_extras, default="") parser.add_argument("--test-environment", action="store_true") parser.add_argument("--packages", type=parse_package_list, default=[], help="extra PM tools beyond the toolchain roots (e.g. ffmpeg)") args = parser.parse_args() if args.toolchain == "node" and (args.extras is not None or args.test_environment): parser.error("Python dependencies require the python or all toolchain") os.environ["HERMES_HOME"] = str(args.home.resolve()) os.environ["HERMES_RUNTIME_DIR"] = str(args.home.resolve() / "tools") phases[args.phase](args) if __name__ == "__main__": main()