Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,16 @@
- run: uv run --isolated --frozen --extra dev --extra tokenizer pytest -q tests/test_tokenizer_nonblocking.py::test_pinned_cache_metadata_matches_tiktokens_own_declaration
- 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.
# Keep the existing status-check name for branch protection. Feature PRs
# require independent fragments; only release PRs change shared version files.
version-bump:
if: >-
github.event_name == 'pull_request' &&
!contains(github.event.pull_request.labels.*.name, 'skip-version-bump')
if: github.event_name == 'pull_request'
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 }}

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
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.
# not declare, has pending change fragments, a stale lock, or no release notes.
- name: Verify tag matches declared version
run: python3 scripts/version.py --check-tag "$TAG"
env:
Expand Down
Empty file added changes/.gitkeep
Empty file.
48 changes: 36 additions & 12 deletions docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,48 @@ 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
## Feature pull requests

CI fails any pull request that touches `agent_core/` or `pyproject.toml` without
increasing the three-part `[project].version`. Equal versions, downgrades, and
malformed versions are rejected. To bump:
Feature and fix PRs do not bump the package version or edit `CHANGELOG.md`.
Add one independent file under `changes/`, using the PR number or a unique slug:

- `changes/42.fix.md` for a bug fix or internal change;
- `changes/43.feature.md` for a new capability;
- `changes/example.breaking.md` for a compatibility change.

Write a non-empty release-note paragraph without headings. Explain the consumer
impact and any required migration. Each PR gets its own file, so merging another
feature PR does not conflict on a shared version, lockfile or changelog entry.
CI requires a newly added fragment for published code changes and validates all
pending fragments. Docs/tests/tooling-only PRs need no fragment. The CI status
keeps the name `version-bump` for branch-protection compatibility; the old
`skip-version-bump` label no longer bypasses the gate.

Between releases, main keeps the last released package version. Use a commit SHA
to identify development snapshots; distribution versions identify releases.
Consumers requiring a distinct package version should consume published releases.

## Preparing a release

Create one release PR from current main after the desired feature PRs merge:

