From f31d59bdf135689d1248c7c30625b407537cf92c Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Thu, 3 Sep 2026 12:53:25 +0800 Subject: [PATCH 1/2] feat: version discipline, tagged releases, and downstream bump automation Two products consume AgentCore by pinning a revision, but [project].version never moved off the bootstrap 0.1.0 across 80 commits. Both products therefore report the same version for different code, and the installed dist-info identifies nothing. Establish the version as a real identifier and automate its propagation: - Bump to 0.2.0 and relock. 0.1.0 is retired rather than reused: it named no particular revision. - CI fails a pull request that changes agent_core/ or pyproject.toml without moving the version, with a 'skip-version-bump' label as the escape hatch. This is the gap that let 80 commits ship as 0.1.0. - Release workflow: a v* tag is verified against the declared version, the tagged tree is re-checked (a tag can point at a commit no pull request saw), built, and published as a GitHub Release carrying the wheel, sdist, and its changelog section. A missing changelog entry fails the release. - Release dispatches to both products, which repin and open a bump PR for their own CI to validate. Templates in .github/downstream/, setup in docs/downstream-bump.md. - docs/versioning.md records the scheme, what counts as breaking while the effective public surface is wider than agent_core.__all__, why 1.0 cannot be promised yet, and why a private registry is not yet worth its operating cost. Two corrections found by testing against the real consumers: - The documented pin scheme was https://, but both products declare git+ssh://git@github.com/. The credential note rewrote https://github.com/, which is a no-op against an ssh:// dependency URL. - Repinning uses an in-place rev substitution, not `uv add`: uv add rewrites the PEP 508 direct URL into uv's proprietary [tool.uv.sources] table, which pip ignores, silently breaking non-uv install paths. Co-Authored-By: Claude Opus 5 (1M context) --- .github/downstream/bump-agent-core.yml | 123 ++++++++++++++++++++++++ .github/downstream/repin_agent_core.py | 57 +++++++++++ .github/workflows/ci.yml | 20 +++- .github/workflows/release.yml | 73 ++++++++++++++ CHANGELOG.md | 61 ++++++++++++ README.md | 47 +++++++-- docs/downstream-bump.md | 80 ++++++++++++++++ docs/versioning.md | 127 +++++++++++++++++++++++++ pyproject.toml | 2 +- scripts/changelog_section.py | 62 ++++++++++++ scripts/check_version_bump.py | 89 +++++++++++++++++ scripts/version.py | 55 +++++++++++ uv.lock | 2 +- 13 files changed, 786 insertions(+), 12 deletions(-) create mode 100644 .github/downstream/bump-agent-core.yml create mode 100644 .github/downstream/repin_agent_core.py create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 docs/downstream-bump.md create mode 100644 docs/versioning.md create mode 100644 scripts/changelog_section.py create mode 100644 scripts/check_version_bump.py create mode 100644 scripts/version.py diff --git a/.github/downstream/bump-agent-core.yml b/.github/downstream/bump-agent-core.yml new file mode 100644 index 0000000..1103636 --- /dev/null +++ b/.github/downstream/bump-agent-core.yml @@ -0,0 +1,123 @@ +# 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 secrets: +# AGENT_CORE_REPO_TOKEN — read access to the private ApodexAI/AgentCore, so +# `uv` can resolve the git dependency. +# BUMP_PR_TOKEN — token used to push the branch and open the PR. This must NOT +# be the default GITHUB_TOKEN: pull requests created with GITHUB_TOKEN do +# not trigger workflow runs, so this product's CI would never run against +# the bump — which is the entire point of the PR. Use a GitHub App +# installation token or a fine-grained PAT with contents:write and +# pull-requests:write on this repository. +# Both may be the same App token if it is scoped to both repositories. + +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 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ secrets.BUMP_PR_TOKEN }} + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.12" + + - name: Resolve target version + run: | + set -euo pipefail + version="${{ github.event.client_payload.version || inputs.version }}" + case "$version" in + v[0-9]*) ;; + *) echo "Refusing to pin '$version': expected a v-prefixed release tag." >&2 + exit 1 ;; + esac + echo "VERSION=$version" >> "$GITHUB_ENV" + echo "BRANCH=chore/agent-core-$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: ${{ secrets.AGENT_CORE_REPO_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: ${{ secrets.BUMP_PR_TOKEN }} + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + # 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 view "$BRANCH" --json number >/dev/null 2>&1; 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/ci.yml b/.github/workflows/ci.yml index d06e816..a0622f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,25 @@ jobs: with: python-version: "3.12" - run: uv sync --frozen --extra dev - - run: uv run ruff check agent_core tests + - run: uv run ruff check agent_core tests scripts - run: uv run pyright agent_core - run: uv run pytest -q - run: uv build + + # Two products pin an AgentCore revision. If published code changes without a + # version bump, both end up reporting the same version for different code and + # the installed dist-info stops identifying what is running. Enforce the bump + # at the pull request, where it is cheap to fix. + version-bump: + if: >- + github.event_name == 'pull_request' && + !contains(github.event.pull_request.labels.*.name, 'skip-version-bump') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The check needs the merge base, which a shallow clone does not have. + fetch-depth: 0 + - run: python3 scripts/check_version_bump.py --base "$BASE_SHA" + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..198fc9c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.12" + + # Fail before building anything if the tag names a version the tree does + # not declare, or if the release has no changelog entry to publish. + - name: Verify tag matches declared version + run: python3 scripts/version.py --check-tag "$TAG" + env: + TAG: ${{ github.ref_name }} + + - name: Extract release notes + run: | + set -euo pipefail + version="$(python3 scripts/version.py)" + python3 scripts/changelog_section.py "$version" > release-notes.md + + # The tagged tree is re-verified rather than trusting main's CI run: a tag + # can point at a commit that never went through a pull request. + - run: uv sync --frozen --extra dev + - run: uv run ruff check agent_core tests scripts + - run: uv run pyright agent_core + - run: uv run pytest -q + - run: uv build + + - name: Publish GitHub Release + 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 }} + + # 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 a missing token degrades to a warning: the release itself is + # already published and must not be failed by downstream plumbing. + - name: Request downstream bump PRs + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ secrets.DOWNSTREAM_BUMP_TOKEN }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "::warning title=No downstream dispatch::DOWNSTREAM_BUMP_TOKEN is not set; \ + no bump PRs were requested. Open them manually, or see docs/downstream-bump.md." + exit 0 + fi + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6e85870 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +Notable changes to `apodex-agent-core`, newest first. This file is the contract +between AgentCore and its consumers: every release entry must say what a product +has to know before upgrading. The release workflow reads the matching section as +the GitHub Release body, so a release with no entry here fails. + +Versioning follows [docs/versioning.md](docs/versioning.md). + +## [0.2.0] - 2026-09-03 + +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. + +Why 0.2.0 and not 0.1.1: `0.1.0` was the bootstrap constant and was never +released or moved. Every commit from the initial foundation through the +cooldown-fallback observability work declared that same version, so `0.1.0` +identifies no particular code and is retired rather than reused. + +### Published surface + +The importable surface is `agent_core.*` as scoped in the README, with per-area +contracts in `docs/*-boundary.md`: messages and token estimation, context +management and spill storage, LLM runtime (binding, streaming, retry +classification, runaway recovery), the agent loop and its typed product hooks, +durable run journals, MiniDAG and registries, middleware and observers, agent-bus +coordination, skills, and scheduling. + +### Consumer action + +Repin from a commit SHA to the tag: + +```toml +dependencies = [ + "apodex-agent-core @ git+ssh://git@github.com/ApodexAI/AgentCore.git@v0.2.0", +] +``` + +No source changes are required: the code at `v0.2.0` is `main` as of this +release. Installed metadata now reports `0.2.0`, so `pip list` and the +`dist-info` in a product image finally identify which AgentCore is running. + +### Added + +- `docs/versioning.md`: version policy, what counts as a breaking change while + the public surface is still wider than `agent_core.__all__`, and the release + procedure. +- `CHANGELOG.md` (this file). +- CI now fails a pull request that changes published code without bumping + `[project].version`, which is what let 80 commits ship as `0.1.0`. +- `Release` workflow: pushing a `v*` tag re-runs the full check suite, verifies + the tag matches `[project].version`, builds the wheel and sdist, and publishes + them on a GitHub Release with these notes attached. +- Automated downstream bump PRs: a release dispatches to ApodexHarness and + FrontierAgentInternal, which repin and open a pull request for their own CI to + validate. Templates live in `.github/downstream/`; setup is + [docs/downstream-bump.md](docs/downstream-bump.md). +- Corrected the documented pin scheme to `git+ssh://git@github.com/`, which is + what both products actually declare. The credential note previously rewrote + `https://github.com/`, a no-op against an `ssh://` dependency URL. diff --git a/README.md b/README.md index b82276d..fe581cb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ products consume an immutable AgentCore revision and keep only their adapters. ## Current scope -Version `0.1.x` contains the converged foundation layer: +Version `0.2.x` contains the converged foundation layer: - native message types and constructors; - token-estimation helpers; @@ -85,7 +85,7 @@ provider transports and product-neutral affinity lifecycle safeguards. ```bash uv sync --frozen --extra dev -uv run ruff check agent_core tests +uv run ruff check agent_core tests scripts uv run pyright agent_core uv run pytest -q uv build @@ -102,14 +102,21 @@ AgentCore uses the import namespace `agent_core` and the distribution name ## Consuming a private revision -Products should pin an immutable commit, never a branch: +Products should pin a released tag, never a branch: ```toml dependencies = [ - "apodex-agent-core @ git+https://github.com/ApodexAI/AgentCore.git@", + "apodex-agent-core @ git+ssh://git@github.com/ApodexAI/AgentCore.git@v0.2.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 +[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`. Use a dedicated GitHub App or fine-grained token stored as `AGENT_CORE_REPO_TOKEN`; do not use a developer's personal token. @@ -118,9 +125,13 @@ Configure Git before `uv sync`: ```bash git config --global \ url."https://x-access-token:${AGENT_CORE_REPO_TOKEN}@github.com/".insteadOf \ - "https://github.com/" + "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. @@ -128,13 +139,31 @@ clean checkout and CI unreproducible. ## Change and release workflow 1. Reproduce a shared bug with an AgentCore test. -2. Change AgentCore in one pull request and pass its standalone CI. -3. Merge and record the immutable commit SHA (or publish a tagged version). -4. Automation opens dependency-bump PRs in ApodexHarness and - FrontierAgentInternal. +2. Change AgentCore in one pull request. Bump `[project].version`, run + `uv lock`, and add a `CHANGELOG.md` entry — CI fails a pull request that + changes published code without a version bump. +3. Merge, then tag and push: + + ```bash + git switch main && git pull + git tag -a v0.2.0 -m 'AgentCore 0.2.0' + git push origin v0.2.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 FrontierAgentInternal, + 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. 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. + 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 new file mode 100644 index 0000000..04f3a5f --- /dev/null +++ b/docs/downstream-bump.md @@ -0,0 +1,80 @@ +# Automated downstream bump PRs + +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. + +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. + +## One-time setup + +### 1. A token that can reach both repositories + +Create a GitHub App installed on `ApodexAI/AgentCore`, +`ApodexAI/ApodexHarness`, and `ApodexAI/FrontierAgentInternal`, with: + +- **contents: read** on AgentCore (so `uv` can resolve the private dependency); +- **contents: write** and **pull-requests: write** on the two products. + +A fine-grained PAT works too, but must not be a personal one — it becomes a +single-person dependency for every release. Do not reuse the default +`GITHUB_TOKEN`; see the warning in step 3. + +### 2. In AgentCore + +Add the token as the `DOWNSTREAM_BUMP_TOKEN` secret. Without it the release +still succeeds and logs a warning — dispatching is downstream plumbing and must +never fail a published release. + +### 3. In each product repository + +Copy both files out of `.github/downstream/` in this repository: + +| From | To | +| --- | --- | +| `.github/downstream/bump-agent-core.yml` | `.github/workflows/bump-agent-core.yml` | +| `.github/downstream/repin_agent_core.py` | `.github/workflows/repin_agent_core.py` | + +Then add two secrets: + +- `AGENT_CORE_REPO_TOKEN` — read access to AgentCore, for dependency resolution. +- `BUMP_PR_TOKEN` — used to push the branch and open the PR. + +**`BUMP_PR_TOKEN` must not be 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. The failure is +silent: you get a PR with no checks on it. + +## 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. + +## What the products currently pin + +As of AgentCore 0.2.0, both products pin commit +`a9b5272` (the PR #21 merge) and both report `apodex-agent-core 0.1.0` in their +lockfiles. Moving them 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`. diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..6e7d3fc --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,127 @@ +# Versioning and release + +AgentCore is consumed by ApodexHarness and FrontierAgentInternal, which pin an +immutable revision. This document defines what a version number means here, when +to bump it, and how to publish one. + +## Scheme: `0.MINOR.PATCH` + +- **MINOR** — a breaking change to the published surface, or new capability. +- **PATCH** — a fix or internal change that cannot alter how a correct consumer + behaves. + +Both products live in the same organization and upgrade deliberately, so MINOR +carries breaking changes rather than reserving a MAJOR for them. This is the +standard reading of a `0.x` series: treat MINOR as the compatibility boundary and +pin accordingly. + +### Why not 1.0 yet + +1.0 is a promise about a stable, enumerated API. AgentCore cannot make it today: +`agent_core.__all__` exports 14 symbols, but consumers import from deep paths +(`agent_core.runtime.loop`, `agent_core.components.middleware.llm`, and others), +so the *effective* public surface is far larger than the declared one. Until the +surface is deliberately narrowed — or the deep paths are explicitly blessed as +public — "is this a breaking change?" cannot be answered consistently, and a 1.0 +would be a number without a guarantee behind it. + +Prerequisite for 1.0: an explicit statement of which import paths are public, +with everything else moved under a private prefix or re-exported. + +## What counts as breaking + +Because the deep paths are in practice public, assume any of these is breaking +until shown otherwise: + +- removing or renaming a module, class, function, or attribute reachable from + `agent_core.*`, including deep paths; +- changing a function signature other than by adding a keyword argument with a + default; +- changing a `Protocol` that products implement (a new required method breaks + every product implementation) — see `docs/*-boundary.md` for the contracts + products are expected to satisfy; +- changing the type or meaning of a field on a shared model (`Message`, + `LLMResponse`, `StreamDelta`, …); +- changing observable runtime behavior a product depends on: emitted event types + and their payloads, error types raised, retry classification outcomes, + compaction or trimming decisions; +- tightening a dependency floor in a way that can conflict with a product's + own pins. + +Not breaking: added modules and symbols, added optional keyword arguments, added +event fields consumers can ignore, internal refactors with identical observable +behavior, tests, docs, and tooling. + +When in doubt, bump MINOR. The cost of an unnecessary MINOR is nothing; the cost +of a breaking PATCH is a product discovering it in production. + +## Bumping + +CI fails any pull request that touches `agent_core/` or `pyproject.toml` without +moving `[project].version`. To bump: + +```bash +# 1. Edit [project].version in pyproject.toml. +# 2. Sync the lockfile — uv.lock records this project's own version, and a stale +# lock makes `uv sync --frozen` fail in CI and in both products. +uv lock +# 3. Add a '## [] - ' section to CHANGELOG.md. +``` + +If a change genuinely cannot affect consumers and the check is wrong, apply the +`skip-version-bump` label to the pull request and say why in the description. + +## Releasing + +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 +``` + +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. + +## How products consume a release + +Pin the tag, not a branch and not a SHA: + +```toml +dependencies = [ + "apodex-agent-core @ git+ssh://git@github.com/ApodexAI/AgentCore.git@v0.2.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. + +## On a private package registry + +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. + +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: + +- a third consumer appears, or AgentCore becomes a transitive dependency; +- Git credential distribution starts causing real build failures. + +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. diff --git a/pyproject.toml b/pyproject.toml index e58aeab..4a9d79b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "apodex-agent-core" -version = "0.1.0" +version = "0.2.0" description = "Shared, product-neutral runtime primitives for Apodex agents" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/changelog_section.py b/scripts/changelog_section.py new file mode 100644 index 0000000..ba185c2 --- /dev/null +++ b/scripts/changelog_section.py @@ -0,0 +1,62 @@ +"""Print one version's section from CHANGELOG.md. + +The release workflow uses this as the GitHub Release body, which makes a missing +CHANGELOG entry a hard release failure rather than a silently empty release +note. Consumers reading a bump PR need to know what changed without diffing the +tag range by hand. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +HEADING = re.compile(r"^## \[(?P[^\]]+)\]") + + +def extract(version: str, text: str) -> str | None: + lines = text.splitlines() + start: int | None = None + + for index, line in enumerate(lines): + match = HEADING.match(line) + if match is None: + continue + if start is None and match.group("version") == version: + start = index + 1 + continue + if start is not None: + # The next version heading terminates the section. + return "\n".join(lines[start:index]).strip() + + if start is None: + return None + return "\n".join(lines[start:]).strip() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="Version to extract, without a leading 'v'.") + args = parser.parse_args(argv) + + changelog = ROOT / "CHANGELOG.md" + section = extract(args.version, changelog.read_text(encoding="utf-8")) + + if not section: + print( + f"CHANGELOG.md has no entry for {args.version!r}.\n" + f"Add a '## [{args.version}] - ' section describing what " + "consumers must know before upgrading.", + file=sys.stderr, + ) + return 1 + + print(section) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_version_bump.py b/scripts/check_version_bump.py new file mode 100644 index 0000000..d89bcd5 --- /dev/null +++ b/scripts/check_version_bump.py @@ -0,0 +1,89 @@ +"""Fail a pull request that changes shared runtime code without bumping the version. + +Two products consume AgentCore by pinning a revision. When ``agent_core/`` +changes but ``[project].version`` does not, both products end up reporting the +same version for different code: the installed ``dist-info`` stops identifying +what is actually running, and no version constraint downstream can mean +anything. This check is the enforcement point for that rule. + +Docs-only, test-only, and tooling-only pull requests are exempt, because they +change nothing a consumer can import. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tomllib +from pathlib import Path + +# Support being run as a plain script from any working directory. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from version import read_version + +# Paths whose contents are importable by a consumer. A change under any of these +# alters the published artifact and therefore requires a new version. +PUBLISHED_PATHS = ("agent_core/", "pyproject.toml") + + +def _git(*args: str) -> str: + return subprocess.run( + ["git", *args], check=True, capture_output=True, text=True + ).stdout + + +def changed_files(base: str) -> list[str]: + # Two-dot diff: what this branch's tip looks like against the merge base, + # which is what the merge would actually land. + merge_base = _git("merge-base", base, "HEAD").strip() + out = _git("diff", "--name-only", f"{merge_base}..HEAD") + return [line for line in out.splitlines() if line] + + +def base_version(base: str) -> str | None: + try: + blob = _git("show", f"{base}:pyproject.toml") + except subprocess.CalledProcessError: + # No pyproject on the base ref: nothing to compare against, so nothing + # this check can meaningfully assert. + return None + return tomllib.loads(blob)["project"]["version"] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True, help="Base ref or SHA of the pull request.") + args = parser.parse_args(argv) + + touched = [f for f in changed_files(args.base) if f.startswith(PUBLISHED_PATHS)] + if not touched: + print("No published code changed; version bump not required.") + return 0 + + current = read_version() + previous = base_version(args.base) + + if previous is None or current != previous: + print(f"Published code changed and version moved {previous} -> {current}.") + return 0 + + listed = "\n ".join(touched[:20]) + overflow = f"\n ... and {len(touched) - 20} more" if len(touched) > 20 else "" + print( + f"This pull request changes published code but leaves [project].version at " + f"{current!r}.\n\n" + f"Changed:\n {listed}{overflow}\n\n" + "Bump [project].version in pyproject.toml, then run `uv lock` so the " + "lockfile's self-entry matches (otherwise `uv sync --frozen` fails), and " + "add a CHANGELOG.md entry. See docs/versioning.md for how to choose the " + "new number. If this change genuinely cannot affect consumers, apply the " + "'skip-version-bump' label.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/version.py b/scripts/version.py new file mode 100644 index 0000000..516f3a4 --- /dev/null +++ b/scripts/version.py @@ -0,0 +1,55 @@ +"""Single source of truth for reading the distribution version. + +CI and the release workflow both need the version declared in +``pyproject.toml``. Parsing it with ``tomllib`` rather than ``grep`` keeps the +two from disagreeing when the file is reformatted, and makes the failure mode a +clear traceback instead of a silently empty string. +""" + +from __future__ import annotations + +import argparse +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def read_version(pyproject: Path | None = None) -> str: + path = pyproject or ROOT / "pyproject.toml" + with path.open("rb") as handle: + return tomllib.load(handle)["project"]["version"] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check-tag", + metavar="TAG", + help="Verify a git tag ('v1.2.3' or '1.2.3') matches the declared version.", + ) + args = parser.parse_args(argv) + + version = read_version() + + if args.check_tag is None: + print(version) + return 0 + + tagged = args.check_tag.removeprefix("refs/tags/").removeprefix("v") + if tagged != version: + print( + f"tag {args.check_tag!r} does not match pyproject version {version!r}.\n" + "A release tag must name the version it publishes: either move the tag " + "or bump [project].version (and re-run `uv lock`).", + file=sys.stderr, + ) + return 1 + + print(version) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index fe9fa88..f7db215 100644 --- a/uv.lock +++ b/uv.lock @@ -50,7 +50,7 @@ wheels = [ [[package]] name = "apodex-agent-core" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "anthropic", extra = ["bedrock"] }, From 48d5866cf8f5c2588a5b7c82f71714a24dea6e1b Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Thu, 3 Sep 2026 12:55:53 +0800 Subject: [PATCH 2/2] docs: correct the downstream pin's actual location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither product declares AgentCore on main: the pin exists only on each product's in-progress migration branch (fix/ci-bwrap-soft-probe, refactor/agent-core), and neither has an open pull request yet. This matters for the bump automation, which repins on the default branch. Until a migration merges, repin_agent_core.py reports 'found 0' and exits non-zero rather than committing a half-edited pin — correct behavior, but it makes the automation inert until then. Say so where someone setting it up will read it. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +++++ docs/downstream-bump.md | 27 ++++++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e85870..d30c1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,11 @@ No source changes are required: the code at `v0.2.0` is `main` as of this release. Installed metadata now reports `0.2.0`, so `pip list` and the `dist-info` in a product image finally identify which AgentCore is running. +Note that as of this release neither product declares AgentCore on its `main` +branch — the pin exists only on each product's in-progress migration branch +(`fix/ci-bwrap-soft-probe`, `refactor/agent-core`). The repin above applies +wherever the declaration currently lives. + ### Added - `docs/versioning.md`: version policy, what counts as a breaking change while diff --git a/docs/downstream-bump.md b/docs/downstream-bump.md index 04f3a5f..f6fb243 100644 --- a/docs/downstream-bump.md +++ b/docs/downstream-bump.md @@ -70,11 +70,24 @@ 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. -## What the products currently pin - -As of AgentCore 0.2.0, both products pin commit -`a9b5272` (the PR #21 merge) and both report `apodex-agent-core 0.1.0` in their -lockfiles. Moving them 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 +## 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`.