From f0b1f06b3e1823ee78e265ebe0e4a86f909e2ffe Mon Sep 17 00:00:00 2001 From: abonneth Date: Mon, 19 Oct 2026 13:43:38 +0200 Subject: [PATCH] Release on merge: rolling release PR, publish gated on prod and runtime compatibility Co-authored-by: Cursor --- .github/workflows/publish.yml | 11 +++- .github/workflows/release.yml | 86 ++++++++++++++++++++++++ scripts/bump_version.py | 55 ++++++++++++++++ scripts/check_release.py | 119 ++++++++++++++++++++++++++++++++++ tests/test_release_scripts.py | 83 ++++++++++++++++++++++++ 5 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/bump_version.py create mode 100644 scripts/check_release.py create mode 100644 tests/test_release_scripts.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8412528..be000a9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,8 +62,17 @@ jobs: spawn_env={"HAI_AGENT_RUNTIME_RECIPE": "shared"}).shutdown() ' + api-compat: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + + - name: Prod serves every operation and the pinned runtime accepts every session field + run: python3 scripts/check_release.py --runtime-pin src/hai_agents_local/runtime/pin.json + publish: - needs: [build, runtime-pin] + needs: [build, runtime-pin, api-compat] runs-on: ubuntu-latest timeout-minutes: 10 environment: release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fbdd04a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,86 @@ +name: Release + +# Each push to main either releases its version (no vX.Y.Z tag yet), or keeps one rolling +# "Release X.Y.Z" PR that bumps the patch over the unreleased changes. Merging that PR is the +# release decision; Publish then waits for the `release` environment approval. +# Runs as the hai-runtime-release GitHub App (HAI_RUNTIME_RELEASE_APP_ID var, +# HAI_RUNTIME_RELEASE_APP_PRIVATE_KEY secret): releases and PRs made with GITHUB_TOKEN +# trigger no workflows, so Publish and CI would never run. + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Mint hai-agents-python token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.HAI_RUNTIME_RELEASE_APP_ID }} + private-key: ${{ secrets.HAI_RUNTIME_RELEASE_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Plan + id: plan + run: | + version="$(python3 scripts/bump_version.py --current)" + if ! git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then + action=release + elif git diff --quiet "v${version}" HEAD -- src pyproject.toml README.md; then + action=none + else + action=stage + fi + echo "v${version}: ${action}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "action=${action}" >> "$GITHUB_OUTPUT" + + - name: Release v${{ steps.plan.outputs.version }} + if: steps.plan.outputs.action == 'release' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TAG: v${{ steps.plan.outputs.version }} + run: gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --generate-notes + + - name: Close the stale release PR + if: steps.plan.outputs.action != 'stage' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + for pr in $(gh pr list --head release/next --state open --json number --jq '.[].number'); do + gh pr close "$pr" --delete-branch + done + + - name: Bump the patch version + id: bump + if: steps.plan.outputs.action == 'stage' + run: echo "version=$(python3 scripts/bump_version.py)" >> "$GITHUB_OUTPUT" + + - uses: peter-evans/create-pull-request@v8 + if: steps.plan.outputs.action == 'stage' + with: + token: ${{ steps.app-token.outputs.token }} + branch: release/next + delete-branch: true + add-paths: | + pyproject.toml + uv.lock + src/hai_agents/core/client_wrapper.py + commit-message: "Release ${{ steps.bump.outputs.version }}" + title: "Release ${{ steps.bump.outputs.version }}" + body: "" diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..427363c --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,55 @@ +"""Print the SDK version, or bump its patch in every file that stamps it.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +_V = r"\d+\.\d+\.\d+" +STAMPS = { + Path("pyproject.toml"): [re.compile(rf'^(version = ")({_V})(")', re.MULTILINE)], + Path("uv.lock"): [re.compile(rf'(name = "hai-agents"\nversion = ")({_V})(")')], + Path("src/hai_agents/core/client_wrapper.py"): [ + re.compile(rf'("User-Agent": "hai_agents/)({_V})(")'), + re.compile(rf'("X-HCompany-Client-Version": ")({_V})(")'), + ], +} + + +def current(root: Path = ROOT) -> str: + match = STAMPS[Path("pyproject.toml")][0].search((root / "pyproject.toml").read_text()) + if match is None: + raise SystemExit("pyproject.toml has no version") + return match.group(2) + + +def bump(root: Path = ROOT) -> str: + """Raise the patch version everywhere; fails before writing if any stamp is missing or out of step.""" + old = current(root) + major, minor, patch = old.split(".") + new = f"{major}.{minor}.{int(patch) + 1}" + rewritten: dict[Path, str] = {} + for relative, patterns in STAMPS.items(): + text = (root / relative).read_text() + for pattern in patterns: + found = pattern.findall(text) + if [stamp for _, stamp, _ in found] != [old]: + raise SystemExit(f"{relative}: expected one {pattern.pattern!r} stamped {old}, found {found}") + text = pattern.sub(rf"\g<1>{new}\g<3>", text) + rewritten[relative] = text + for relative, text in rewritten.items(): + (root / relative).write_text(text) + return new + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--current", action="store_true", help="print the version without bumping") + args = parser.parse_args() + print(current() if args.current else bump()) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_release.py b/scripts/check_release.py new file mode 100644 index 0000000..4f4a5de --- /dev/null +++ b/scripts/check_release.py @@ -0,0 +1,119 @@ +"""Block a release whose API spec needs operations prod lacks, or session fields the pinned runtime does not accept.""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PROD_SPECS = ("https://agp.hcompany.ai/share/openapi.json", "https://agp.eu.hcompany.ai/share/openapi.json") +RUNTIME_SPEC = "https://assets.hcompanyprod.fr/hai-agent-runtime/{}/openapi.json" +# The local runtime answers every other resource with 501, so only sessions carry fields it must accept. +RUNTIME_RESOURCE = "/api/v2/sessions" +METHODS = ("get", "post", "put", "patch", "delete") + +Spec = dict[str, Any] +Schema = dict[str, Any] + + +def operations(spec: Spec) -> dict[str, Schema]: + return { + f"{method.upper()} {path}": op + for path, item in spec.get("paths", {}).items() + for method, op in item.items() + if method in METHODS + } + + +def fields(spec: Spec, schema: Schema, prefix: str = "", seen: frozenset[str] = frozenset()) -> set[str]: + """Every property path `schema` accepts, union branches merged, array items as `[]`.""" + ref = schema.get("$ref") + if ref is not None: + if ref in seen: + return set() + seen |= {ref} + schema = spec + for part in ref.removeprefix("#/").split("/"): + schema = schema[part] + found: set[str] = set() + for key in ("anyOf", "oneOf", "allOf"): + for branch in schema.get(key, []): + found |= fields(spec, branch, prefix, seen) + for key, suffix in (("items", "[]"), ("additionalProperties", ".*")): + if isinstance(schema.get(key), dict): + found |= fields(spec, schema[key], prefix + suffix, seen) + for name, sub in schema.get("properties", {}).items(): + path = f"{prefix}.{name}" if prefix else name + found |= {path} | fields(spec, sub, path, seen) + return found + + +def _body_fields(spec: Spec, op: Schema) -> set[str]: + schema = op.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema") + return fields(spec, schema) if schema else set() + + +def missing_operations(sdk: Spec, server: Spec) -> list[str]: + return sorted(operations(sdk).keys() - operations(server).keys()) + + +def unaccepted_session_fields(sdk: Spec, runtime: Spec) -> list[str]: + served = operations(runtime) + return sorted( + f"{name}: {field}" + for name, op in operations(sdk).items() + if name.split(" ", 1)[1].startswith(RUNTIME_RESOURCE) and name in served + for field in _body_fields(sdk, op) - _body_fields(runtime, served[name]) + ) + + +def _fetch(url: str) -> Spec | None: + try: + with urllib.request.urlopen(url, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if error.code in (403, 404): + return None + raise + + +def problems(sdk: Spec, runtime_version: str | None) -> list[str]: + found: list[str] = [] + for url in PROD_SPECS: + prod = _fetch(url) + if prod is None: + raise SystemExit(f"cannot read {url}") + found += [f"{url} does not serve {op}" for op in missing_operations(sdk, prod)] + if runtime_version: + runtime = _fetch(RUNTIME_SPEC.format(runtime_version)) + if runtime is None: + print(f"::warning::hai-agent-runtime {runtime_version} has no published openapi.json; skipping its check") + else: + found += [ + f"hai-agent-runtime {runtime_version} does not accept {f}" + for f in unaccepted_session_fields(sdk, runtime) + ] + return found + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--spec", type=Path, default=ROOT / "openapi.json") + parser.add_argument("--runtime-pin", type=Path, help="pin.json naming the hai-agent-runtime the release bundles") + args = parser.parse_args() + runtime_version = json.loads(args.runtime_pin.read_text())["version"] if args.runtime_pin else None + found = problems(json.loads(args.spec.read_text()), runtime_version) + for problem in found: + print(f"::error::{problem}") + if found: + sys.exit(1) + print("release is compatible with prod" + (f" and hai-agent-runtime {runtime_version}" if runtime_version else "")) + + +if __name__ == "__main__": + main() diff --git a/tests/test_release_scripts.py b/tests/test_release_scripts.py new file mode 100644 index 0000000..25b86a2 --- /dev/null +++ b/tests/test_release_scripts.py @@ -0,0 +1,83 @@ +"""A release bumps every version stamp together and refuses specs that prod or the pinned runtime cannot serve.""" + +import runpy +import shutil +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +bump_version = runpy.run_path(str(ROOT / "scripts" / "bump_version.py")) +check_release = runpy.run_path(str(ROOT / "scripts" / "check_release.py")) + + +def _copy_stamped_files(root: Path) -> None: + for relative in bump_version["STAMPS"]: + (root / relative).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / relative, root / relative) + + +def test_bump_moves_every_stamp_to_the_next_patch(tmp_path): + _copy_stamped_files(tmp_path) + major, minor, patch = bump_version["current"](tmp_path).split(".") + + new = bump_version["bump"](tmp_path) + + assert new == f"{major}.{minor}.{int(patch) + 1}" + for relative, patterns in bump_version["STAMPS"].items(): + text = (tmp_path / relative).read_text() + assert all([stamp for _, stamp, _ in pattern.findall(text)] == [new] for pattern in patterns), relative + + +def test_bump_writes_nothing_when_a_stamp_is_out_of_step(tmp_path): + _copy_stamped_files(tmp_path) + wrapper = tmp_path / "src/hai_agents/core/client_wrapper.py" + wrapper.write_text(wrapper.read_text().replace('"X-HCompany-Client-Version": "', '"X-HCompany-Client-Version": "9')) + before = {relative: (tmp_path / relative).read_text() for relative in bump_version["STAMPS"]} + + with pytest.raises(SystemExit): + bump_version["bump"](tmp_path) + + assert {relative: (tmp_path / relative).read_text() for relative in bump_version["STAMPS"]} == before + + +def _spec(paths: dict, schemas: dict | None = None) -> dict: + return {"paths": paths, "components": {"schemas": schemas or {}}} + + +def _post(schema: dict) -> dict: + return {"post": {"requestBody": {"content": {"application/json": {"schema": schema}}}}} + + +def test_operations_prod_does_not_serve_block_the_release(): + sdk = _spec({"/api/v2/agents": {"get": {}}, "/api/v2/agents/{name}/restore": {"post": {}}}) + prod = _spec({"/api/v2/agents": {"get": {}}}) + + assert check_release["missing_operations"](sdk, prod) == ["POST /api/v2/agents/{name}/restore"] + + +def test_session_fields_the_runtime_does_not_accept_block_the_release(): + environment = {"properties": {"kind": {"type": "string"}, "settle_s": {"type": "number"}}} + agent = { + "properties": { + "environments": { + "type": "array", + "items": {"oneOf": [{"type": "string"}, {"$ref": "#/components/schemas/Env"}]}, + }, + "subagents": {"type": "array", "items": {"$ref": "#/components/schemas/Agent"}}, + } + } + session = {"properties": {"agent": {"anyOf": [{"type": "string"}, {"$ref": "#/components/schemas/Agent"}]}}} + sdk = _spec( + {"/api/v2/sessions": _post(session), "/api/v2/environments": _post({"$ref": "#/components/schemas/Env"})}, + {"Agent": agent, "Env": environment}, + ) + old_environment = {"properties": {"kind": {"type": "string"}}} + runtime = _spec( + {"/api/v2/sessions": _post(session), "/api/v2/environments": _post({"$ref": "#/components/schemas/Env"})}, + {"Agent": agent, "Env": old_environment}, + ) + + assert check_release["unaccepted_session_fields"](sdk, runtime) == [ + "POST /api/v2/sessions: agent.environments[].settle_s", + ]