```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.
python3 scripts/prepare_release.py --dry-run # review the aggregated notes
python3 scripts/prepare_release.py
uv lock
# 3. Add a '## [<version>] - <YYYY-MM-DD>' 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.
Adding or removing that label triggers a fresh CI run, so the gate reflects the
current escape-hatch decision without requiring an unrelated commit.
The script selects the next PATCH for fixes only, or MINOR for any feature or
breaking change. `--version 0.X.Y` can select a higher version. It updates
`pyproject.toml`, prepends one dated `CHANGELOG.md` section, and removes the
consumed fragments. Commit those changes together with `uv.lock`. CI rejects a
stale lock, a missing release entry, remaining fragments, or a version downgrade.
Only this release PR edits the shared version/changelog, so serialize releases.
If more feature PRs merge while the release PR is open, regenerate the release
from updated main to include their fragments before tagging.

Tags additionally reject any pending fragments and stale project lock version,
so unreleased main cannot accidentally publish changes under the old version.

## Releasing

Expand Down
82 changes: 39 additions & 43 deletions scripts/check_version_bump.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,4 @@
"""Fail a pull request that changes shared runtime code without increasing the version.

Two products consume AgentCore by pinning a revision. When ``agent_core/``
changes but ``[project].version`` does not increase, both products can 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.
"""
"""Require independent change fragments for code PRs and validate release PRs."""

from __future__ import annotations

Expand All @@ -20,9 +10,12 @@
from pathlib import Path

# Support being run as a plain script from any working directory.
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from scripts.release_notes import fragments, read_fragment, validate_lock, validate_release
from scripts.version import read_version

from version import read_version
ROOT = Path(__file__).resolve().parent.parent

# Paths whose contents are importable by a consumer. A change under any of these
# alters the published artifact and therefore requires a new version.
Expand Down Expand Up @@ -69,43 +62,46 @@ def main(argv: list[str] | None = None) -> int:
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

changed = changed_files(args.base)
touched = [f for f in changed if f.startswith(PUBLISHED_PATHS)]
current = read_version()
previous = base_version(args.base)

if previous is None:
print(f"Published code changed and version moved {previous} -> {current}.")
return 0

try:
increased = version_key(current) > version_key(previous)
current_key = version_key(current)
previous_key = version_key(previous) if previous is not None else current_key
validate_lock(ROOT, current)
for path in fragments(ROOT):
read_fragment(path)
if current_key < previous_key:
raise ValueError(f"Version must not decrease: {previous} -> {current}")
if current_key > previous_key:
base_fragments = _git("ls-tree", "-r", "--name-only", args.base, "--", "changes/").splitlines()
if any(p.endswith((".feature.md", ".breaking.md")) for p in base_fragments):
minimum = (previous_key[0], previous_key[1] + 1, 0)
if current_key < minimum:
raise ValueError("Feature or breaking fragments require a MINOR release")
validate_release(ROOT, current)
print(f"Release validated: {previous} -> {current}")
return 0
merge_base = _git("merge-base", args.base, "HEAD").strip()
changed_existing = _git("diff", "--no-renames", "--diff-filter=MD", "--name-only", f"{merge_base}..HEAD").splitlines()
if any(p.startswith("changes/") and p.endswith(".md") for p in changed_existing):
raise ValueError("Only a release PR may modify or remove existing change fragments")
if "CHANGELOG.md" in changed:
raise ValueError("Keep CHANGELOG.md for release PRs; add a changes/<id>.<kind>.md fragment instead")
if not touched:
print("No published code changed; release fragment not required.")
return 0
added = _git("diff", "--diff-filter=A", "--name-only", f"{merge_base}..HEAD").splitlines()
new_fragments = [p for p in fragments(ROOT) if p.relative_to(ROOT).as_posix() in added]
if not new_fragments:
raise ValueError("Published code changed: add changes/<id>.fix.md, .feature.md or .breaking.md; do not bump the version in a feature PR")
print("Published change has a new release fragment; version stays unchanged until release.")
return 0
except ValueError as error:
print(str(error), file=sys.stderr)
return 1

if increased:
print(f"Published code changed and version increased {previous} -> {current}.")
return 0

listed = "\n ".join(touched[:20])
overflow = f"\n ... and {len(touched) - 20} more" if len(touched) > 20 else ""
print(
"This pull request changes published code but does not increase "
f"[project].version ({previous!r} -> {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())
77 changes: 77 additions & 0 deletions scripts/prepare_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Collect pending fragments into one release; run uv lock afterwards."""

from __future__ import annotations

import argparse
import datetime
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from scripts.check_version_bump import version_key
from scripts.release_notes import fragments, read_fragment
from scripts.version import read_version

ROOT = Path(__file__).resolve().parent.parent


def prepare(root: Path, *, version: str | None = None, dry_run: bool = False) -> str:
paths = fragments(root)
if not paths:
raise ValueError("No pending change fragments")
notes = [read_fragment(path) for path in paths]
current = read_version(root / "pyproject.toml")
major, minor, patch = version_key(current)
minimum = (major, minor + 1, 0) if any(k != "fix" for k, _ in notes) else (major, minor, patch + 1)
target = version or ".".join(map(str, minimum))
if version_key(target) < minimum:
raise ValueError(f"Version {target} is below the required {'.'.join(map(str, minimum))}")
changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8")
if f"## [{target}]" in changelog:
raise ValueError(f"Changelog already contains {target}")
first_heading = re.search(r"^## \[", changelog, re.MULTILINE)
if first_heading is None:
raise ValueError("Cannot find the first version heading in CHANGELOG.md")
section = f"## [{target}] - {datetime.date.today().isoformat()}\n\n"
for kind, heading in (("breaking", "Changed"), ("feature", "Added"), ("fix", "Fixed")):
entries = [text for k, text in notes if k == kind]
if entries:
section += f"### {heading}\n\n"
section += "\n".join("- " + text.replace("\n", "\n ") for text in entries) + "\n\n"
project_path = root / "pyproject.toml"
project = project_path.read_text(encoding="utf-8")
# Limit replacement to [project], so another table's version stays untouched.
match = re.search(r"(?ms)^\[project\]\s*\n(.*?)(?=^\[|\Z)", project)
if match is None:
raise ValueError("Missing [project] table")
body, count = re.subn(r'^version\s*=\s*[\'\"][^\'\"]+[\'\"]\s*$', f'version = "{target}"', match.group(1), count=1, flags=re.MULTILINE)
if count != 1:
raise ValueError("Cannot find [project].version")
if dry_run:
return section
project_path.write_text(project[:match.start(1)] + body + project[match.end(1):], encoding="utf-8")
(root / "CHANGELOG.md").write_text(changelog[:first_heading.start()] + section + changelog[first_heading.start():], encoding="utf-8")
for path in paths:
path.unlink()
return section


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", help="Override the automatically selected version (may only increase it)")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
try:
print(prepare(ROOT, version=args.version, dry_run=args.dry_run))
except ValueError as error:
print(str(error), file=sys.stderr)
return 1
if not args.dry_run:
print("Run uv lock, then commit pyproject.toml, uv.lock, CHANGELOG.md and removed fragments in a release PR.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
41 changes: 41 additions & 0 deletions scripts/release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Independent change fragments and validation shared by CI and release tooling."""

from __future__ import annotations

import re
import tomllib
from pathlib import Path

from scripts.changelog_section import extract

FRAGMENT_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*\.(fix|feature|breaking)\.md$")


def fragments(root: Path) -> list[Path]:
return sorted((root / "changes").glob("*.md"))


def read_fragment(path: Path) -> tuple[str, str]:
match = FRAGMENT_NAME.fullmatch(path.name)
if match is None:
raise ValueError(f"Invalid change fragment: {path.name}; use <id>.fix|feature|breaking.md")
text = path.read_text(encoding="utf-8").strip()
if not text or any(line.startswith("#") for line in text.splitlines()):
raise ValueError(f"{path.name}: write a non-empty release-note paragraph without headings")
return match.group(1), text


def validate_lock(root: Path, version: str) -> None:
data = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8"))
own = [p for p in data["package"] if p["name"] == "apodex-agent-core"]
if len(own) != 1 or own[0]["version"] != version:
raise ValueError("uv.lock project version is stale; run uv lock")


def validate_release(root: Path, version: str) -> None:
pending = fragments(root)
if pending:
raise ValueError("Unreleased change fragments remain; run scripts/prepare_release.py")
validate_lock(root, version)
if not extract(version, (root / "CHANGELOG.md").read_text(encoding="utf-8")):
raise ValueError(f"CHANGELOG.md has no non-empty entry for {version}")
13 changes: 11 additions & 2 deletions scripts/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,21 @@ def main(argv: list[str] | None = None) -> int:
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`).",
"A release tag must name the version it publishes: bump [project].version "
"and use a new tag (never move a published tag).",
file=sys.stderr,
)
return 1

# A tag must never publish a tree with pending feature changes or a stale lock.
sys.path.insert(0, str(ROOT))
from scripts.release_notes import validate_release

try:
validate_release(ROOT, version)
except ValueError as error:
print(str(error), file=sys.stderr)
return 1
print(version)
return 0

Expand Down
23 changes: 0 additions & 23 deletions tests/test_release_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,29 +30,6 @@ def test_version_key_rejects_values_outside_the_version_scheme(value: str) -> No
check_version_bump.version_key(value)


@pytest.mark.parametrize(("previous", "current"), [("0.2.0", "0.2.0"), ("0.2.0", "0.1.9")])
def test_version_gate_rejects_equal_or_decreasing_versions(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
previous: str,
current: str,
) -> None:
monkeypatch.setattr(check_version_bump, "changed_files", lambda _base: ["agent_core/x.py"])
monkeypatch.setattr(check_version_bump, "base_version", lambda _base: previous)
monkeypatch.setattr(check_version_bump, "read_version", lambda: current)

assert check_version_bump.main(["--base", "base-sha"]) == 1
assert "does not increase" in capsys.readouterr().err


def test_version_gate_accepts_an_increase(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(check_version_bump, "changed_files", lambda _base: ["agent_core/x.py"])
monkeypatch.setattr(check_version_bump, "base_version", lambda _base: "0.2.0")
monkeypatch.setattr(check_version_bump, "read_version", lambda: "0.2.1")

assert check_version_bump.main(["--base", "base-sha"]) == 0


def test_version_label_changes_retrigger_ci() -> None:
workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")

Expand Down
Loading
Loading