From c1b6e2114fe63e3b851d3e4834d264c5948545fa Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 22 Oct 2026 13:04:46 -0700 Subject: [PATCH] ci(release): build an agent's image only when its folder changes The release pinned the protocol in each shipped agent's requirements.txt, so every bump changed the agent's sources and its src- tag, and a new Claude Code image was built and published each release. Agents now keep their protocol floor (pi's stale ==0.1.305 becomes >=0.1.305); only the shipped envs' and artifacts' requirements are pinned. An agent's image is then built when its folder changes and skipped as published otherwise, and each wheel still records its digest. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/scripts/infra_images.py | 4 +- .github/scripts/shipped_bundles.py | 18 ++-- .github/workflows/publish-pypi.yml | 19 +++-- AGENTS.md | 9 +- CONTRIBUTING.md | 5 +- .../agents/claude-code/requirements.txt | 5 +- .../agents/agents/pi/requirements.txt | 3 +- .../agents/claude_code/test_image_contract.py | 2 +- tst/unit/test_release_infra_images.py | 15 ++-- tst/unit/test_release_protocol_pins.py | 85 +++++++++++-------- 10 files changed, 97 insertions(+), 68 deletions(-) diff --git a/.github/scripts/infra_images.py b/.github/scripts/infra_images.py index 2de4eb0d..2c11bd45 100644 --- a/.github/scripts/infra_images.py +++ b/.github/scripts/infra_images.py @@ -5,8 +5,8 @@ ``plan`` prints each image's name, repository, tag, Dockerfile and context as JSON, ``--missing`` only those the registry hasn't published: it has no image under the tag, or refuses to show one, as GHCR does for a package that doesn't exist yet, so the first publish can start. ``--only infra`` lists only the infra images, and ``--only agents`` -only the agents' images, which install the agentenv-framework-protocol a release ships with, so a release builds them -after it publishes the protocol. ``write PATH`` records the digest the registry serves under every +only the agents' images, which a release builds after it publishes the protocol, as an agent's protocol floor may name +it. ``write PATH`` records the digest the registry serves under every image's tag as a release wheel carries them, and fails if any is missing. ``public`` warns about each image a user, who pulls anonymously, can't. The registry is read with ``GHCR_TOKEN`` (as ``GITHUB_ACTOR``) when it's set, anonymously otherwise, and always anonymously by ``public``. diff --git a/.github/scripts/shipped_bundles.py b/.github/scripts/shipped_bundles.py index 140c4553..b329bd8e 100644 --- a/.github/scripts/shipped_bundles.py +++ b/.github/scripts/shipped_bundles.py @@ -1,10 +1,12 @@ """The bundles agentenv-framework ships, from its ``agent_env.bundles`` entry points: `` = ""`` ships the folder ``src///``. -``pin`` pins agentenv-framework-protocol, keeping its extras, in each shipped env's, agent's and artifact's -requirements.txt to the version ``packages/agentenv-protocol`` is at, which a release ships them with. Every release job -that reads those folders runs it first, so an image's tag, its build and the wheel all see the same sources. It uses only -the standard library, so the jobs that push images can run it. +``pin`` pins agentenv-framework-protocol, keeping its extras, in each shipped env's and artifact's requirements.txt to +the version ``packages/agentenv-protocol`` is at, which a release ships them with. An agent's keeps its floor: a pin is +one of its image's sources, so pinning it would build and publish a new image every release, and with the floor its +image is built only when its folder changes. Every release job that reads those folders runs it first, so an image's +tag, its build and the wheel all see the same sources. It uses only the standard library, so the jobs that push images +can run it. """ from __future__ import annotations @@ -18,6 +20,7 @@ REPO = Path(__file__).resolve().parents[2] BUNDLES = "agent_env.bundles" PROTOCOL = "agentenv-framework-protocol" +_PINNED_KINDS = ("envs", "artifacts") # A requirement on the protocol, with its extras, and not on a package whose name starts with the protocol's. _PROTOCOL_REQUIREMENT = re.compile(rf"^{PROTOCOL}(\[[^\]]*\])?(?![\w.-])") @@ -30,8 +33,9 @@ def bundle_roots(repo: Path = REPO) -> dict[str, Path]: def requirements_files(repo: Path = REPO) -> list[Path]: - """Every ``///requirements.txt`` a shipped bundle holds.""" - return sorted(path for root in bundle_roots(repo).values() for path in root.glob("*/*/requirements.txt")) + """Every ``///requirements.txt`` a shipped bundle holds: those a release pins.""" + return sorted(path for root in bundle_roots(repo).values() for kind in _PINNED_KINDS + for path in root.glob(f"{kind}/*/requirements.txt")) def protocol_version(repo: Path = REPO) -> str: @@ -42,7 +46,7 @@ def pin(repo: Path = REPO) -> list[Path]: """Pin the protocol in each shipped requirements file that names it, and return those files.""" files = requirements_files(repo) if not files: - raise ValueError(f"the bundles {repo / 'pyproject.toml'} declares hold no requirements.txt") + raise ValueError(f"the bundles {repo / 'pyproject.toml'} declares hold no env or artifact requirements.txt") version, pinned = protocol_version(repo), [] for path in files: lines = path.read_text().splitlines(keepends=True) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 4eb0cddb..2785be18 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -11,13 +11,14 @@ name: Publish to PyPI # It also publishes the images agent-env ships (agent_env.env.published_images), its infra images and its agents', # whose tags the registry lacks, and records their digests in the wheel and the release notes, once the repository # variable PUBLISH_INFRA_IMAGES is "true". The infra images are published before agentenv-framework-protocol. The -# agents' images install the protocol the release ships with, so each release builds them anew once the protocol is on -# PyPI. agentenv-framework waits for every image. Every job that reads the shipped bundles' folders first pins the -# protocol in them as the wheel does (.github/scripts/shipped_bundles.py pin), so an image's tag, its build and the -# wheel all see the same sources. Run by hand on main, the workflow publishes the images alone, with nothing sent to -# PyPI; the agents' images then install the protocol version main is at, which must be on PyPI. Only the jobs that read -# run agent-env and its dependencies; the jobs holding a token that pushes images or publishes to PyPI install nothing -# and check what they're handed (.github/scripts/infra_build.py). +# agents' images keep a protocol floor, so each is built only when its folder changes, once the protocol is on PyPI, +# as a raised floor may name the protocol this release ships. agentenv-framework waits for every image. Every job that +# reads the shipped bundles' folders first pins the protocol in their envs and artifacts as the wheel does +# (.github/scripts/shipped_bundles.py pin), so an image's tag, its build and the wheel all see the same sources. Run +# by hand on main, the workflow publishes the images alone, with nothing sent to PyPI; an agent's image then installs +# the newest protocol on PyPI at or above its floor. Only the jobs that read run agent-env and its dependencies; the +# jobs holding a token that pushes images or publishes to PyPI install nothing and check what they're handed +# (.github/scripts/infra_build.py). on: push: @@ -233,8 +234,8 @@ jobs: run: uv publish --trusted-publishing always --check-url https://pypi.org/simple/ dist/* # The agents' images, built, checked and pushed as infra-build does the infra images, once the - # agentenv-framework-protocol they install is on PyPI: every release builds them anew, since the protocol pin in their - # requirements.txt is a source. + # agentenv-framework-protocol they install is on PyPI: an agent's floor may name the one this release ships. An image + # whose folder hasn't changed keeps its tag, which is published already, so no job here runs for it. agent-build: needs: [main-ci, infra-plan, protocol] # On a release, once the protocol is on PyPI; run by hand, once main's CI has passed, as no protocol is published. diff --git a/AGENTS.md b/AGENTS.md index 8daa9e3b..f3aaf341 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -277,8 +277,8 @@ that the runs left no sandbox work folder, and that each published infra image's installed sources as from the checkout. `.github/workflows/publish-pypi.yml` runs on the `v*` tag the release automation pushes: it waits for `main`'s CI to pass on the tagged commit, publishes `agentenv-framework-protocol` and then -`agentenv-framework` to PyPI, pinning the protocol in `pyproject.toml` and in each shipped env's and agent's -`requirements.txt` (`.github/scripts/shipped_bundles.py pin` finds them, so a new agent is pinned with no workflow change, +`agentenv-framework` to PyPI, pinning the protocol in `pyproject.toml` and in each shipped env's and artifact's +`requirements.txt` (`.github/scripts/shipped_bundles.py pin` finds them, so a new env is pinned with no workflow change, and every job that reads those folders runs it first, so an image's tag, its build and the wheel see the same sources), and creates the GitHub release, whose notes are the titles of the pull requests merged since the previous release. @@ -286,8 +286,9 @@ Once the repository variable `PUBLISH_INFRA_IMAGES` is `true`, it also publishes ships and the Claude Code agent's (`agent_env.env.published_images`) whose `src-` tags GHCR lacks: after `main`'s CI passes, each is built on a runner of each platform, checked, and tagged as one multi-platform image, and a tag that exists is never moved. The infra images are published before the protocol, and a failed infra image job sends neither package -to PyPI. The agent's image installs the protocol the release pins, so each release builds it again once the protocol is -on PyPI, and a failed agent image job keeps `agentenv-framework` off it. The wheel and the release notes record each +to PyPI. An agent's `requirements.txt` keeps its protocol floor, unpinned, so its image is built again only when its +folder changes (raise the floor when the agent needs a newer protocol), once the protocol is on PyPI, and a failed agent +image job keeps `agentenv-framework` off it. The wheel and the release notes record each image's digest. Run by hand on `main`, the workflow publishes the images alone. Its jobs that run agent-env (`.github/scripts/infra_images.py`) hold no token that writes; the jobs that push images or publish to PyPI install nothing and check what they're handed (`.github/scripts/infra_build.py`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10c77d64..1a9dbad0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,8 +92,9 @@ A release also publishes the infra images agent-env ships, once the `PUBLISH_INF `true`: one per Dockerfile under `src/agent_env/env`, and the Claude Code agent's (`agentenv-claude-code`, its CLI included), at `ghcr.io/scaleapi/agentenv-:src-` for linux/amd64 and linux/arm64, where the tag is the build context's source digest, so an unchanged Dockerfile is not built again. -The Claude Code image installs the `agentenv-framework-protocol` its release pins, which is one of its sources, so each -release builds it again, once the protocol is on PyPI and before `agentenv-framework` is published. +The Claude Code image installs the newest `agentenv-framework-protocol` at or above the floor in its +`requirements.txt`, which a release doesn't pin, so it is built again only when its folder changes: once the protocol is +on PyPI, as a raised floor may name it, and before `agentenv-framework` is published. Each of them pins its base images by digest, and Dependabot bumps those pins within each base's major and minor version (an upgrade, such as postgres 16 to 18, is made by hand), so a base's fixes reach the published image: a bump changes the sources, and the next release builds it again. diff --git a/src/agent_env/agents/agents/claude-code/requirements.txt b/src/agent_env/agents/agents/claude-code/requirements.txt index 0fbd1aa1..46457490 100644 --- a/src/agent_env/agents/agents/claude-code/requirements.txt +++ b/src/agent_env/agents/agents/claude-code/requirements.txt @@ -1,3 +1,4 @@ -# A release pins agentenv-framework-protocol to the version it was released with. The floor is the first that -# serves peer-agents/v1's tools, which the agent advertises without handlers of its own. +# The image installs the newest agentenv-framework-protocol at or above this floor; a release doesn't pin it, so the +# image is built again only when this folder changes. The floor is the first that serves peer-agents/v1's tools, which +# the agent advertises without handlers of its own; raise it when the agent needs a newer protocol. agentenv-framework-protocol[agent]>=0.1.351 diff --git a/src/agent_env/agents/agents/pi/requirements.txt b/src/agent_env/agents/agents/pi/requirements.txt index 192b1427..8d60e277 100644 --- a/src/agent_env/agents/agents/pi/requirements.txt +++ b/src/agent_env/agents/agents/pi/requirements.txt @@ -1 +1,2 @@ -agentenv-framework-protocol[agent]==0.1.305 +# The image installs the newest agentenv-framework-protocol at or above this floor; a release doesn't pin it. +agentenv-framework-protocol[agent]>=0.1.305 diff --git a/tst/unit/agents/claude_code/test_image_contract.py b/tst/unit/agents/claude_code/test_image_contract.py index e9fd66c0..6445b09d 100644 --- a/tst/unit/agents/claude_code/test_image_contract.py +++ b/tst/unit/agents/claude_code/test_image_contract.py @@ -1,5 +1,5 @@ """The agent's folder is its build context: the Dockerfile copies only its own files, keeps TLS checks on, and -installs the protocol the release pins.""" +installs the protocol at or above its floor, which a release doesn't pin.""" from __future__ import annotations diff --git a/tst/unit/test_release_infra_images.py b/tst/unit/test_release_infra_images.py index bdd26182..1a473b63 100644 --- a/tst/unit/test_release_infra_images.py +++ b/tst/unit/test_release_infra_images.py @@ -100,7 +100,7 @@ def test_a_failed_infra_image_job_sends_neither_package_to_pypi(): def test_the_agents_images_are_built_once_the_protocol_they_install_is_on_pypi(): - """Each installs the version the release pins in its requirements.txt; run by hand, no protocol is published.""" + """An agent's protocol floor may name the version this release publishes; run by hand, none is published.""" assert "protocol" in JOBS["agent-build"]["needs"] assert ("(needs.protocol.result == 'success' || (github.event_name == 'workflow_dispatch' " "&& needs.protocol.result == 'skipped'))") in _condition("agent-build") @@ -117,7 +117,7 @@ def test_the_framework_waits_for_every_image_job(): def test_every_job_that_reads_the_shipped_bundles_pins_the_protocol_first(): - """The pin is a source of the agents' images: a job without it would tag, build, record or ship other sources.""" + """The pin is a source of the shipped envs: a job without it would tag, build, record or ship other sources.""" readers = {job: "\n".join(_runs(job)) for job in JOBS if _READS_SHIPPED_SOURCES.search("\n".join(_runs(job)))} assert set(readers) == {"infra-plan", "infra-build", "agent-build", "infra-record", "framework"} @@ -125,14 +125,17 @@ def test_every_job_that_reads_the_shipped_bundles_pins_the_protocol_first(): assert runs.count(_PIN) == 1 and runs.index(_PIN) < _READS_SHIPPED_SOURCES.search(runs).start(), job -def test_the_images_built_after_the_protocol_are_those_the_pin_changes(): - """An infra image is built before the protocol is on PyPI, so none of them can install the version a release pins.""" +def test_the_pin_changes_no_published_images_sources(): + """A published image is built again only when its folder changes: a protocol pin in it would build a new one every + release, and an infra image, built before the protocol is on PyPI, couldn't install the version pinned.""" shipped_bundles = load_script("shipped_bundles.py") pinned = [path for path in shipped_bundles.requirements_files() if any(line.startswith(shipped_bundles.PROTOCOL) for line in path.read_text().splitlines())] - assert {image.name for image in bootstrap.PUBLISHED_IMAGES - if any(path.is_relative_to(image.dockerfile.parent) for path in pinned)} == {i.name for i in AGENT_IMAGES} + assert pinned + assert not [(image.name, path) for image in bootstrap.PUBLISHED_IMAGES for path in pinned + if path.is_relative_to(image.dockerfile.parent)] + assert AGENT_IMAGES and all(image in bootstrap.PUBLISHED_IMAGES for image in AGENT_IMAGES) def test_the_github_release_is_created_whenever_the_framework_is_published(): diff --git a/tst/unit/test_release_protocol_pins.py b/tst/unit/test_release_protocol_pins.py index 7306b228..851df79b 100644 --- a/tst/unit/test_release_protocol_pins.py +++ b/tst/unit/test_release_protocol_pins.py @@ -1,6 +1,6 @@ """The files ``.github/workflows/publish-pypi.yml`` rewrites to pin agentenv-framework-protocol on a release, the -pyproject with its sed and the shipped bundles' requirements files with ``shipped_bundles.py pin``: each must hold exactly -one protocol requirement, or the release ships an unpinned one.""" +pyproject with its sed and the shipped bundles' env and artifact requirements files with ``shipped_bundles.py pin``: each +must hold exactly one protocol requirement, or the release ships an unpinned one. An agent's keeps its floor.""" from __future__ import annotations @@ -22,49 +22,59 @@ def test_the_pyproject_holds_the_one_quoted_protocol_requirement_the_release_pin def _repo(tmp_path: Path, requirements: dict[str, str]) -> Path: - """A checkout whose protocol is at 1.2.3, shipping the agents bundle with an agent per entry of ``requirements``.""" + """A checkout whose protocol is at 1.2.3, shipping the agents bundle with a ``/`` folder per entry of + ``requirements``.""" (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n\n[project.entry-points."agent_env.bundles"]\n' 'agents = "agent_env"\n') (tmp_path / "packages/agentenv-protocol").mkdir(parents=True) (tmp_path / "packages/agentenv-protocol/pyproject.toml").write_text('[project]\nversion = "1.2.3"\n') - for agent, text in requirements.items(): - folder = tmp_path / "src/agent_env/agents/agents" / agent - folder.mkdir(parents=True) - (folder / "requirements.txt").write_text(text) + for folder, text in requirements.items(): + (tmp_path / "src/agent_env/agents" / folder).mkdir(parents=True) + (tmp_path / "src/agent_env/agents" / folder / "requirements.txt").write_text(text) return tmp_path -def _requirements(repo: Path, agent: str) -> str: - return (repo / "src/agent_env/agents/agents" / agent / "requirements.txt").read_text() +def _requirements(repo: Path, folder: str) -> str: + return (repo / "src/agent_env/agents" / folder / "requirements.txt").read_text() def test_pin_pins_the_protocol_to_the_version_the_release_ships_keeping_its_extras(tmp_path): - repo = _repo(tmp_path, {"floor": "# a comment\nagentenv-framework-protocol[agent]>=0.1.351\nmcp>=1.25,<2\n", - "bare": "agentenv-framework-protocol", - "old": "agentenv-framework-protocol[agent]==0.1.305 # stale\n", - "none": "agentenv-framework-protocol-tools==1\nhttpx\n"}) + repo = _repo(tmp_path, {"envs/floor": "# a comment\nagentenv-framework-protocol[agent]>=0.1.351\nmcp>=1.25,<2\n", + "envs/bare": "agentenv-framework-protocol", + "artifacts/old": "agentenv-framework-protocol[agent]==0.1.305 # stale\n", + "envs/none": "agentenv-framework-protocol-tools==1\nhttpx\n"}) pinned = shipped_bundles.pin(repo) - assert [path.parent.name for path in pinned] == ["bare", "floor", "old"] - assert _requirements(repo, "floor") == "# a comment\nagentenv-framework-protocol[agent]==1.2.3\nmcp>=1.25,<2\n" - assert _requirements(repo, "bare") == "agentenv-framework-protocol==1.2.3" - assert _requirements(repo, "old") == "agentenv-framework-protocol[agent]==1.2.3\n" - assert _requirements(repo, "none") == "agentenv-framework-protocol-tools==1\nhttpx\n" + assert [path.parent.name for path in pinned] == ["old", "bare", "floor"] + assert _requirements(repo, "envs/floor") == "# a comment\nagentenv-framework-protocol[agent]==1.2.3\nmcp>=1.25,<2\n" + assert _requirements(repo, "envs/bare") == "agentenv-framework-protocol==1.2.3" + assert _requirements(repo, "artifacts/old") == "agentenv-framework-protocol[agent]==1.2.3\n" + assert _requirements(repo, "envs/none") == "agentenv-framework-protocol-tools==1\nhttpx\n" + + +def test_an_agent_keeps_its_floor_so_its_image_is_built_only_when_its_folder_changes(tmp_path): + floor = "agentenv-framework-protocol[agent]>=0.1.351\n" + repo = _repo(tmp_path, {"agents/claude-code": floor, "envs/echo": "agentenv-framework-protocol[server]\n"}) + + assert [path.parent.name for path in shipped_bundles.pin(repo)] == ["echo"] + assert _requirements(repo, "agents/claude-code") == floor def test_pin_pinned_again_changes_nothing(tmp_path): """Each release job pins its own checkout, and so do two pins of one.""" - repo = _repo(tmp_path, {"claude-code": "agentenv-framework-protocol[agent]>=0.1.351\n"}) + repo = _repo(tmp_path, {"envs/echo": "agentenv-framework-protocol[server]>=0.1.351\n"}) shipped_bundles.pin(repo) - assert [path.parent.name for path in shipped_bundles.pin(repo)] == ["claude-code"] - assert _requirements(repo, "claude-code") == "agentenv-framework-protocol[agent]==1.2.3\n" + assert [path.parent.name for path in shipped_bundles.pin(repo)] == ["echo"] + assert _requirements(repo, "envs/echo") == "agentenv-framework-protocol[server]==1.2.3\n" -def test_pin_fails_when_the_shipped_bundles_hold_no_requirements_file(tmp_path): - with pytest.raises(ValueError, match="hold no requirements.txt"): - shipped_bundles.pin(_repo(tmp_path, {})) +@pytest.mark.parametrize("requirements", [{}, {"agents/claude-code": "agentenv-framework-protocol[agent]>=0.1.351\n"}], + ids=["none", "agents-only"]) +def test_pin_fails_when_the_shipped_bundles_hold_no_env_or_artifact_requirements_file(tmp_path, requirements): + with pytest.raises(ValueError, match="hold no env or artifact requirements.txt"): + shipped_bundles.pin(_repo(tmp_path, requirements)) def test_the_release_pins_the_shipped_bundles_with_the_script(): @@ -84,21 +94,28 @@ def test_each_shipped_requirements_file_the_release_pins_holds_one_protocol_line if line.startswith("agentenv-framework-protocol")] for path in shipped_bundles.requirements_files()} - assert {"src/agent_env/examples/echo-mcp/envs/echo/requirements.txt", - "src/agent_env/agents/agents/claude-code/requirements.txt"} <= set(pinned) + assert "src/agent_env/examples/echo-mcp/envs/echo/requirements.txt" in pinned assert all(len(lines) == 1 for lines in pinned.values()), pinned -def test_an_agent_added_to_a_shipped_bundle_is_pinned_with_the_rest(tmp_path): +def test_each_shipped_agent_requires_the_protocol_by_a_floor(): + """Nothing pins an agent's at release, so an exact version there would hold its image to that protocol.""" + agents = [path for root in shipped_bundles.bundle_roots().values() for path in root.glob("agents/*/requirements.txt")] + lines = {path.parent.name: [line for line in path.read_text().splitlines() + if line.startswith("agentenv-framework-protocol")] for path in agents} + + assert {"claude-code", "pi"} <= set(lines) + assert all(len(found) == 1 and ">=" in found[0] and "==" not in found[0] for found in lines.values()), lines + + +def test_an_env_added_to_a_shipped_bundle_is_pinned_with_the_rest_and_an_agent_isnt(tmp_path): (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n\n[project.entry-points."agent_env.bundles"]\n' 'agents = "agent_env"\nhello = "agent_env.examples"\n') - (tmp_path / "src/agent_env/examples/hello/tasks").mkdir(parents=True) - for agent in ("claude-code", "another"): - folder = tmp_path / "src/agent_env/agents/agents" / agent - folder.mkdir(parents=True) - (folder / "requirements.txt").write_text("agentenv-framework-protocol[agent]\n") + for folder in ("examples/hello/envs/echo", "examples/hello/envs/another", "agents/agents/claude-code"): + (tmp_path / "src/agent_env" / folder).mkdir(parents=True) + (tmp_path / "src/agent_env" / folder / "requirements.txt").write_text("agentenv-framework-protocol[server]\n") assert [path.relative_to(tmp_path).as_posix() for path in shipped_bundles.requirements_files(tmp_path)] == [ - "src/agent_env/agents/agents/another/requirements.txt", - "src/agent_env/agents/agents/claude-code/requirements.txt", + "src/agent_env/examples/hello/envs/another/requirements.txt", + "src/agent_env/examples/hello/envs/echo/requirements.txt", ]