From 0f6250d27fe08ad6feb3cfe37e8ac995b8c187d9 Mon Sep 17 00:00:00 2001 From: Charlie Masters Date: Tue, 29 Sep 2026 23:42:25 +0100 Subject: [PATCH] build: prepare SDK-owned runtime pin updates --- README.md | 9 +++ scripts/bump_runtime.py | 102 +++++++++++++++++++++++++++++++ src/hai_agents/local/manifest.py | 4 +- tests/test_bump_runtime.py | 38 ++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 scripts/bump_runtime.py create mode 100644 tests/test_bump_runtime.py diff --git a/README.md b/README.md index a62948a..271ef30 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,15 @@ message attachments and exposes files shared by the agent through the runtime: local shared resources expire with the retained session. The limits are 50 MiB per file, 64 MiB and 128 shared files per session. +## Runtime release maintenance + +The SDK runtime manifest is maintained independently of schema generation. After +publishing and verifying a compatible runtime, run `scripts/bump_runtime.py` with +`--version` and one `--sha PLATFORM=SHA256` for every platform already in the +manifest. Partial updates are rejected so a new URL cannot retain an old digest. +The generator preserves this SDK-owned file. The release workflow still opens +legacy CLI pin PRs; retarget it only after the SDK/CLI migration has shipped. + ## How a session works A session is one run of an agent against a task. It moves through a small set of states: `pending`, `running`, and then a settled state such as `completed`, `idle`, `failed`, `timed_out`, or `interrupted`. diff --git a/scripts/bump_runtime.py b/scripts/bump_runtime.py new file mode 100644 index 0000000..47a4732 --- /dev/null +++ b/scripts/bump_runtime.py @@ -0,0 +1,102 @@ +"""Rewrite the pinned hai-agent-runtime version + per-platform sha256 in the SDK runtime manifest.""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +# Stdlib only on purpose: this runs as `python scripts/bump_runtime.py` in a +# checkout with no dependencies installed, so a third-party import would break +# the bump step. +RUNTIME_INSTALL = Path(__file__).parents[1] / "src" / "hai_agents" / "local" / "manifest.py" +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_MANIFEST_FILENAME_RE = re.compile(r'"hai-agent-runtime-([^".]+)\.zip"') + + +@dataclass(frozen=True) +class RuntimeBump: + version: str + shas: dict[str, str] # platform key (e.g. darwin-arm64) -> sha256 hex + + def __post_init__(self) -> None: + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?", self.version): + raise ValueError("runtime version must be a release version, e.g. 0.1.13") + for platform, sha in self.shas.items(): + if not _SHA256_RE.fullmatch(sha) or sha == "0" * 64: + raise ValueError(f"{platform}: {sha!r} is not a lowercase 64-char sha256") + + +def _filename_for(platform: str) -> str: + return f"hai-agent-runtime-{platform}.zip" + + +def _manifest_platforms(source: str) -> set[str]: + """Platform keys that have a published artifact literal in `source`.""" + return set(_MANIFEST_FILENAME_RE.findall(source)) + + +def apply_bump(source: str, bump: RuntimeBump) -> str: + """Return `source` with PINNED_RUNTIME_VERSION and the manifest digests replaced; raises if any anchor is missing.""" + published = _manifest_platforms(source) + extra = bump.shas.keys() - published + if extra: + raise ValueError(f"no manifest entry for platform(s): {sorted(extra)}") + # The version is a single literal feeding every derived URL, so any published + # platform left without a fresh sha would keep a stale digest at the new + # version's URL and fail verification on download. Refuse the partial bump. + missing = published - bump.shas.keys() + if missing: + raise ValueError(f"missing sha for published platform(s): {sorted(missing)}") + + updated, count = re.subn( + r'PINNED_RUNTIME_VERSION = "[^"]*"', + f'PINNED_RUNTIME_VERSION = "{bump.version}"', + source, + ) + if count != 1: + raise ValueError(f"expected exactly one PINNED_RUNTIME_VERSION assignment, found {count}") + + for platform, sha in bump.shas.items(): + filename = _filename_for(platform) + pattern = re.compile(rf'("{re.escape(filename)}",\s*(?:#[^\n]*\n\s*)*")[0-9a-fA-F]{{64}}(")') + updated, count = pattern.subn(rf"\g<1>{sha}\g<2>", updated) + if count != 1: + raise ValueError(f"expected exactly one sha256 literal for {filename}, found {count}") + return updated + + +def _parse_args(argv: list[str]) -> RuntimeBump: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument( + "--sha", + action="append", + required=True, + metavar="PLATFORM=SHA256", + help="per-platform digest, e.g. darwin-arm64= (repeatable)", + ) + args = parser.parse_args(argv) + shas: dict[str, str] = {} + for entry in args.sha: + platform, _, sha = entry.partition("=") + if not platform or not sha: + parser.error(f"--sha must be PLATFORM=SHA256, got {entry!r}") + if platform in shas: + parser.error(f"duplicate --sha for {platform}") + shas[platform] = sha.lower() + return RuntimeBump(version=args.version, shas=shas) + + +def main(argv: list[str]) -> int: + bump = _parse_args(argv) + source = RUNTIME_INSTALL.read_text() + RUNTIME_INSTALL.write_text(apply_bump(source, bump)) + print(f"bumped runtime to {bump.version} ({', '.join(sorted(bump.shas))})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/hai_agents/local/manifest.py b/src/hai_agents/local/manifest.py index e9588f6..a5e83a5 100644 --- a/src/hai_agents/local/manifest.py +++ b/src/hai_agents/local/manifest.py @@ -1,8 +1,8 @@ """Pinned hai-agent-runtime version and per-platform artifact digests. This module is the SDK's single runtime pin: a runtime release bumps -PINNED_RUNTIME_VERSION and MANIFEST here (via the retargeted -release-hai-agent-runtime.yaml pin PR) and nothing else. Artifacts live under an +PINNED_RUNTIME_VERSION and MANIFEST here with scripts/bump_runtime.py. +The release workflow targets the legacy CLI until the consumer migration ships. Artifacts live under an immutable version-scoped CDN prefix, so an edge can never serve stale bytes. Cross-ref: eng_plans/14-06-2026-holodesktop-binary-versioning-autoupdate. """ diff --git a/tests/test_bump_runtime.py b/tests/test_bump_runtime.py new file mode 100644 index 0000000..263311f --- /dev/null +++ b/tests/test_bump_runtime.py @@ -0,0 +1,38 @@ +"""The release updater must never move URLs while retaining a platform's old digest.""" + +import runpy +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.parametrize("complete", [True, False]) +def test_release_pin_update_is_complete_or_leaves_manifest_unchanged(tmp_path, complete): + script = tmp_path / "scripts" / "bump_runtime.py" + manifest = tmp_path / "src" / "hai_agents" / "local" / "manifest.py" + script.parent.mkdir(parents=True) + manifest.parent.mkdir(parents=True) + shutil.copyfile(ROOT / "scripts" / "bump_runtime.py", script) + shutil.copyfile(ROOT / "src" / "hai_agents" / "local" / "manifest.py", manifest) + before = manifest.read_bytes() + original = runpy.run_path(str(manifest))["MANIFEST"] + shas = {platform: f"{index:064x}" for index, platform in enumerate(original, start=1)} + args = [sys.executable, str(script), "--version", "9.8.7"] + for platform, sha in list(shas.items())[: len(shas) if complete else -1]: + args.extend(["--sha", f"{platform}={sha}"]) + result = subprocess.run(args, capture_output=True, text=True) + if not complete: + assert result.returncode != 0 + assert manifest.read_bytes() == before + return + assert result.returncode == 0, result.stderr + updated = runpy.run_path(str(manifest))["MANIFEST"] + assert set(updated) == set(original) + for platform, artifact in updated.items(): + assert artifact.sha256 == shas[platform] + assert artifact.url.endswith(f"/9.8.7/hai-agent-runtime-{platform}.zip")