From f8892c915ca87da3d872fc215ddbd4b83761ca4f Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Fri, 25 Sep 2026 14:16:09 +0800 Subject: [PATCH] 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