diff --git a/.github/downstream/bump-agent-core.yml b/.github/downstream/bump-agent-core.yml deleted file mode 100644 index 84f9bc3..0000000 --- a/.github/downstream/bump-agent-core.yml +++ /dev/null @@ -1,145 +0,0 @@ -# Template — copy into a product repository as -# .github/workflows/bump-agent-core.yml -# together with repin_agent_core.py, as -# .github/workflows/repin_agent_core.py -# -# Opens (or refreshes) a pull request moving this product's AgentCore pin to a -# newly released tag. Triggered by AgentCore's release workflow, and manually -# runnable to recover a missed or failed dispatch. -# -# Required repository configuration: -# AGENT_CORE_AUTOMATION_APP_CLIENT_ID — GitHub App client ID, stored as a -# repository variable. -# AGENT_CORE_AUTOMATION_APP_PRIVATE_KEY — GitHub App private key, stored as a -# repository secret. Short-lived installation tokens are generated for -# this run; never store an installation token as a secret because it -# expires after one hour. - -name: Bump AgentCore - -on: - repository_dispatch: - types: [agent-core-release] - workflow_dispatch: - inputs: - version: - description: "AgentCore tag to pin, e.g. v0.2.0" - required: true - -jobs: - bump: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Create AgentCore read token - id: agent-core-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.AGENT_CORE_AUTOMATION_APP_CLIENT_ID }} - private-key: ${{ secrets.AGENT_CORE_AUTOMATION_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: AgentCore - permission-contents: read - - - name: Create product bump token - id: bump-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.AGENT_CORE_AUTOMATION_APP_CLIENT_ID }} - private-key: ${{ secrets.AGENT_CORE_AUTOMATION_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-contents: write - permission-pull-requests: write - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - with: - python-version: "3.12" - - - name: Resolve target version - env: - INPUT_VERSION: ${{ github.event.client_payload.version || inputs.version }} - run: | - set -euo pipefail - version="$INPUT_VERSION" - if [[ ! "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "Refusing to pin '$version': expected a tag such as v0.2.1." >&2 - exit 1 - fi - printf 'VERSION=%s\n' "$version" >> "$GITHUB_ENV" - printf 'BRANCH=chore/agent-core-%s\n' "$version" >> "$GITHUB_ENV" - - # The dependency is declared as ssh://git@github.com/...; rewrite that - # exact scheme so uv resolves it over HTTPS with a token instead of - # needing an SSH key on the runner. - - name: Authenticate git for the private dependency - env: - AGENT_CORE_REPO_TOKEN: ${{ steps.agent-core-token.outputs.token }} - run: | - set -euo pipefail - git config --global \ - url."https://x-access-token:${AGENT_CORE_REPO_TOKEN}@github.com/".insteadOf \ - "ssh://git@github.com/" - - # Substitute the rev in place, then relock. Do NOT use `uv add` here: it - # rewrites the standard PEP 508 direct-URL dependency into uv's - # proprietary [tool.uv.sources] table, which pip ignores — silently - # breaking any non-uv install path and adding a structural diff to every - # bump PR. - - name: Repin AgentCore - run: | - set -euo pipefail - python3 .github/workflows/repin_agent_core.py "$VERSION" - uv lock - - - name: Stop if already pinned - id: diff - run: | - set -euo pipefail - if git diff --quiet -- pyproject.toml uv.lock; then - echo "Already pinned to ${VERSION}; nothing to open." - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Open or refresh the bump pull request - if: steps.diff.outputs.changed == 'true' - env: - GH_TOKEN: ${{ steps.bump-token.outputs.token }} - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email 'github-actions[bot]@users.noreply.github.com' - gh auth setup-git - - # Force-push a deterministic branch so a re-dispatch of the same - # version refreshes the existing PR instead of failing. - git switch -c "$BRANCH" - git add pyproject.toml uv.lock - git commit -m "chore: bump AgentCore to ${VERSION}" - git push --force origin "$BRANCH" - - if [[ "$(gh pr list --head "$BRANCH" --state open --json number --jq 'length')" != 0 ]]; then - echo "Refreshed existing pull request for ${BRANCH}." - exit 0 - fi - - cat > /tmp/bump-body.md < tuple[str, int]: - return PIN.subn(rf"\g<1>{version}", text) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("version", help="Release tag to pin, e.g. v0.2.0") - parser.add_argument("--file", default="pyproject.toml") - args = parser.parse_args(argv) - - path = Path(args.file) - original = path.read_text(encoding="utf-8") - updated, count = repin(original, args.version) - - if count != 1: - print( - f"Expected exactly one AgentCore pin in {path}, found {count}.\n" - "The dependency declaration changed shape; update this script rather " - "than letting the bump land a half-edited pin.", - file=sys.stderr, - ) - return 1 - - if updated == original: - print(f"Already pinned to {args.version}.") - return 0 - - path.write_text(updated, encoding="utf-8") - print(f"Repinned to {args.version}.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2cc665..2416466 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,12 +4,11 @@ on: push: tags: ["v*"] -permissions: - contents: write - jobs: - release: + build: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -37,67 +36,86 @@ jobs: - run: uv run pytest -q - run: uv build - - name: Publish GitHub Release + # A PyPI version number can never be reused, not even after deleting the + # release. Reject malformed metadata here rather than burning the version. + - name: Validate package metadata + run: uv run --with twine twine check dist/* + + # The release contract requires the artifact to install and import in a + # clean environment. Checking it here matters more than usual because a + # PyPI version number cannot be reclaimed: a wheel that fails to import + # would burn the version rather than fail the release. + - name: Install and import the built wheel in a clean environment run: | set -euo pipefail - gh release create "$TAG" \ - --title "AgentCore ${TAG#v}" \ - --notes-file release-notes.md \ - dist/*.whl dist/*.tar.gz - env: - TAG: ${{ github.ref_name }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uv venv /tmp/wheel-smoke + uv pip install --python /tmp/wheel-smoke/bin/python dist/*.whl + /tmp/wheel-smoke/bin/python - <<'SMOKE' + import agent_core + from agent_core import user_msg - - name: Check downstream automation credentials - id: downstream-app - env: - APP_CLIENT_ID: ${{ vars.AGENT_CORE_AUTOMATION_APP_CLIENT_ID }} - APP_PRIVATE_KEY: ${{ secrets.AGENT_CORE_AUTOMATION_APP_PRIVATE_KEY }} - run: | - if [[ -n "$APP_CLIENT_ID" && -n "$APP_PRIVATE_KEY" ]]; then - echo "configured=true" >> "$GITHUB_OUTPUT" - else - echo "configured=false" >> "$GITHUB_OUTPUT" - echo "::warning title=No downstream dispatch::GitHub App credentials are not configured; no bump PRs were requested. See docs/downstream-bump.md." - fi + assert user_msg("hi") == {"role": "user", "content": "hi"} + print("imported", agent_core.__name__, "with", len(agent_core.__all__), "exports") + SMOKE - - name: Create downstream dispatch token - id: downstream-token - if: steps.downstream-app.outputs.configured == 'true' - continue-on-error: true - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - client-id: ${{ vars.AGENT_CORE_AUTOMATION_APP_CLIENT_ID }} - private-key: ${{ secrets.AGENT_CORE_AUTOMATION_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: | - ApodexHarness - FrontierAgentInternal - permission-contents: write + name: release-artifacts + path: | + dist/ + release-notes.md + if-no-files-found: error - - name: Warn if dispatch authentication failed - if: >- - steps.downstream-app.outputs.configured == 'true' && - steps.downstream-token.outcome != 'success' - run: echo "::warning title=Downstream authentication failed::Could not create a GitHub App token; no bump PRs were requested." + # Separate job so `id-token: write` — which mints the OIDC identity PyPI + # trusts — is scoped to publishing alone and never exposed to the build or to + # any third-party action running beside it. + publish-pypi: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-artifacts + path: release-artifacts/ + # Trusted Publishing: no API token, no secret. PyPI verifies the OIDC + # claim naming this repository, this workflow file, and the environment + # above, then issues a short-lived upload token itself. + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: release-artifacts/dist/ - # Notify both consumers so a release always produces a visible bump PR - # rather than waiting for someone to remember. This runs after the release - # exists, and authentication/dispatch failures degrade to warnings: the - # release itself is already published and must not be failed by downstream - # plumbing. - - name: Request downstream bump PRs - if: steps.downstream-token.outcome == 'success' - env: - TAG: ${{ github.ref_name }} - GH_TOKEN: ${{ steps.downstream-token.outputs.token }} + # Publish the GitHub Release last. A failed PyPI upload therefore cannot leave + # a GitHub Release claiming that a version was published when it was not. + publish-github: + needs: publish-pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-artifacts + path: release-artifacts/ + # `gh release create` is not retry-safe after a partial API failure. Use + # an upsert so rerunning this job always converges on the same release. + - name: Publish GitHub Release run: | set -euo pipefail - for repo in ApodexAI/ApodexHarness ApodexAI/FrontierAgentInternal; do - echo "Dispatching agent-core-release ${TAG} to ${repo}" - gh api "repos/${repo}/dispatches" \ - --method POST \ - -f event_type=agent-core-release \ - -f "client_payload[version]=${TAG}" \ - || echo "::warning title=Dispatch failed::Could not notify ${repo}; open its bump PR manually." - done + if gh release view "$TAG" >/dev/null 2>&1; then + gh release edit "$TAG" \ + --title "AgentCore ${TAG#v}" \ + --notes-file release-artifacts/release-notes.md + gh release upload "$TAG" --clobber \ + release-artifacts/dist/*.whl release-artifacts/dist/*.tar.gz + else + gh release create "$TAG" \ + --title "AgentCore ${TAG#v}" \ + --notes-file release-artifacts/release-notes.md \ + release-artifacts/dist/*.whl release-artifacts/dist/*.tar.gz + fi + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b38332e..e8b17c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,71 @@ the GitHub Release body, so a release with no entry here fails. Versioning follows [docs/versioning.md](docs/versioning.md). +## [0.3.0] - 2026-09-03 + +First published release. AgentCore is now open source under Apache-2.0 and +distributed on PyPI as [`apodex-agent-core`](https://pypi.org/project/apodex-agent-core/). + +### Consumer action + +Replace the Git dependency with the published package: + +```toml +dependencies = [ + "apodex-agent-core==0.3.0", # was: apodex-agent-core @ git+ssh://…@ +] +``` + +Then delete the credential plumbing this required — the AgentCore deploy key, +`AGENT_CORE_REPO_TOKEN`, and any `insteadOf` rewriting. Installing needs no +credential now, `docker build` included. + +### Changed + +- **Distribution.** Releases publish to PyPI from the tagged tree using Trusted + Publishing (OIDC): no API token is stored anywhere, and `id-token: write` is + scoped to the publishing job alone. GitHub Releases still carry the wheel, + sdist, and changelog section. +- **Downstream bumps are Dependabot's job.** The `repository_dispatch` + + repin-script mechanism is deleted along with `.github/downstream/`. It existed + only because a private Git dependency could not be resolved without a + credential; a public PyPI package needs none, and Dependabot's `uv` ecosystem + updates `pyproject.toml` and `uv.lock` and opens a pull request that the + product's own CI validates. This also removes the need for an organization-level + GitHub App or a cross-repository PAT. +- Package metadata: SPDX `license = "Apache-2.0"` with `license-files` (PEP 639), + trove classifiers, and project URLs. +- `docs/versioning.md` records why publishing to PyPI reversed the earlier + decision to stay on Git pins — going public removed that trade's entire cost + side. + +### Added + +- The release now runs `twine check` and installs the built wheel in a clean + environment to `import agent_core` before publishing. A PyPI version number + can never be reused, so a broken artifact must fail the release rather than + consume the number. +- `docs/downstream-bump.md`: PyPI publisher registration, the `pypi` environment, + the Dependabot config, and the one real limitation — a Dependabot pull request + runs CI as if from a fork, so `secrets.*` resolves against Dependabot secrets + rather than Actions secrets. + +### Fixed + +- The version gate accepted a *decreasing* version: it compared for inequality + rather than an increase, so a pull request could move `0.2.0` back to `0.1.9` + and pass. +- The `skip-version-bump` label was inert. The default `pull_request` event types + exclude `labeled`, so applying the label did not re-run the check and the pull + request stayed red forever. + ## [0.2.0] - 2026-09-03 +**Never published.** No tag was pushed and no artifact was distributed; the +version exists only in `main`'s history. Consumers went from Git revisions +straight to `0.3.0` on PyPI. The entry below is kept because it records when +version discipline was introduced. + First versioned release. Functionally this is the runtime that products have already been consuming by commit SHA; what changes is that the revision now has a name. diff --git a/README.md b/README.md index 94ae89c..58df9e3 100644 --- a/README.md +++ b/README.md @@ -107,45 +107,31 @@ AgentCore uses the import namespace `agent_core` and the distribution name ## Consuming the release package -FrontierAgent should pin a released tag, never a branch: +AgentCore is published on PyPI: + +```bash +uv add apodex-agent-core # or: pip install apodex-agent-core +``` + +FrontierAgent pins an exact version: ```toml dependencies = [ - "apodex-agent-core @ git+ssh://git@github.com/ApodexAI/AgentCore.git@v0.2.0", + "apodex-agent-core==0.3.0", ] ``` -A tag is as immutable as a commit here — tags are never moved or deleted once -pushed — and unlike a SHA it is readable in a diff, so a bump PR states which -version the product is moving to. Every tag has a GitHub Release carrying the -built wheel, the sdist, and its changelog section. See +Exact, because this is a `0.x` series where a MINOR bump may be breaking. The +pin makes each upgrade a reviewable event: Dependabot opens a pull request +against it and FrontierAgent CI decides whether the new version is safe. See +[docs/downstream-bump.md](docs/downstream-bump.md). + +No credential is involved anywhere in that path — the repository and the package +are both public. Every release also has a GitHub Release carrying the wheel, the +sdist, and its changelog section. See [docs/versioning.md](docs/versioning.md) for what a version number means and [CHANGELOG.md](CHANGELOG.md) for what changed. -Because this repository is private, CI needs a read-only credential that can -clone `ApodexAI/AgentCore`. Prefer a dedicated GitHub App: store its client ID -as a repository variable and its private key as a secret, then mint a -short-lived installation token during each job with -`actions/create-github-app-token`. Do not store the generated installation -token as a secret; it expires after one hour. A fine-grained machine-user PAT -stored as `AGENT_CORE_REPO_TOKEN` is the fallback, not a developer's personal -token. Expose either credential to the following step as -`AGENT_CORE_REPO_TOKEN`, then configure Git before `uv sync`: - -```bash -git config --global \ - url."https://x-access-token:${AGENT_CORE_REPO_TOKEN}@github.com/".insteadOf \ - "ssh://git@github.com/" -``` - -The rewrite must target `ssh://git@github.com/`, which is the scheme the pin -above declares. Rewriting `https://github.com/` instead is a no-op against an -`ssh://` dependency URL and leaves CI failing on an SSH key it does not have. - -Until that credential is installed in both product repositories, product -source must not be switched to the private dependency: doing so would make a -clean checkout and CI unreproducible. - ## Release package contract Each AgentCore release is the installable engine runtime used by FrontierAgent. @@ -163,7 +149,8 @@ The release must: - pass lint, type checking, the complete test suite, and an install/import smoke test against the built wheel before publication; - attach the wheel, source distribution, and matching changelog section to the - GitHub Release, then trigger FrontierAgent's pinned-version bump workflow. + GitHub Release, and publish the same artifacts to PyPI so Dependabot can open + FrontierAgent's bump pull request. A release is not complete merely because a tag exists. It is complete when its artifacts can be installed in a clean Python 3.12 environment, `import @@ -181,23 +168,24 @@ must not depend on a sibling checkout or files outside the distribution. ```bash git switch main && git pull - git tag -a v0.2.0 -m 'AgentCore 0.2.0' - git push origin v0.2.0 + git tag -a v0.3.0 -m 'AgentCore 0.3.0' + git push origin v0.3.0 ``` The `Release` workflow verifies the tag matches the declared version, - re-runs the full check suite against the tagged tree, builds, and publishes a - GitHub Release with the wheel, sdist, and changelog section. -4. The release workflow dispatches to ApodexHarness and FrontierAgent, each of - which opens a bump PR moving its pin to the new tag. See - [docs/downstream-bump.md](docs/downstream-bump.md) for the one-time token and - workflow setup those products need. + re-runs the full check suite against the tagged tree, builds and validates the + artifacts, publishes them to PyPI through Trusted Publishing, then creates a + retry-safe GitHub Release with the wheel, sdist, and changelog section. +4. Dependabot normally opens an exact-version bump PR in each product. While + GitHub's current `uv` updater defect is unresolved, open that PR with the + documented no-credential manual fallback instead. See + [docs/downstream-bump.md](docs/downstream-bump.md). 5. Product CI validates adapters and end-to-end behavior. Product PRs must not patch vendored/shared implementation code. [docs/versioning.md](docs/versioning.md) covers the version scheme, what counts -as a breaking change, and why AgentCore is not on 1.0 or a private package -registry yet. +as a breaking change, why AgentCore is not on 1.0 yet, and why publishing to +PyPI replaced the earlier Git-pin approach. Product-only bugs stay in the product repository. If a proposed fix needs to edit both products' core copies, that is evidence it belongs here. diff --git a/docs/downstream-bump.md b/docs/downstream-bump.md index 00a7273..a8a7c88 100644 --- a/docs/downstream-bump.md +++ b/docs/downstream-bump.md @@ -1,104 +1,137 @@ -# Automated downstream bump PRs +# Publishing releases and automating downstream bumps -When AgentCore publishes a release, both products should get a pull request -moving their pin — without anyone remembering to open it. This document is the -one-time setup. +AgentCore is public and publishes to PyPI. Consumers depend on a normal version +specifier, and **Dependabot** is the long-term bump mechanism — so there is no +cross-repository credential, no dispatch, and no bespoke repin script anywhere +in this design. A no-credential manual fallback covers the current upstream +Dependabot defect documented below. -Flow: release tag pushed → `Release` workflow publishes the GitHub Release → -`repository_dispatch` (`agent-core-release`) fires at both products → each opens -`chore/agent-core-` with the repinned `pyproject.toml` and `uv.lock`, and -its own CI validates the upgrade. +Flow: tag `v0.3.0` → `Release` workflow verifies, tests, and builds → publishes +to PyPI via Trusted Publishing → creates or repairs the matching GitHub Release +→ Dependabot (or the temporary manual fallback) opens a product pull request +that the product's own CI validates. -## One-time setup +## One-time setup in AgentCore -### 1. A GitHub App for release automation +### 1. Register the PyPI publisher (before the first release) -Create a GitHub App installed only on `ApodexAI/AgentCore`, -`ApodexAI/ApodexHarness`, and `ApodexAI/FrontierAgentInternal`. Grant the App: +`apodex-agent-core` does not exist on PyPI yet, so use the **pending publisher** +flow: on PyPI, go to your account → *Publishing* (not a project page, since +there is no project yet) and add a GitHub Actions publisher: -- **contents: write**, needed to dispatch and push bump branches; -- **pull-requests: write**, needed to open bump pull requests. - -GitHub App permissions apply to the installation rather than varying per -repository. The workflows therefore generate separate, short-lived tokens and -downscope each one to only the repositories and permissions needed by that -step: read-only for resolving AgentCore, product-write for a bump PR, and -downstream-only write access for dispatch. - -Do not create an installation token by hand and save it as a secret. -Installation tokens expire after one hour; the workflows use -`actions/create-github-app-token` to mint and revoke one for every run. - -### 2. In all three repositories - -Configure the same two values in AgentCore and each product: - -- repository variable `AGENT_CORE_AUTOMATION_APP_CLIENT_ID` — the App's client - ID; -- repository secret `AGENT_CORE_AUTOMATION_APP_PRIVATE_KEY` — the App's private - key. - -If those values are absent or invalid in AgentCore, publishing the release still -succeeds and logs a warning — dispatching is downstream plumbing and must never -fail a published release. Missing or invalid credentials in a product correctly -fail its bump workflow because it cannot safely resolve or push the update. - -### 3. In each product repository - -Copy both files out of `.github/downstream/` in this repository: - -| From | To | +| Field | Value | | --- | --- | -| `.github/downstream/bump-agent-core.yml` | `.github/workflows/bump-agent-core.yml` | -| `.github/downstream/repin_agent_core.py` | `.github/workflows/repin_agent_core.py` | - -The workflow creates one read-only token for AgentCore and a separate write -token for the current product. It checks out with `persist-credentials: false`, -so neither token remains embedded in the repository's Git configuration. Do not -replace the App token with the default `GITHUB_TOKEN`: pull requests created -with `GITHUB_TOKEN` do not trigger workflow runs, so the product's CI would never -run against the bump — which is the only reason the PR exists. - -## Verifying the wiring without cutting a release - -Each product's workflow also accepts `workflow_dispatch` with a version input. -Run it manually against an existing tag: it should open a PR, or report -`Already pinned`. Use the same path to recover a dispatch that was missed -because a secret was absent when the release ran. - -## Two things that will bite - -**The pin's declaration shape.** `repin_agent_core.py` substitutes the rev in -place and fails loudly if it does not find exactly one pin. Do not "simplify" it -to `uv add`: that rewrites the PEP 508 direct URL into uv's `[tool.uv.sources]` -table, which pip ignores, breaking non-uv install paths such as a Dockerfile -running `pip install .`. If the check reports a count other than 1, the -dependency was redeclared and the script needs updating — that is the intended -behavior, not an obstacle. - -**`uv.lock` must be committed with `pyproject.toml`.** The lock records both the -tag and the commit it resolved to, plus AgentCore's own version. A bump that -edits only `pyproject.toml` leaves the lock stale and fails `uv sync --frozen`. -The workflow commits both. - -## Prerequisite: the pin must be on the product's default branch - -As of AgentCore 0.2.0, **neither product declares AgentCore on `main`**. The pin -lives only on each product's in-progress migration branch: - -| Product | Branch carrying the pin | Pinned revision | -| --- | --- | --- | -| ApodexHarness | `fix/ci-bwrap-soft-probe` | `a9b5272` | -| FrontierAgentInternal | `refactor/agent-core` | `a9b5272` | - -Until one of those merges, this workflow has nothing to repin on `main`: -`repin_agent_core.py` will report `found 0` and exit non-zero rather than commit -a half-edited pin. That is the intended behavior, but it means the automation is -inert — install it now so it is ready, and expect its first real run only after -the migration lands. - -Both branches pin `a9b5272` (the #21 merge) and both lockfiles report -`apodex-agent-core 0.1.0`. Whichever merges first, moving it to `v0.2.0` also -picks up the five commits after that merge — the cooldown-fallback observability -fixes in `components/middleware/llm/base.py`, `providers/fallback.py`, and -`retry_policy.py`. +| PyPI project name | `apodex-agent-core` | +| Owner | `ApodexAI` | +| Repository name | `AgentCore` | +| Workflow name | `release.yml` | +| Environment | `pypi` | + +This needs **no GitHub organization permission and no GitHub App** — it is +configured entirely on the PyPI side by whoever will own the PyPI project. A +pending publisher does not reserve the name: it converts to a real publisher on +the first successful upload, and if someone else registers `apodex-agent-core` +first it is invalidated. + +### 2. Create the `pypi` environment on GitHub + +Settings → Environments → *New environment* → `pypi`. The publishing job +declares `environment: pypi`, and the registration above binds PyPI's trust to +that name. Add a required reviewer there if you want releases to pause for +approval. + +No secrets are involved: `permissions: id-token: write` on the publishing job +lets PyPI verify an OIDC claim naming this repository, this workflow file, and +that environment, and PyPI then issues its own short-lived upload token. + +## One-time setup in each product + +### 1. Depend on the published package + +```toml +dependencies = [ + "apodex-agent-core==0.3.0", +] +``` + +Pin exactly (`==`). AgentCore is `0.x`, where a MINOR bump may be breaking (see +[versioning.md](versioning.md)); an exact pin is what makes the Dependabot pull +request the place where an upgrade is reviewed. The old +`git+ssh://git@github.com/ApodexAI/AgentCore.git@` form and every credential +it required — deploy keys, `AGENT_CORE_REPO_TOKEN`, `insteadOf` rewriting — are +obsolete now that the package is public. + +### 2. Add `.github/dependabot.yml` + +```yaml +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "daily" + # Watch only AgentCore here. Everything else this product depends on is its + # own concern and would bury the one bump that needs product review. + allow: + - dependency-name: "apodex-agent-core" + commit-message: + prefix: "chore" + labels: + - "agent-core" +``` + +Dependabot's `uv` ecosystem is intended to update both `pyproject.toml` and +`uv.lock`, and it works with private and internal repositories. However, as of +2026-09-03 its hosted updater has an open defect that passes the target version +as an invalid positional argument to `uv lock`, so version-update jobs can fail +before opening a pull request. Track +[dependabot-core#15842](https://github.com/dependabot/dependabot-core/issues/15842) +and keep the configuration above in place so automation resumes when GitHub +ships the fix. + +Until then, update without any AgentCore credential from a product checkout: + +```bash +uv add 'apodex-agent-core==0.3.0' +uv sync --frozen +``` + +Commit both `pyproject.toml` and `uv.lock` in the same pull request and let the +product's normal CI validate it. Substitute the new release number each time; +the package and repository are public, so this fallback needs no token, deploy +key, GitHub App, or cross-repository permission. + +## The one real limitation + +**A Dependabot pull request runs CI as if it came from a fork.** Its workflow +run gets a read-only `GITHUB_TOKEN` and, critically, `secrets.*` resolves +against **Dependabot secrets** — a separate store — not Actions secrets. + +- Plain test CI (checkout, `uv sync`, `pytest`) works unchanged: it needs only + read access. +- Any step that consumes a secret sees an empty value unless that secret is + *also* added under Settings → Secrets and variables → **Dependabot**. Check + each product for steps that need one (container registry pushes, for instance) + and either duplicate the secret there or guard the step to skip on + Dependabot-authored pull requests. +- A step needing write access must ask for it explicitly with a `permissions:` + block. + +This is worth the trade: the alternative was a cross-repository App or PAT, +which needs organization-level administration and puts a long-lived credential +in both products. + +## Current state + +Neither product declares AgentCore on `main` yet. The dependency lives only on +each product's in-progress migration branch: + +| Product | Branch carrying the dependency | +| --- | --- | +| ApodexHarness | `fix/ci-bwrap-soft-probe` | +| FrontierAgentInternal | `refactor/agent-core` | + +Both still use the pre-open-source `git+ssh://…@a9b5272` form. Whichever merges +first should switch to `apodex-agent-core==0.3.0` and add the Dependabot config +above; Dependabot has nothing to update until a PyPI dependency is on the +default branch. diff --git a/docs/versioning.md b/docs/versioning.md index 907e6e6..de4ea54 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -81,50 +81,67 @@ Releases are cut from `main` after CI is green: ```bash git switch main && git pull python3 scripts/version.py # confirm the version you are about to tag -git tag -a v0.2.0 -m 'AgentCore 0.2.0' -git push origin v0.2.0 +git tag -a v0.3.0 -m 'AgentCore 0.3.0' +git push origin v0.3.0 ``` Pushing the tag triggers `.github/workflows/release.yml`, which: 1. verifies the tag matches `[project].version` (a mismatch fails the release); 2. re-runs ruff, pyright, and pytest against the tagged tree; -3. runs `uv build`; -4. creates a GitHub Release carrying the wheel, the sdist, and the CHANGELOG - section for that version. - -A tag is never moved or deleted once pushed — products may already have resolved -it. To correct a bad release, bump to the next PATCH and tag again. +3. runs `uv build` and `twine check`; +4. publishes to PyPI from a separate job using Trusted Publishing; +5. creates or repairs a GitHub Release carrying the wheel, the sdist, and the + CHANGELOG section for that version. + +**A PyPI version number is consumed permanently.** It cannot be re-uploaded even +after deleting the release or the entire project; yanking hides a release from +resolution but does not free the number. So a botched release is never fixed in +place — bump to the next PATCH and tag again. The same rule applies to tags, +which are never moved or deleted once pushed because a consumer may already have +resolved one. + +Because the number cannot be reclaimed, the release job runs `twine check` +before publishing, and it is worth doing a first-time dry run against TestPyPI +rather than discovering a metadata problem on the real index. ## How products consume a release -Pin the tag, not a branch and not a SHA: +Depend on the published package with an exact pin: ```toml dependencies = [ - "apodex-agent-core @ git+ssh://git@github.com/ApodexAI/AgentCore.git@v0.2.0", + "apodex-agent-core==0.3.0", ] ``` -A tag is as immutable as a SHA in practice (it is never moved, per above) and it -is readable in a diff, so a bump PR states plainly which version a product is -moving to. +Exact, because this is a `0.x` series where a MINOR bump may be breaking. The +pin is what makes each upgrade an explicit, reviewable event: Dependabot opens a +pull request against it and the product's own CI decides whether the new version +is safe. See [downstream-bump.md](downstream-bump.md). + +## On the package registry -## On a private package registry +AgentCore publishes to PyPI. This became the obvious choice when the repository +went public, and it is worth recording why the earlier answer was the opposite. -Not currently used, and not currently needed. A registry would buy convenience — -no SSH/token plumbing for `docker build` and CI runners, no full-repository clone -during `uv lock`, and clean resolution if AgentCore ever becomes a *transitive* -dependency. It buys nothing in reproducibility: a Git tag is already immutable, -whereas a registry version can in principle be yanked or replaced. +While the repository was private, the options were a private index +(CodeArtifact, Artifactory, Gemfury) or Git pins. Git pins won: a private index +costs credential rotation, availability, and backup work, while buying only +convenience — a Git tag was already immutable, so there was nothing to gain in +reproducibility. -With two first-party consumers in one organization, the credential rotation, -availability, and backup burden of a private index (CodeArtifact, Artifactory, -Gemfury) outweighs that convenience. Revisit when either becomes true: +Going public removed the entire cost side of that trade. Public PyPI needs no +credentials to read, nothing to operate, and Trusted Publishing means nothing to +store on the publishing side either. It also removed the *reason* for Git pins: +every consumer previously needed a credential just to resolve the dependency. -- a third consumer appears, or AgentCore becomes a transitive dependency; -- Git credential distribution starts causing real build failures. +What PyPI adds beyond convenience: -The intermediate step, if only the plumbing hurts: attach the wheel built by the -release workflow — already published on each GitHub Release — and install via -`--find-links`, with no index to operate. +- installation with no credential at all, including inside `docker build`; +- no full-repository clone during `uv lock`; +- clean resolution if AgentCore ever becomes a *transitive* dependency; +- Dependabot support, which replaces a bespoke cross-repository bump mechanism + that would otherwise need organization-level administration; +- stronger immutability than a Git tag, not weaker: a published version number + can never be reused, whereas a tag is only immutable by convention. diff --git a/pyproject.toml b/pyproject.toml index 581160e..7529927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,21 @@ [project] name = "apodex-agent-core" -version = "0.2.0" +version = "0.3.0" description = "Shared, product-neutral runtime primitives for Apodex agents" readme = "README.md" -license = { file = "LICENSE" } +license = "Apache-2.0" +license-files = ["LICENSE"] requires-python = ">=3.12" +keywords = ["agent", "llm", "runtime", "agent-loop", "tool-calling"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries", + "Typing :: Typed", +] dependencies = [ "anthropic[bedrock]>=0.69", "httpx>=0.27,<1", @@ -28,6 +39,12 @@ dev = [ "pyyaml>=6.0", ] +[project.urls] +Homepage = "https://github.com/ApodexAI/AgentCore" +Repository = "https://github.com/ApodexAI/AgentCore" +Changelog = "https://github.com/ApodexAI/AgentCore/blob/main/CHANGELOG.md" +Issues = "https://github.com/ApodexAI/AgentCore/issues" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/tests/test_release_automation.py b/tests/test_release_automation.py index bcc4a9b..f74a0b0 100644 --- a/tests/test_release_automation.py +++ b/tests/test_release_automation.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest +import yaml from scripts import check_version_bump @@ -58,22 +59,107 @@ def test_version_label_changes_retrigger_ci() -> None: assert "types: [opened, synchronize, reopened, labeled, unlabeled]" in workflow -def test_downstream_version_input_is_not_interpolated_into_shell_source() -> None: - workflow = (ROOT / ".github/downstream/bump-agent-core.yml").read_text(encoding="utf-8") +def _release_workflow() -> str: + return (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") - assert "INPUT_VERSION: ${{ github.event.client_payload.version || inputs.version }}" in workflow - assert 'version="$INPUT_VERSION"' in workflow - assert 'version="${{ github.event.client_payload.version || inputs.version }}"' not in workflow - assert "persist-credentials: false" in workflow +def test_pypi_publishing_uses_trusted_publishing_without_any_token() -> None: + """A stored PyPI token is the thing Trusted Publishing exists to remove. -def test_workflows_mint_short_lived_github_app_tokens() -> None: - downstream = (ROOT / ".github/downstream/bump-agent-core.yml").read_text(encoding="utf-8") - release = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + Reintroducing one would be a silent downgrade: publishing keeps working, so + nothing fails to reveal that a long-lived credential is back in the repo. + """ + release = _release_workflow() - action = "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1" - assert downstream.count(action) == 2 - assert release.count(action) == 1 - assert "DOWNSTREAM_BUMP_TOKEN" not in release - assert "BUMP_PR_TOKEN" not in downstream - assert "AGENT_CORE_REPO_TOKEN: ${{ secrets." not in downstream + assert "pypa/gh-action-pypi-publish@" in release + for forbidden in ("PYPI_API_TOKEN", "PYPI_TOKEN", "TWINE_PASSWORD", "password:"): + assert forbidden not in release, forbidden + + +def test_id_token_permission_is_scoped_to_the_publish_job_alone() -> None: + """`id-token: write` mints the identity PyPI trusts. + + Granted workflow-wide, every third-party action in the build could request + that identity, so the permission must sit on the publishing job only. + """ + workflow = yaml.safe_load(_release_workflow()) + jobs = workflow["jobs"] + + assert jobs["publish-pypi"]["permissions"] == {"id-token": "write"} + assert jobs["publish-pypi"]["needs"] == "build" + assert "id-token" not in jobs["build"]["permissions"] + assert jobs["build"]["permissions"] == {"contents": "read"} + # Workflow-level permissions would apply to both jobs. + assert "permissions" not in workflow + + +def test_github_release_is_published_last_and_is_retry_safe() -> None: + """A failed upload must not advertise a release that is absent from PyPI.""" + workflow = yaml.safe_load(_release_workflow()) + jobs = workflow["jobs"] + + assert jobs["publish-github"]["needs"] == "publish-pypi" + assert jobs["publish-github"]["permissions"] == {"contents": "write"} + release = _release_workflow() + assert 'gh release view "$TAG"' in release + assert 'gh release upload "$TAG" --clobber' in release + assert "packages-dir: release-artifacts/dist/" in release + + +def test_metadata_is_validated_before_a_version_number_is_consumed() -> None: + """A PyPI version can never be reused, not even after deletion. + + Invalid metadata must fail the build rather than burn the number. + """ + assert "twine check dist/*" in _release_workflow() + + +def test_the_removed_dispatch_machinery_has_not_returned() -> None: + """Downstream bumps are Dependabot's job now. + + The dispatch/repin path needed a cross-repository write credential, which is + exactly what publishing to PyPI removed the need for. + """ + assert not (ROOT / ".github/downstream").exists() + + release = _release_workflow() + for forbidden in ("repository_dispatch", "DOWNSTREAM_BUMP_TOKEN", "create-github-app-token"): + assert forbidden not in release, forbidden + + +def test_release_verifies_the_wheel_installs_and_imports() -> None: + """The artifact, not just the tree, is what consumers get. + + A wheel that builds but cannot be imported would consume the version number + before anyone noticed, and PyPI never releases a number back. + """ + release = _release_workflow() + + assert "uv pip install --python /tmp/wheel-smoke/bin/python dist/*.whl" in release + assert "import agent_core" in release + + +def test_typed_marker_backs_the_typing_classifier() -> None: + """`Typing :: Typed` is a promise consumers' type checkers rely on.""" + import tomllib + + with (ROOT / "pyproject.toml").open("rb") as handle: + project = tomllib.load(handle)["project"] + + assert "Typing :: Typed" in project["classifiers"] + assert (ROOT / "agent_core/py.typed").is_file() + + +def test_license_is_declared_as_an_spdx_expression() -> None: + """PEP 639: an SPDX expression, and no redundant License:: classifier. + + Declaring both makes PyPI reject the upload — after the tag already exists. + """ + import tomllib + + with (ROOT / "pyproject.toml").open("rb") as handle: + project = tomllib.load(handle)["project"] + + assert project["license"] == "Apache-2.0" + assert project["license-files"] == ["LICENSE"] + assert not [c for c in project["classifiers"] if c.startswith("License ::")] diff --git a/uv.lock b/uv.lock index f7db215..908612c 100644 --- a/uv.lock +++ b/uv.lock @@ -50,7 +50,7 @@ wheels = [ [[package]] name = "apodex-agent-core" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "anthropic", extra = ["bedrock"] },