Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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: ""
55 changes: 55 additions & 0 deletions scripts/bump_version.py
Original file line number Diff line number Diff line change
@@ -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()
119 changes: 119 additions & 0 deletions scripts/check_release.py
Original file line number Diff line number Diff line change
@@ -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()
83 changes: 83 additions & 0 deletions tests/test_release_scripts.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading