From b89219ea5694df5ec79a0c61069a1af633e9ac56 Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Fri, 25 Sep 2026 13:28:19 +0800 Subject: [PATCH 1/3] feat(api): export helpers that host facades re-export - loop_types.__all__ now lists wall_deadline_remaining_s, which the loop hooks of every host use but star-import facades could not see. - model_profile._to_openai_tool_calls becomes public to_openai_tool_calls; the private name stays as an alias. Co-Authored-By: Claude Opus 5.5 (1M context) --- agent_core/loop_types.py | 1 + agent_core/runtime/loop/model_profile.py | 10 +++++++--- tests/test_model_profile_native.py | 18 +++++++++--------- tests/test_public_host_helpers.py | 22 ++++++++++++++++++++++ 4 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 tests/test_public_host_helpers.py diff --git a/agent_core/loop_types.py b/agent_core/loop_types.py index 4920482..33a9abc 100644 --- a/agent_core/loop_types.py +++ b/agent_core/loop_types.py @@ -757,4 +757,5 @@ async def notify_tool_result( "notify_observers", "notify_tool_call", "notify_tool_result", + "wall_deadline_remaining_s", ] diff --git a/agent_core/runtime/loop/model_profile.py b/agent_core/runtime/loop/model_profile.py index 457a4af..583c73d 100644 --- a/agent_core/runtime/loop/model_profile.py +++ b/agent_core/runtime/loop/model_profile.py @@ -540,7 +540,7 @@ def _extract_reasoning(response: Any) -> str: return "" -def _to_openai_tool_calls(parsed: list[dict[str, Any]]) -> list[ToolCall]: +def to_openai_tool_calls(parsed: list[dict[str, Any]]) -> list[ToolCall]: """Echo parsed tool calls into OpenAI wire format. Key order ``{type, id, function: {name, arguments}}`` — served checkpoints @@ -583,6 +583,10 @@ def _arguments_json(raw: Any) -> str: return out + + +# Former private name; hosts that build assistant messages by hand imported it. +_to_openai_tool_calls = to_openai_tool_calls class NativeMessageNormalizer: """Convert an LLM response to the OpenAI-wire ``Message`` stored in history. @@ -607,7 +611,7 @@ def to_history( # (verbatim replay is transport correctness, not an agent choice). No # effect on other formats, where raw_content_blocks is None. if thinking_result.raw_content_blocks is not None: - tool_calls = _to_openai_tool_calls(thinking_result.tool_calls) + tool_calls = to_openai_tool_calls(thinking_result.tool_calls) return assistant_msg( thinking_result.raw_content_blocks, tool_calls=tool_calls, ) @@ -616,7 +620,7 @@ def to_history( # ``response.content`` when it contains inline tags makes the # builder below prepend the same reasoning a second time. visible = thinking_result.visible_content - tool_calls = _to_openai_tool_calls(thinking_result.tool_calls) + tool_calls = to_openai_tool_calls(thinking_result.tool_calls) reasoning = "" if policy.thinking_in_history: reasoning = ( diff --git a/tests/test_model_profile_native.py b/tests/test_model_profile_native.py index 0c138b9..2fb402d 100644 --- a/tests/test_model_profile_native.py +++ b/tests/test_model_profile_native.py @@ -19,7 +19,7 @@ NativeMessageNormalizer, ThinkingResult, _extract_reasoning, - _to_openai_tool_calls, + to_openai_tool_calls, ) @@ -114,11 +114,11 @@ def test_to_history_thinking_in_history_snapshots_full_content(): assert msg["content"] == "x\nanswer" -# ── _to_openai_tool_calls wire conversion ─────────────────────────────────── +# ── to_openai_tool_calls wire conversion ─────────────────────────────────── def test_to_openai_tool_calls_parsed_to_wire(): - out = _to_openai_tool_calls([{"name": "f", "args": {"x": 1}, "id": "c1"}]) + out = to_openai_tool_calls([{"name": "f", "args": {"x": 1}, "id": "c1"}]) assert out == [{"type": "function", "id": "c1", "function": {"name": "f", "arguments": '{"x": 1}'}}] @@ -126,11 +126,11 @@ def test_to_openai_tool_calls_parsed_to_wire(): def test_to_openai_tool_calls_passthrough_already_wire(): wire = [{"type": "function", "id": "c1", "function": {"name": "f", "arguments": "{}"}}] - assert _to_openai_tool_calls(wire) == wire + assert to_openai_tool_calls(wire) == wire def test_to_openai_tool_calls_string_args_kept_verbatim(): - out = _to_openai_tool_calls([{"name": "f", "args": '{"x":1}', "id": "c1"}]) + out = to_openai_tool_calls([{"name": "f", "args": '{"x":1}', "id": "c1"}]) assert out[0]["function"]["arguments"] == '{"x":1}' @@ -141,7 +141,7 @@ def test_to_openai_tool_calls_repairs_empty_wire_arguments(): "function": {"name": "bfunction", "arguments": ""}, }] - out = _to_openai_tool_calls(wire) + out = to_openai_tool_calls(wire) assert out[0]["function"]["arguments"] == "{}" @@ -149,7 +149,7 @@ def test_to_openai_tool_calls_repairs_empty_wire_arguments(): def test_to_openai_tool_calls_repairs_truncated_parsed_arguments(): parsed = [{"name": "bash", "args": '{"command":', "id": "c1"}] - out = _to_openai_tool_calls(parsed) + out = to_openai_tool_calls(parsed) assert out[0]["function"]["arguments"] == "{}" @@ -157,13 +157,13 @@ def test_to_openai_tool_calls_repairs_truncated_parsed_arguments(): def test_to_openai_tool_calls_rejects_non_object_arguments(): parsed = [{"name": "bash", "args": '["unexpected"]', "id": "c1"}] - out = _to_openai_tool_calls(parsed) + out = to_openai_tool_calls(parsed) assert out[0]["function"]["arguments"] == "{}" def test_to_openai_tool_calls_empty(): - assert _to_openai_tool_calls([]) == [] + assert to_openai_tool_calls([]) == [] # ── format-aware reasoning round-trip (replaces _ReasoningChatOpenAI) ──────── diff --git a/tests/test_public_host_helpers.py b/tests/test_public_host_helpers.py new file mode 100644 index 0000000..f2d4f01 --- /dev/null +++ b/tests/test_public_host_helpers.py @@ -0,0 +1,22 @@ +"""Helpers host facades re-export must be reachable through the public API. + +Hosts alias AgentCore modules with ``from agent_core.X import *``; a name left +out of ``__all__`` (or kept private) is invisible to static checkers there. +""" + +from __future__ import annotations + +import agent_core.loop_types as loop_types +import agent_core.runtime.loop.model_profile as model_profile + + +def test_wall_deadline_remaining_s_is_exported() -> None: + assert "wall_deadline_remaining_s" in loop_types.__all__ + + +def test_to_openai_tool_calls_is_public_and_keeps_its_old_name() -> None: + namespace: dict[str, object] = {} + exec("from agent_core.runtime.loop.model_profile import *", namespace) + + assert namespace["to_openai_tool_calls"] is model_profile.to_openai_tool_calls + assert model_profile._to_openai_tool_calls is model_profile.to_openai_tool_calls From 26c9882a66a66d6d5ca4ccb79a55951b333f658b Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Fri, 25 Sep 2026 14:04:30 +0800 Subject: [PATCH 2/3] chore: bump package version and document release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c16f8..e5d1f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ the GitHub Release body, so a release with no entry here fails. Versioning follows [docs/versioning.md](docs/versioning.md). +## [0.12.0] - 2026-09-25 + +### Added + +- `loop_types.wall_deadline_remaining_s` is now included in `__all__`. `model_profile.to_openai_tool_calls` is public for host facades; the former `_to_openai_tool_calls` name remains a compatibility alias. + ## [0.11.1] - 2026-09-24 ### Added diff --git a/pyproject.toml b/pyproject.toml index 5522835..447a00f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "apodex-agent-core" -version = "0.11.1" +version = "0.12.0" description = "Shared, product-neutral runtime primitives for Apodex agents" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index cf914dd..8d53616 100644 --- a/uv.lock +++ b/uv.lock @@ -50,7 +50,7 @@ wheels = [ [[package]] name = "apodex-agent-core" -version = "0.11.1" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "anthropic", extra = ["bedrock"] }, From f8892c915ca87da3d872fc215ddbd4b83761ca4f Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Fri, 25 Sep 2026 14:16:09 +0800 Subject: [PATCH 3/3] ci: collect independent change fragments and version releases centrally --- .github/workflows/ci.yml | 10 +- .github/workflows/release.yml | 2 +- changes/.gitkeep | 0 docs/versioning.md | 48 ++++++-- scripts/check_version_bump.py | 82 +++++++------- scripts/prepare_release.py | 77 +++++++++++++ scripts/release_notes.py | 41 +++++++ scripts/version.py | 13 ++- tests/test_release_automation.py | 23 ---- tests/test_release_fragments.py | 182 +++++++++++++++++++++++++++++++ 10 files changed, 390 insertions(+), 88 deletions(-) create mode 100644 changes/.gitkeep create mode 100644 scripts/prepare_release.py create mode 100644 scripts/release_notes.py create mode 100644 tests/test_release_fragments.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20d0c28..99149ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,14 +29,10 @@ jobs: - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aea034f..ca80d82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/changes/.gitkeep b/changes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/versioning.md b/docs/versioning.md index de4ea54..9de42f1 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -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 '## [] - ' 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 diff --git a/scripts/check_version_bump.py b/scripts/check_version_bump.py index b8a98f3..d392565 100644 --- a/scripts/check_version_bump.py +++ b/scripts/check_version_bump.py @@ -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 @@ -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. @@ -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/..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/.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()) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py new file mode 100644 index 0000000..18be1fb --- /dev/null +++ b/scripts/prepare_release.py @@ -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()) diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 0000000..9306a04 --- /dev/null +++ b/scripts/release_notes.py @@ -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 .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}") diff --git a/scripts/version.py b/scripts/version.py index 516f3a4..2c9cf97 100644 --- a/scripts/version.py +++ b/scripts/version.py @@ -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 diff --git a/tests/test_release_automation.py b/tests/test_release_automation.py index 52966f5..feee7fd 100644 --- a/tests/test_release_automation.py +++ b/tests/test_release_automation.py @@ -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") diff --git a/tests/test_release_fragments.py b/tests/test_release_fragments.py new file mode 100644 index 0000000..c32e721 --- /dev/null +++ b/tests/test_release_fragments.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from scripts import check_version_bump as gate +from scripts import version as version_script +from scripts.prepare_release import prepare +from scripts.release_notes import validate_release + + +def git(root: Path, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def write_lock(root: Path, version: str) -> None: + (root / "uv.lock").write_text(f'[[package]]\nname = "apodex-agent-core"\nversion = "{version}"\n') + + +@pytest.fixture +def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + (tmp_path / "pyproject.toml").write_text('[project]\nname = "apodex-agent-core"\nversion = "0.2.0"\n') + write_lock(tmp_path, "0.2.0") + (tmp_path / "CHANGELOG.md").write_text('# Changelog\n\n## [0.2.0] - 2026-01-01\n\nExisting notes.\n') + (tmp_path / "changes").mkdir() + (tmp_path / "changes/.gitkeep").touch() + (tmp_path / "agent_core").mkdir() + (tmp_path / "agent_core/x.py").write_text('x = 1\n') + git(tmp_path, "init", "-q") + git(tmp_path, "config", "user.name", "Test") + git(tmp_path, "config", "user.email", "test@example.com") + commit(tmp_path) + git(tmp_path, "branch", "base") + monkeypatch.setattr(gate, "ROOT", tmp_path) + monkeypatch.setattr(gate, "_git", lambda *args: git(tmp_path, *args)) + monkeypatch.setattr(gate, "read_version", lambda: version_script.read_version(tmp_path / "pyproject.toml")) + return tmp_path + + +def commit(root: Path) -> None: + git(root, "add", ".") + git(root, "commit", "-qm", "test") + + +def test_code_change_requires_new_fragment(repo: Path) -> None: + (repo / "agent_core/x.py").write_text('x = 2\n') + commit(repo) + assert gate.main(["--base", "base"]) == 1 + (repo / "changes/42.fix.md").write_text("Fix the result.") + commit(repo) + assert gate.main(["--base", "base"]) == 0 + + +def test_existing_fragment_cannot_cover_new_code(repo: Path) -> None: + (repo / "changes/old.fix.md").write_text("Earlier change.") + commit(repo) + git(repo, "branch", "-f", "base") + (repo / "agent_core/x.py").write_text('x = 2\n') + (repo / "changes/old.fix.md").write_text("Repurposed old change.") + commit(repo) + assert gate.main(["--base", "base"]) == 1 + + +@pytest.mark.parametrize("name,text", [("42.md", "A fix"), ("42.fix.md", " "), ("42.fix.md", "## Heading")]) +def test_malformed_fragment_rejected(repo: Path, name: str, text: str) -> None: + (repo / "changes" / name).write_text(text) + commit(repo) + assert gate.main(["--base", "base"]) == 1 + + +def test_docs_only_needs_no_fragment(repo: Path) -> None: + (repo / "README.md").write_text("Documentation") + commit(repo) + assert gate.main(["--base", "base"]) == 0 + + +@pytest.mark.parametrize(("kind", "expected"), [("fix", "0.2.1"), ("feature", "0.3.0"), ("breaking", "0.3.0")]) +def test_prepare_release_and_gate(repo: Path, kind: str, expected: str) -> None: + note = repo / f"changes/42.{kind}.md" + note.write_text("Consumer-facing change.") + commit(repo) + git(repo, "branch", "-f", "base") + original = (repo / "pyproject.toml").read_text() + preview = prepare(repo, dry_run=True) + assert expected in preview + assert note.exists() + assert (repo / "pyproject.toml").read_text() == original + prepare(repo) + assert not note.exists() + assert version_script.read_version(repo / "pyproject.toml") == expected + commit(repo) + assert gate.main(["--base", "base"]) == 1 # lock not regenerated yet + write_lock(repo, expected) + commit(repo) + assert gate.main(["--base", "base"]) == 0 + validate_release(repo, expected) + + +def test_tag_refuses_unreleased_tree(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(version_script, "ROOT", repo) + monkeypatch.setattr(version_script, "read_version", lambda: "0.2.0") + (repo / "changes/42.fix.md").write_text("Not released yet.") + assert version_script.main(["--check-tag", "v0.2.0"]) == 1 + + +def test_release_refuses_missing_notes_and_new_fragments(repo: Path) -> None: + with pytest.raises(ValueError, match="stale"): + validate_release(repo, "0.2.1") + write_lock(repo, "0.2.1") + with pytest.raises(ValueError, match="no non-empty"): + validate_release(repo, "0.2.1") + (repo / "changes/late.fix.md").write_text("Merged after release preparation.") + with pytest.raises(ValueError, match="Unreleased"): + validate_release(repo, "0.2.1") + + +def test_prepare_rejects_insufficient_version_without_mutation(repo: Path) -> None: + note = repo / "changes/43.feature.md" + note.write_text("New public API.") + with pytest.raises(ValueError, match="below"): + prepare(repo, version="0.2.1") + assert note.exists() + assert version_script.read_version(repo / "pyproject.toml") == "0.2.0" + + +def test_gate_rejects_downgrade(repo: Path) -> None: + p = repo / "pyproject.toml" + p.write_text(p.read_text().replace("0.2.0", "0.1.9")) + write_lock(repo, "0.1.9") + commit(repo) + assert gate.main(["--base", "base"]) == 1 + + +def test_two_feature_branches_merge_without_release_file_conflicts(repo: Path) -> None: + git(repo, "checkout", "-qb", "feature-a") + (repo / "changes/42.fix.md").write_text("Fix A.") + (repo / "agent_core/a.py").write_text('a = 1\n') + commit(repo) + git(repo, "checkout", "-qb", "feature-b", "base") + (repo / "changes/43.feature.md").write_text("Feature B.") + (repo / "agent_core/b.py").write_text('b = 1\n') + commit(repo) + git(repo, "merge", "--no-edit", "feature-a") + assert gate.main(["--base", "feature-a"]) == 0 + assert version_script.read_version(repo / "pyproject.toml") == "0.2.0" + notes = prepare(repo) + assert "Fix A." in notes and "Feature B." in notes + assert "0.3.0" in notes + + +@pytest.mark.parametrize("operation", ["delete", "edit", "rename"]) +def test_feature_pr_cannot_remove_or_repurpose_pending_notes(repo: Path, operation: str) -> None: + note = repo / "changes/old.fix.md" + note.write_text("Earlier consumer-facing change.") + commit(repo) + git(repo, "branch", "-f", "base") + if operation == "delete": + note.unlink() + elif operation == "edit": + note.write_text("Different note.") + else: + note.rename(repo / "changes/new.fix.md") + commit(repo) + assert gate.main(["--base", "base"]) == 1 + + + +def test_manual_release_cannot_hide_feature_in_patch(repo: Path) -> None: + note = repo / "changes/43.feature.md" + note.write_text("New API.") + commit(repo) + git(repo, "branch", "-f", "base") + note.unlink() + p = repo / "pyproject.toml" + p.write_text(p.read_text().replace("0.2.0", "0.2.1")) + write_lock(repo, "0.2.1") + p = repo / "CHANGELOG.md" + p.write_text(p.read_text().replace("## [0.2.0]", "## [0.2.1]")) + commit(repo) + assert gate.main(["--base", "base"]) == 1