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
4 changes: 2 additions & 2 deletions .github/scripts/infra_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down
18 changes: 11 additions & 7 deletions .github/scripts/shipped_bundles.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""The bundles agentenv-framework ships, from its ``agent_env.bundles`` entry points: ``<name> = "<package>"`` ships the
folder ``src/<package as a path>/<name>/``.

``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
Expand All @@ -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.-])")

Expand All @@ -30,8 +33,9 @@ def bundle_roots(repo: Path = REPO) -> dict[str, Path]:


def requirements_files(repo: Path = REPO) -> list[Path]:
"""Every ``<bundle>/<envs|agents|artifacts>/<name>/requirements.txt`` a shipped bundle holds."""
return sorted(path for root in bundle_roots(repo).values() for path in root.glob("*/*/requirements.txt"))
"""Every ``<bundle>/<envs|artifacts>/<name>/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:
Expand All @@ -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)
Expand Down
19 changes: 10 additions & 9 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,17 +277,18 @@ 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.
Once the repository variable `PUBLISH_INFRA_IMAGES` is `true`, it also publishes the infra images agent-env
ships and the Claude Code agent's (`agent_env.env.published_images`) whose `src-<hex>` 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`,
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>:src-<hex>` 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.
Expand Down
5 changes: 3 additions & 2 deletions src/agent_env/agents/agents/claude-code/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/agent_env/agents/agents/pi/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion tst/unit/agents/claude_code/test_image_contract.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
15 changes: 9 additions & 6 deletions tst/unit/test_release_infra_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -117,22 +117,25 @@ 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"}
for job, runs in readers.items():
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():
Expand Down
Loading
Loading