diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cab509e8..bd63201a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,6 +38,7 @@ updates: - "/src/agent_env/env/gateway" - "/src/agent_env/env/envs/service_db" - "/src/agent_env/env/envs/website_browser" + - "/src/agent_env/agents/agents/claude-code" schedule: interval: "weekly" open-pull-requests-limit: 5 diff --git a/.github/scripts/infra_build.py b/.github/scripts/infra_build.py index 538c2b35..62bffb4b 100644 --- a/.github/scripts/infra_build.py +++ b/.github/scripts/infra_build.py @@ -1,6 +1,7 @@ -"""What the release workflow's jobs that hold a token to push images or publish to PyPI run for the infra images. It uses -only the standard library and the docker CLI, so those jobs install nothing; what they're handed by the jobs that run -agent-env (``infra_images.py``) is checked against the images agent-env ships before it's built, pushed or recorded. +"""What the release workflow's jobs that hold a token to push images or publish to PyPI run for the images agent-env +publishes, its infra images and the Claude Code agent's. It uses only the standard library and the docker CLI, so those +jobs install nothing; what they're handed by the jobs that run agent-env (``infra_images.py``) is checked against the +images agent-env ships before it's built, pushed or recorded. ``check ENTRY`` fails unless ENTRY, a JSON object as ``infra_images.py plan`` lists an image, names an image agent-env ships, with its Dockerfile, context, repository and a source tag. ``exists REF`` exits 0 when the registry has REF, 1 @@ -29,6 +30,7 @@ "agentenv-db-web": "src/agent_env/env/envs/service_db/Dockerfile.db-web", "agentenv-db-mcp": "src/agent_env/env/envs/service_db/Dockerfile.db-mcp", "agentenv-website-browser": "src/agent_env/env/envs/website_browser/Dockerfile", + "agentenv-claude-code": "src/agent_env/agents/agents/claude-code/Dockerfile", } _TAG = re.compile(r"^src-[0-9a-f]{64}$") _DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") @@ -42,8 +44,8 @@ # How long to wait before each try again, so a burst of builds starting together spreads out. _RATE_LIMIT_WAITS_SECONDS = (30, 60, 120) -# Each image's check, beyond its build: the gateway imports its server, Postgres turns healthy, and the sidecars and the -# browser answer their own commands. +# Each image's check, beyond its build: the gateway imports its server, Postgres turns healthy, the sidecars and the +# browser answer their own commands, and Claude Code's agent runs its CLI and imports its server. _SMOKES = { "agentenv-gateway": ["docker", "run", "--rm", "--entrypoint", "python", "{image}", "-c", "import gateway.entrypoint"], "agentenv-db-web": ["docker", "run", "--rm", "--entrypoint", "/usr/bin/pgweb", "{image}", "--version"], @@ -51,6 +53,8 @@ "agentenv-website-browser": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", 'npx --no-install @playwright/mcp@"$PLAYWRIGHT_MCP_VERSION" --help >/dev/null' " && ls -d /root/.cache/ms-playwright/chromium-*"], + "agentenv-claude-code": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", + 'claude --version && python -c "import a2a_server"'], } diff --git a/.github/scripts/infra_images.py b/.github/scripts/infra_images.py index 37eff140..2de4eb0d 100644 --- a/.github/scripts/infra_images.py +++ b/.github/scripts/infra_images.py @@ -1,10 +1,12 @@ -"""The infra images agent-env publishes (``agent_env.env.published_images``), for the release workflow's jobs that only -read: this imports agent-env, so it runs where agent-env and its dependencies are installed, and no job that holds a -token to push images or publish to PyPI runs it (those run ``infra_build.py``). +"""The images agent-env publishes, its infra images and the Claude Code agent's (``agent_env.env.published_images``), for +the release workflow's jobs that only read: this imports agent-env, so it runs where agent-env and its dependencies are +installed, and no job that holds a token to push images or publish to PyPI runs it (those run ``infra_build.py``). ``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. ``write PATH`` records the digest the registry serves under every +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 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``. @@ -19,22 +21,26 @@ from pathlib import Path from agent_env.env.bootstrap import PUBLISHED_IMAGES -from agent_env.env.published_images import REGISTRY, PublishedImage, write_release_digests +from agent_env.env.published_images import AGENT_IMAGES, REGISTRY, PublishedImage, write_release_digests from agent_env.store.image_store.oci_registry_credentials import RegistryAuth from agent_env.store.image_store.registry_api import NoSuchImageError, RegistryRefusedError, pin_digest REPO = Path(__file__).resolve().parents[2] +_GROUPS = {"infra": tuple(image for image in PUBLISHED_IMAGES if image not in AGENT_IMAGES), "agents": AGENT_IMAGES} def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("plan").add_argument("--missing", action="store_true") + plan = commands.add_parser("plan") + plan.add_argument("--missing", action="store_true") + plan.add_argument("--only", choices=sorted(_GROUPS)) commands.add_parser("write").add_argument("path", type=Path) commands.add_parser("public") args = parser.parse_args() if args.command == "plan": - print(json.dumps([_described(image) for image in PUBLISHED_IMAGES + images = _GROUPS[args.only] if args.only else PUBLISHED_IMAGES + print(json.dumps([_described(image) for image in images if not args.missing or _published_digest(image) is None])) return 0 if args.command == "public": diff --git a/.github/scripts/shipped_bundles.py b/.github/scripts/shipped_bundles.py index 8d3b0f95..140c4553 100644 --- a/.github/scripts/shipped_bundles.py +++ b/.github/scripts/shipped_bundles.py @@ -1,14 +1,25 @@ """The bundles agentenv-framework ships, from its ``agent_env.bundles`` entry points: `` = ""`` ships the -folder ``src///``. Run, it prints each shipped env's, agent's and artifact's requirements.txt, -the files a release pins agentenv-framework-protocol in.""" +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. +""" from __future__ import annotations +import argparse +import re +import sys import tomllib from pathlib import Path REPO = Path(__file__).resolve().parents[2] BUNDLES = "agent_env.bundles" +PROTOCOL = "agentenv-framework-protocol" +# 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.-])") def bundle_roots(repo: Path = REPO) -> dict[str, Path]: @@ -23,5 +34,40 @@ def requirements_files(repo: Path = REPO) -> list[Path]: return sorted(path for root in bundle_roots(repo).values() for path in root.glob("*/*/requirements.txt")) +def protocol_version(repo: Path = REPO) -> str: + return tomllib.loads((repo / "packages" / "agentenv-protocol" / "pyproject.toml").read_text())["project"]["version"] + + +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") + version, pinned = protocol_version(repo), [] + for path in files: + lines = path.read_text().splitlines(keepends=True) + if any(_PROTOCOL_REQUIREMENT.match(line) for line in lines): + path.write_text("".join(_pinned(line, version) for line in lines)) + pinned.append(path) + return pinned + + +def _pinned(line: str, version: str) -> str: + if not (match := _PROTOCOL_REQUIREMENT.match(line)): + return line + ending = line[len(line.rstrip("\r\n")):] + return f"{PROTOCOL}{match.group(1) or ''}=={version}{ending}" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_subparsers(dest="command", required=True).add_parser("pin") + parser.parse_args() + version = protocol_version() + for path in pin(): + print(f"pinned {PROTOCOL}=={version} in {path.relative_to(REPO).as_posix()}") + return 0 + + if __name__ == "__main__": - print("\n".join(path.relative_to(REPO).as_posix() for path in requirements_files())) + sys.exit(main()) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index c9cca9bf..4eb0cddb 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -8,11 +8,16 @@ name: Publish to PyPI # requests merged since the previous release. Files already on PyPI and a release that already exists are skipped, so a # rerun is safe. # -# It also publishes the infra images agent-env ships (agent_env.env.published_images) 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". Run by hand on main, it publishes them alone, with nothing sent to 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). +# 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). on: push: @@ -50,7 +55,8 @@ jobs: echo "${workflow} passed: ${url}" done - # The infra images whose tags the registry lacks, each tagged by its sources at this commit. + # The images whose tags the registry lacks, the infra images' and the agents' apart, each tagged by the sources this + # release ships. infra-plan: if: >- (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) @@ -61,7 +67,8 @@ jobs: contents: read packages: read outputs: - missing: ${{ steps.plan.outputs.missing }} + missing_infra: ${{ steps.plan.outputs.missing_infra }} + missing_agents: ${{ steps.plan.outputs.missing_agents }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -71,27 +78,32 @@ jobs: python-version: "3.12" - name: Install uv from public PyPI run: python -m pip install --quiet --index-url https://pypi.org/simple -r .github/ci-requirements.txt + - name: Pin agentenv-framework-protocol in the shipped bundles, as the release does + run: python3 .github/scripts/shipped_bundles.py pin - name: Find the images the registry lacks id: plan env: GHCR_TOKEN: ${{ github.token }} run: | uv sync --locked --no-dev - missing=$(uv run --no-sync python .github/scripts/infra_images.py plan --missing) - echo "missing=$missing" >> "$GITHUB_OUTPUT" + missing_infra=$(uv run --no-sync python .github/scripts/infra_images.py plan --missing --only infra) + missing_agents=$(uv run --no-sync python .github/scripts/infra_images.py plan --missing --only agents) + echo "missing_infra=$missing_infra" >> "$GITHUB_OUTPUT" + echo "missing_agents=$missing_agents" >> "$GITHUB_OUTPUT" - # Once main's CI has passed, so nothing is pushed for a commit that fails it: builds each missing image on a runner of - # each platform, checks that it runs, and pushes it under its tag and platform (src--amd64), which nothing looks - # up until infra-publish tags the multi-platform image. An image another run published meanwhile isn't built again. + # Once main's CI has passed, so nothing is pushed for a commit that fails it: builds each missing infra image on a + # runner of each platform, checks that it runs, and pushes it under its tag and platform (src--amd64), which + # nothing looks up until infra-publish tags the multi-platform image. An image another run published meanwhile isn't + # built again. agent-build runs the same steps for the agents' images. infra-build: needs: [main-ci, infra-plan] - if: needs.infra-plan.outputs.missing != '[]' + if: needs.infra-plan.outputs.missing_infra != '[]' strategy: fail-fast: true # A few at a time: each pulls its base images anonymously, and public.ecr.aws allows one such pull a second. max-parallel: 4 matrix: - image: ${{ fromJSON(needs.infra-plan.outputs.missing) }} + image: ${{ fromJSON(needs.infra-plan.outputs.missing_infra) }} arch: [amd64, arm64] runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} timeout-minutes: 60 @@ -111,6 +123,8 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false + - name: Pin agentenv-framework-protocol in the shipped bundles, as the release does + run: python3 .github/scripts/shipped_bundles.py pin - name: Check that it's an image agent-env ships, and that no run has published it id: wanted env: @@ -144,16 +158,164 @@ jobs: if: steps.wanted.outputs.build == 'true' run: python3 .github/scripts/infra_build.py retry docker push "$BUILT" - # Tags each new image's two platform builds as one multi-platform image under its source tag, which installs look up. - # A tag that exists is never moved, so an image a wheel or an install pinned stays the one its tag names. + # Tags each new infra image's two platform builds as one multi-platform image under its source tag, which installs + # look up. A tag that exists is never moved, so an image a wheel or an install pinned stays the one its tag names. + # agent-publish runs the same steps for the agents' images. infra-publish: needs: [main-ci, infra-plan, infra-build] - if: needs.infra-plan.outputs.missing != '[]' + if: needs.infra-plan.outputs.missing_infra != '[]' runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read packages: write + env: + MISSING: ${{ needs.infra-plan.outputs.missing_infra }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Tag each image's platform builds as one multi-platform image under its source tag + env: + GHCR_TOKEN: ${{ github.token }} + run: | + echo "$GHCR_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + jq -c '.[]' <<< "$MISSING" | while read -r entry; do + python3 .github/scripts/infra_build.py check "$entry" + ref=$(jq -r '"\(.repository):\(.tag)"' <<< "$entry") + code=0 + python3 .github/scripts/infra_build.py exists "$ref" || code=$? + if [ "$code" = 0 ]; then + echo "$ref is published already, so it's left as it is" + continue + fi + [ "$code" = 1 ] || exit 1 + python3 .github/scripts/infra_build.py retry docker buildx imagetools create -t "$ref" \ + --annotation "index:org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \ + --annotation "index:org.opencontainers.image.revision=${GITHUB_SHA}" \ + "${ref}-amd64" "${ref}-arm64" + done + + # When images are published, the protocol waits for the infra images, so a failed infra image job sends neither + # package to PyPI. A failed agent image job, which runs after it, keeps agentenv-framework off PyPI. + protocol: + needs: [main-ci, infra-plan, infra-build, infra-publish] + if: >- + !cancelled() && github.event_name == 'push' && needs.main-ci.result == 'success' + && (needs.infra-plan.result == 'skipped' + || (needs.infra-plan.result == 'success' + && contains(fromJSON('["success", "skipped"]'), needs.infra-build.result) + && contains(fromJSON('["success", "skipped"]'), needs.infra-publish.result))) + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: pypi-protocol + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install uv from public PyPI + run: python -m pip install --quiet --index-url https://pypi.org/simple -r .github/ci-requirements.txt + - name: Check the tag names the version being built + run: | + version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) + [ "v${version}" = "${GITHUB_REF_NAME}" ] || { echo "::error::tag ${GITHUB_REF_NAME}, but pyproject.toml is at ${version}"; exit 1; } + - name: Build agentenv-framework-protocol + run: uv build --no-sources packages/agentenv-protocol --out-dir dist + - name: Check its metadata renders + run: uvx --index-url https://pypi.org/simple twine check --strict dist/* + - name: Publish agentenv-framework-protocol + 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. + 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. + if: >- + !cancelled() && needs.main-ci.result == 'success' && needs.infra-plan.result == 'success' + && needs.infra-plan.outputs.missing_agents != '[]' + && (needs.protocol.result == 'success' + || (github.event_name == 'workflow_dispatch' && needs.protocol.result == 'skipped')) + strategy: + fail-fast: true + # A few at a time: each pulls its base images anonymously, and public.ecr.aws allows one such pull a second. + max-parallel: 4 + matrix: + image: ${{ fromJSON(needs.infra-plan.outputs.missing_agents) }} + arch: [amd64, arm64] + runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + timeout-minutes: 60 + permissions: + contents: read + packages: write + env: + ENTRY: ${{ toJSON(matrix.image) }} + NAME: ${{ matrix.image.name }} + DOCKERFILE: ${{ matrix.image.dockerfile }} + CONTEXT: ${{ matrix.image.context }} + TAG: ${{ matrix.image.tag }} + REF: ${{ matrix.image.repository }}:${{ matrix.image.tag }} + BUILT: ${{ matrix.image.repository }}:${{ matrix.image.tag }}-${{ matrix.arch }} + PLATFORM: linux/${{ matrix.arch }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Pin agentenv-framework-protocol in the shipped bundles, as the release does + run: python3 .github/scripts/shipped_bundles.py pin + - name: Check that it's an image agent-env ships, and that no run has published it + id: wanted + env: + GHCR_TOKEN: ${{ github.token }} + run: | + python3 .github/scripts/infra_build.py check "$ENTRY" + echo "$GHCR_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + code=0 + python3 .github/scripts/infra_build.py exists "$REF" || code=$? + case "$code" in + 0) echo "build=false" >> "$GITHUB_OUTPUT"; echo "$REF is published already, so it isn't built again" ;; + 1) echo "build=true" >> "$GITHUB_OUTPUT" ;; + *) exit 1 ;; + esac + - name: Pull its base images from mirror.gcr.io, as public.ecr.aws can rate-limit a runner for hours + if: steps.wanted.outputs.build == 'true' + run: python3 .github/scripts/mirror_base_images.py "$DOCKERFILE" + - name: Build + if: steps.wanted.outputs.build == 'true' + run: | + # No provenance attestation, which would show in the multi-platform image as an unknown platform. + python3 .github/scripts/infra_build.py retry \ + docker build --platform "$PLATFORM" --provenance=false -f "$DOCKERFILE" -t "$BUILT" \ + --label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \ + --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ + --label "io.agentenv.source-tag=${TAG}" "$CONTEXT" + - name: Check that it runs + if: steps.wanted.outputs.build == 'true' + run: python3 .github/scripts/infra_build.py smoke "$NAME" "$BUILT" + - name: Push it under its tag and platform + if: steps.wanted.outputs.build == 'true' + run: python3 .github/scripts/infra_build.py retry docker push "$BUILT" + + # Each new agent image's platform builds tagged as one multi-platform image, as infra-publish does the infra images. + agent-publish: + needs: [main-ci, infra-plan, agent-build] + # Explicit: the default skips it when a job upstream was skipped, as protocol is on a run by hand. + if: >- + !cancelled() && needs.main-ci.result == 'success' && needs.agent-build.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + env: + MISSING: ${{ needs.infra-plan.outputs.missing_agents }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -161,7 +323,6 @@ jobs: - name: Tag each image's platform builds as one multi-platform image under its source tag env: GHCR_TOKEN: ${{ github.token }} - MISSING: ${{ needs.infra-plan.outputs.missing }} run: | echo "$GHCR_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin jq -c '.[]' <<< "$MISSING" | while read -r entry; do @@ -184,11 +345,13 @@ jobs: # job checks against the registry before the wheel carries it; and a warning for each image an anonymous pull, as an # install makes, doesn't get: GHCR keeps a new package private until its admin makes it public. infra-record: - needs: [infra-plan, infra-build, infra-publish] + needs: [infra-plan, infra-build, infra-publish, agent-build, agent-publish] if: >- !cancelled() && needs.infra-plan.result == 'success' && contains(fromJSON('["success", "skipped"]'), needs.infra-build.result) && contains(fromJSON('["success", "skipped"]'), needs.infra-publish.result) + && contains(fromJSON('["success", "skipped"]'), needs.agent-build.result) + && contains(fromJSON('["success", "skipped"]'), needs.agent-publish.result) runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -205,6 +368,8 @@ jobs: python-version: "3.12" - name: Install uv from public PyPI run: python -m pip install --quiet --index-url https://pypi.org/simple -r .github/ci-requirements.txt + - name: Pin agentenv-framework-protocol in the shipped bundles, as the release does + run: python3 .github/scripts/shipped_bundles.py pin - name: Record the published images' digests id: record env: @@ -216,39 +381,6 @@ jobs: - name: Warn about images an anonymous pull doesn't get run: uv run --no-sync python .github/scripts/infra_images.py public - # Both packages wait for the infra images when they're published, so a failed image job sends neither to PyPI. - protocol: - needs: [main-ci, infra-plan, infra-record] - if: >- - !cancelled() && github.event_name == 'push' && needs.main-ci.result == 'success' - && (needs.infra-plan.result == 'skipped' - || (needs.infra-plan.result == 'success' && needs.infra-record.result == 'success')) - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: pypi-protocol - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - name: Install uv from public PyPI - run: python -m pip install --quiet --index-url https://pypi.org/simple -r .github/ci-requirements.txt - - name: Check the tag names the version being built - run: | - version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) - [ "v${version}" = "${GITHUB_REF_NAME}" ] || { echo "::error::tag ${GITHUB_REF_NAME}, but pyproject.toml is at ${version}"; exit 1; } - - name: Build agentenv-framework-protocol - run: uv build --no-sources packages/agentenv-protocol --out-dir dist - - name: Check its metadata renders - run: uvx --index-url https://pypi.org/simple twine check --strict dist/* - - name: Publish agentenv-framework-protocol - run: uv publish --trusted-publishing always --check-url https://pypi.org/simple/ dist/* - # A release's wheel names only images the registry serves under their tags. framework: needs: [protocol, infra-plan, infra-record] @@ -274,7 +406,7 @@ jobs: python-version: "3.12" - name: Install uv from public PyPI run: python -m pip install --quiet --index-url https://pypi.org/simple -r .github/ci-requirements.txt - - name: Put the infra images' record in the wheel, checked against the registry + - name: Put the images' record in the wheel, checked against the registry id: record if: needs.infra-plan.result == 'success' env: @@ -292,15 +424,8 @@ jobs: # workspace members, and the bare name keys [tool.uv.sources]; none of those may be rewritten. sed -i "s/\"agentenv-framework-protocol[^\"]*\"/\"agentenv-framework-protocol==${protocol}\"/" pyproject.toml grep -q "\"agentenv-framework-protocol==${protocol}\"" pyproject.toml - # Each shipped bundle's images install the protocol this release ships with, keeping its extras. The list is - # assigned first, so the step stops if the script fails or finds nothing. - requirements_files=$(python .github/scripts/shipped_bundles.py) - [ -n "$requirements_files" ] - for requirements in $requirements_files; do - grep -q "^agentenv-framework-protocol" "$requirements" || continue - sed -i -E "s/^agentenv-framework-protocol(\[[^]]*\])?.*/agentenv-framework-protocol\1==${protocol}/" "$requirements" - grep -qE "^agentenv-framework-protocol(\[[^]]*\])?==${protocol}$" "$requirements" - done + # Each shipped bundle's images install the protocol this release ships with, keeping its extras. + python3 .github/scripts/shipped_bundles.py pin uv build --no-sources --out-dir dist - name: Check its metadata renders run: uvx --index-url https://pypi.org/simple twine check --strict dist/* @@ -336,7 +461,7 @@ jobs: protocol=$(sed -n 's/^version = "\(.*\)"/\1/p' packages/agentenv-protocol/pyproject.toml) header="On PyPI: [agentenv-framework ${framework}](https://pypi.org/project/agentenv-framework/${framework}/) ยท [agentenv-framework-protocol ${protocol}](https://pypi.org/project/agentenv-framework-protocol/${protocol}/)" if [ -n "$RECORD" ]; then - header+=$'\n\n'"Infra images published for this release, by digest:"$'\n' + header+=$'\n\n'"Images published for this release, by digest:"$'\n' header+=$(jq -r '.images[] | "- `\(.repository):\(.tag)@\(.digest)`"' <<< "$RECORD") fi args=(--verify-tag --title "$tag" --generate-notes --notes "$header") diff --git a/AGENTS.md b/AGENTS.md index 2be17214..49499ce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,14 +270,17 @@ 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` lists them, so a new agent is pinned with no workflow change), +`requirements.txt` (`.github/scripts/shipped_bundles.py pin` finds them, so a new agent 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 (`agent_env.env.published_images`) whose `src-` tags GHCR lacks: after `main`'s CI passes, each is +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 wheel and the release notes record each image's digest, and a failed image job sends neither -package to PyPI. Run by hand on `main`, the workflow publishes the images alone. +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 +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`, standard library and the docker CLI only). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfd53839..51f0d7ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,14 +89,17 @@ the previous release, so a clear pull request title is also the release note. A `main`'s CI is not published and gets no release; its pull requests are in the next release's notes. A release also publishes the infra images agent-env ships, once the `PUBLISH_INFRA_IMAGES` repository variable is -`true`: one per Dockerfile under `src/agent_env/env`, at `ghcr.io/scaleapi/agentenv-:src-` for linux/amd64 +`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. Each of them pins its base images by digest, and Dependabot bumps those pins, so a base's fixes reach the published image: a bump changes the sources, and the next release builds it again. Installs pull them instead of building the infra images: a run on local stores registers a kind's images when GHCR has every one for the sources installed, by the digest the wheel records or, with no record, the one GHCR serves, and builds them otherwise; a sandbox that can't pull one builds it from its build context. The wheel and the release notes -record each image's digest. A failed image job keeps the release off PyPI. Maintainers can publish the images +record each image's digest. A failed image job keeps `agentenv-framework` off PyPI. Maintainers can publish the images alone by running the workflow by hand on `main`. GHCR keeps a new package private until a package admin makes it public, and the workflow warns about each one until then. diff --git a/src/agent_env/agents/README.md b/src/agent_env/agents/README.md index dec562ce..81411c80 100644 --- a/src/agent_env/agents/README.md +++ b/src/agent_env/agents/README.md @@ -8,9 +8,11 @@ from another bundle as `@local/agentenv-framework/agents/claude-code`. It needs Docker, and a model endpoint serving Anthropic's Messages API, `/v1/messages`, which a LiteLLM gateway does: the `[model]` base URL and key reach it as `ANTHROPIC_BASE_URL` and a bearer token, and `[model.headers]` are sent on every model call. The `LITELLM_*` variables stay in its environment too, so a script the model runs can call the -endpoint. Building the image downloads Claude Code, which is Anthropic's software under its -[Commercial Terms](https://www.anthropic.com/legal/commercial-terms); the image is built where it runs and never -published. The CLI's version is the Dockerfile's `CLAUDE_CODE_VERSION`. The image gives the scripts the model runs +endpoint. The image holds Claude Code, which is Anthropic's software under its +[Commercial Terms](https://www.anthropic.com/legal/commercial-terms); a release also publishes the image, CLI included, +at `ghcr.io/scaleapi/agentenv-claude-code:src-`, built from this folder with the protocol pinned as the wheel +ships it. The CLI's version is the Dockerfile's +`CLAUDE_CODE_VERSION`. The image gives the scripts the model runs `node`, without npm or npx, and the Python packages `model-requirements.txt` pins. `install_agent` installs it too, into a container a task already runs or onto a `vm` sandbox's Linux host, with diff --git a/src/agent_env/env/bootstrap.py b/src/agent_env/env/bootstrap.py index 2d6773ac..d220dac2 100644 --- a/src/agent_env/env/bootstrap.py +++ b/src/agent_env/env/bootstrap.py @@ -22,7 +22,7 @@ from agent_env.env.envs import GatewayEnv, MCPServerEnv from agent_env.env.envs.service_db import ServiceDBEnv from agent_env.env.envs.website_browser import WEBSITE_BROWSER_ENVIRONMENT_NAME, WEBSITE_BROWSER_IMAGE_TAG -from agent_env.env.published_images import PublishedImage, published_digest +from agent_env.env.published_images import AGENT_IMAGES, PublishedImage, published_digest from agent_env.store.base import NotFoundError from agent_env.store.document_store import LocalSqliteDocumentStore from agent_env.store.ids import derive_id @@ -137,13 +137,14 @@ def _metadata(kind: str, extra: Mapping[str, str] | None) -> dict[str, str]: SERVICE_DB: [SERVICE_DB_DOCKERFILE, DB_WEB_DOCKERFILE, DB_MCP_DOCKERFILE], WEBSITE_BROWSER: [WEBSITE_BROWSER_DOCKERFILE], } -# The image each shipped Dockerfile is published as. +# The image each shipped Dockerfile is published as: the infra envs' and the shipped agents'. PUBLISHED_IMAGES = ( PublishedImage("agentenv-gateway", GATEWAY_DOCKERFILE), PublishedImage("agentenv-service-db", SERVICE_DB_DOCKERFILE), PublishedImage("agentenv-db-web", DB_WEB_DOCKERFILE), PublishedImage("agentenv-db-mcp", DB_MCP_DOCKERFILE), PublishedImage("agentenv-website-browser", WEBSITE_BROWSER_DOCKERFILE), + *AGENT_IMAGES, ) _PUBLISHED = {image.dockerfile: image for image in PUBLISHED_IMAGES} diff --git a/src/agent_env/env/published_images.py b/src/agent_env/env/published_images.py index da2a17af..f7b64e9f 100644 --- a/src/agent_env/env/published_images.py +++ b/src/agent_env/env/published_images.py @@ -23,6 +23,8 @@ # Short, so a network that drops requests doesn't hold up a run's checks for long. _LOOKUP_TIMEOUT_SECONDS = 10 _SERVED: dict[str, str] = {} # the digest the registry served each ref under, which never changes: no tag is moved +# The agents bundle agent-env ships, whose agents a run deploys like a bundle's own. +_AGENTS = Path(__file__).parents[1] / "agents" / "agents" @dataclass(frozen=True) @@ -47,6 +49,10 @@ def ref(self) -> str: return f"{self.repository}:{self.tag()}" +# The images of the agents agent-env ships that its releases publish, Claude Code's CLI included: the default agent's. +AGENT_IMAGES = (PublishedImage("agentenv-claude-code", _AGENTS / "claude-code" / "Dockerfile"),) + + def published_digest(image: PublishedImage) -> str | None: """The digest ``image`` is published under for the sources installed: the one this install's release recorded, else the one the registry serves under its tag now; None when it serves none or can't be asked.""" diff --git a/tst/unit/env/published_images_test.py b/tst/unit/env/published_images_test.py index c3be9925..3c9aa539 100644 --- a/tst/unit/env/published_images_test.py +++ b/tst/unit/env/published_images_test.py @@ -23,7 +23,9 @@ def test_every_shipped_dockerfile_is_published_once_under_a_name_of_its_own(): + """The infra envs' and the default agent's, Claude Code.""" shipped = [dockerfile for dockerfiles in bootstrap._DOCKERFILES.values() for dockerfile in dockerfiles] + shipped.append(published_images._AGENTS / "claude-code" / "Dockerfile") assert sorted(image.dockerfile for image in bootstrap.PUBLISHED_IMAGES) == sorted(shipped) assert len({image.name for image in bootstrap.PUBLISHED_IMAGES}) == len(bootstrap.PUBLISHED_IMAGES) diff --git a/tst/unit/test_infra_build_script.py b/tst/unit/test_infra_build_script.py index c6aa4199..f56e3451 100644 --- a/tst/unit/test_infra_build_script.py +++ b/tst/unit/test_infra_build_script.py @@ -132,7 +132,7 @@ def test_verify_record_refuses_a_digest_the_registry_doesnt_serve_under_its_tag( @pytest.mark.parametrize("record, problem", [ - ({"images": []}, "records no agentenv-db-mcp, agentenv-db-web"), + ({"images": []}, "records no agentenv-claude-code, agentenv-db-mcp, agentenv-db-web"), ({"images": [], "extra": 1}, "it holds ['extra', 'images']"), ({"images": [{"repository": "ghcr.io/someone/thing", "tag": _TAG, "digest": _DIGEST}]}, "'ghcr.io/someone/thing' isn't an image agent-env ships"), diff --git a/tst/unit/test_infra_images_script.py b/tst/unit/test_infra_images_script.py index 841b6da0..5a454b22 100644 --- a/tst/unit/test_infra_images_script.py +++ b/tst/unit/test_infra_images_script.py @@ -8,7 +8,7 @@ import pytest from agent_env.env import bootstrap -from agent_env.env.published_images import release_digests +from agent_env.env.published_images import AGENT_IMAGES, release_digests from agent_env.store.image_store.registry_api import NoSuchImageError, RegistryRefusedError from tst.util.scripts import load_script @@ -50,6 +50,19 @@ def test_plan_lists_every_image_with_its_checkout_paths_and_tag(script, monkeypa "context": "src/agent_env/env/gateway"} +def test_plan_only_lists_the_infra_images_or_the_agents_images(script, monkeypatch, capsys): + """The release builds the infra images before it publishes the protocol, and the agents', which install it, after.""" + planned = {} + for only in ("infra", "agents"): + assert _run(script, monkeypatch, "plan", "--only", only) == 0 + planned[only] = [entry["name"] for entry in json.loads(capsys.readouterr().out)] + + agents = [image.name for image in AGENT_IMAGES] + assert planned == {"infra": [image.name for image in bootstrap.PUBLISHED_IMAGES if image.name not in agents], + "agents": agents} + assert "agentenv-gateway" in planned["infra"] and agents == ["agentenv-claude-code"] + + def test_plan_missing_lists_the_images_the_registry_lacks_or_refuses_to_show(script, monkeypatch, capsys): gateway, service_db, *rest = bootstrap.PUBLISHED_IMAGES _registry(script, monkeypatch, {gateway.ref(): NoSuchImageError("no such image"), diff --git a/tst/unit/test_release_infra_images.py b/tst/unit/test_release_infra_images.py index 9e90f873..58854d71 100644 --- a/tst/unit/test_release_infra_images.py +++ b/tst/unit/test_release_infra_images.py @@ -1,4 +1,5 @@ -"""What ``.github/workflows/publish-pypi.yml`` relies on to publish the infra images and record them in the wheel.""" +"""What ``.github/workflows/publish-pypi.yml`` relies on to publish the images agent-env ships, its infra images and its +agents', and record them in the wheel.""" from __future__ import annotations @@ -10,7 +11,8 @@ import yaml from agent_env.env import bootstrap -from agent_env.env.published_images import RELEASE_FILE +from agent_env.env.published_images import AGENT_IMAGES, RELEASE_FILE +from tst.util.scripts import load_script REPO = Path(__file__).resolve().parents[2] WORKFLOW = yaml.safe_load((REPO / ".github" / "workflows" / "publish-pypi.yml").read_text()) @@ -18,6 +20,9 @@ RECORD = str(RELEASE_FILE.relative_to(REPO)) # What installs or runs agent-env and its dependencies in a job. _RUNS_DEPENDENCIES = re.compile(r"\buv (sync|run)\b|infra_images\.py") +# What reads the shipped bundles' folders: an image's tag, its build, and the wheel. +_READS_SHIPPED_SOURCES = re.compile(r"infra_images\.py (plan|write)|docker build\b|uv build --no-sources --out-dir dist") +_PIN = "python3 .github/scripts/shipped_bundles.py pin" def _runs(job: str) -> list[str]: @@ -34,6 +39,14 @@ def _writes(job: str) -> bool: return any(access == "write" for access in permissions.values()) +def _condition(job: str) -> str: + return " ".join(JOBS[job]["if"].split()) + + +def _finished(job: str) -> str: + return f"""contains(fromJSON('["success", "skipped"]'), needs.{job}.result)""" + + @pytest.mark.parametrize("job", sorted(JOBS)) def test_a_job_that_installs_agent_env_holds_no_token_that_writes(job): """Then a dependency's code never runs where a token can push an image, publish to PyPI or edit the repository.""" @@ -62,20 +75,64 @@ def test_a_published_tag_is_never_built_again_or_moved(): assert publish.index('infra_build.py exists "$ref"') < publish.index("imagetools create") -def test_nothing_is_pushed_before_mains_ci_passes(): - assert "main-ci" in JOBS["infra-build"]["needs"] and "main-ci" in JOBS["infra-publish"]["needs"] +@pytest.mark.parametrize(("agent_job", "infra_job"), [("agent-build", "infra-build"), ("agent-publish", "infra-publish")]) +def test_the_agent_image_jobs_are_the_infra_image_jobs_on_the_agents_list(agent_job, infra_job): + """So each check here on how the infra images are built, pushed and tagged holds for the agents' images too.""" + def body(job: str) -> dict: + return {key: value for key, value in JOBS[job].items() if key not in ("needs", "if")} + + assert yaml.safe_load(yaml.safe_dump(body(infra_job)).replace("missing_infra", "missing_agents")) == body(agent_job) + + +@pytest.mark.parametrize("job", ["infra-build", "infra-publish", "agent-build", "agent-publish"]) +def test_nothing_is_pushed_before_mains_ci_passes(job): + """A condition with ``!cancelled()`` runs whatever the jobs it needs ended as, so it checks main-ci's itself.""" + assert "main-ci" in JOBS[job]["needs"] + assert "!cancelled()" not in JOBS[job]["if"] or "needs.main-ci.result == 'success'" in JOBS[job]["if"] + +def test_a_failed_infra_image_job_sends_neither_package_to_pypi(): + """The protocol runs with publishing off, or once each infra image job succeeded or had nothing to do.""" + assert {"infra-plan", "infra-build", "infra-publish"} <= set(JOBS["protocol"]["needs"]) + assert _condition("protocol").startswith("!cancelled()") + assert (f"(needs.infra-plan.result == 'skipped' || (needs.infra-plan.result == 'success' " + f"&& {_finished('infra-build')} && {_finished('infra-publish')}))") in _condition("protocol") -@pytest.mark.parametrize("job", ["protocol", "framework"]) -def test_a_failed_image_job_sends_neither_package_to_pypi(job): - """Each runs with publishing off, or once the record, which runs only after every image job did, is made.""" - spec = JOBS[job] - assert {"infra-plan", "infra-record"} <= set(spec["needs"]) and spec["if"].startswith("!cancelled()") +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.""" + 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") + + +def test_the_framework_waits_for_every_image_job(): + """It runs with publishing off, or once the record is made, which runs only after every image job did.""" + assert {"infra-plan", "infra-record"} <= set(JOBS["framework"]["needs"]) + assert _condition("framework").startswith("!cancelled()") assert ("(needs.infra-plan.result == 'skipped' || (needs.infra-plan.result == 'success' " - "&& needs.infra-record.result == 'success'))") in " ".join(spec["if"].split()) - for image_job in ("infra-build", "infra-publish"): - assert f"""contains(fromJSON('["success", "skipped"]'), needs.{image_job}.result)""" in JOBS["infra-record"]["if"] + "&& needs.infra-record.result == 'success'))") in _condition("framework") + for image_job in ("infra-build", "infra-publish", "agent-build", "agent-publish"): + assert image_job in JOBS["infra-record"]["needs"] and _finished(image_job) in _condition("infra-record") + + +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.""" + 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.""" + 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} def test_the_github_release_is_created_whenever_the_framework_is_published(): @@ -102,7 +159,7 @@ def test_the_release_notes_list_the_checked_record(): assert JOBS["framework"]["outputs"]["record"] == "${{ steps.record.outputs.record }}" assert create["env"]["RECORD"] == "${{ needs.framework.outputs.record }}" - assert "Infra images published for this release, by digest:" in create["run"] + assert "Images published for this release, by digest:" in create["run"] def test_nothing_reaches_pypi_from_a_run_by_hand(): diff --git a/tst/unit/test_release_protocol_pins.py b/tst/unit/test_release_protocol_pins.py index 7be67622..17739f89 100644 --- a/tst/unit/test_release_protocol_pins.py +++ b/tst/unit/test_release_protocol_pins.py @@ -1,5 +1,6 @@ -"""The files ``.github/workflows/publish-pypi.yml`` rewrites to pin agentenv-framework-protocol on a release: each must -hold exactly one line its sed matches, or the release fails after the protocol is already on PyPI.""" +"""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.""" from __future__ import annotations @@ -7,6 +8,8 @@ import re from pathlib import Path +import pytest + REPO = Path(__file__).resolve().parents[2] WORKFLOW = (REPO / ".github" / "workflows" / "publish-pypi.yml").read_text() _spec = importlib.util.spec_from_file_location("shipped_bundles", REPO / ".github" / "scripts" / "shipped_bundles.py") @@ -18,9 +21,55 @@ def test_the_pyproject_holds_the_one_quoted_protocol_requirement_the_release_pin assert len(re.findall(r'"agentenv-framework-protocol[^"]*"', (REPO / "pyproject.toml").read_text())) == 1 -def test_the_release_pins_the_requirements_files_the_shipped_bundles_hold(): - assert "requirements_files=$(python .github/scripts/shipped_bundles.py)" in WORKFLOW - assert re.search(r"^\s*for requirements in \$requirements_files; do$", WORKFLOW, re.MULTILINE) +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``.""" + (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) + return tmp_path + + +def _requirements(repo: Path, agent: str) -> str: + return (repo / "src/agent_env/agents/agents" / agent / "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"}) + + 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" + + +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"}) + 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" + + +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, {})) + + +def test_the_release_pins_the_shipped_bundles_with_the_script(): + assert WORKFLOW.count("python3 .github/scripts/shipped_bundles.py pin") >= 1 + assert "requirements_files=$(" not in WORKFLOW def test_each_shipped_requirements_file_the_release_pins_holds_one_protocol_line():