From 9a7ecacff06561377f792ed32f59f4e26db2fd7d Mon Sep 17 00:00:00 2001 From: cafitac Date: Thu, 30 Apr 2026 06:12:38 +0900 Subject: [PATCH] ci: auto release on main merges --- .github/workflows/auto-release.yml | 82 ++++++++++++++++++++ scripts/bump_release_version.py | 118 +++++++++++++++++++++++++++++ tests/test_bump_release_version.py | 51 +++++++++++++ tests/test_release_metadata.py | 8 +- tests/test_release_workflows.py | 28 +++++++ 5 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/auto-release.yml create mode 100644 scripts/bump_release_version.py create mode 100644 tests/test_bump_release_version.py create mode 100644 tests/test_release_workflows.py diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..ce2c578 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,82 @@ +name: auto-release + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: auto-release-main + cancel-in-progress: false + +jobs: + bump-tag-and-publish: + if: >- + github.ref == 'refs/heads/main' && + (github.event_name == 'workflow_dispatch' || + !contains(github.event.head_commit.message, '[skip release]')) + runs-on: ubuntu-latest + steps: + - name: Checkout main with full history + uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Set up uv + uses: astral-sh/setup-uv@v8.1.0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Configure release bot identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Bump release metadata + id: bump + run: | + set -euo pipefail + NEXT_VERSION=$(uv run python scripts/bump_release_version.py --patch) + uv lock + TAG="v${NEXT_VERSION}" + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists" >&2 + exit 1 + fi + echo "version=${NEXT_VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Validate bumped release + run: | + uv run python scripts/check_release_metadata.py + uv run pytest tests/ -q + uv run python scripts/smoke_release_readiness.py + uvx --from build python -m build + npm pack --dry-run + + - name: Commit bumped release metadata + run: | + set -euo pipefail + git add pyproject.toml package.json src/agent_memory/__init__.py uv.lock + git commit -m "chore: release v${{ steps.bump.outputs.version }} [skip release]" + git tag -a "${{ steps.bump.outputs.tag }}" -m "Release ${{ steps.bump.outputs.tag }}" + + - name: Push release commit and tag + run: | + set -euo pipefail + git push origin HEAD:main + TAG="${{ steps.bump.outputs.tag }}" + git push origin "$TAG" diff --git a/scripts/bump_release_version.py b/scripts/bump_release_version.py new file mode 100644 index 0000000..b82c8b3 --- /dev/null +++ b/scripts/bump_release_version.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +from agent_memory.release_metadata import PROJECT_ROOT, load_release_metadata + +SEMVER_RE = re.compile(r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)$") + + +def bump_patch_version(version: str) -> str: + match = SEMVER_RE.match(version) + if match is None: + raise ValueError(f"Expected a simple MAJOR.MINOR.PATCH version, got {version!r}") + major = int(match.group("major")) + minor = int(match.group("minor")) + patch = int(match.group("patch")) + return f"{major}.{minor}.{patch + 1}" + + +def replace_once(text: str, old: str, new: str, path: Path) -> str: + count = text.count(old) + if count != 1: + raise ValueError(f"Expected exactly one occurrence of {old!r} in {path}, found {count}") + return text.replace(old, new, 1) + + +def update_pyproject_version(path: Path, version: str) -> None: + text = path.read_text() + updated = re.sub( + r'(?m)^version = "[^"]+"$', + f'version = "{version}"', + text, + count=1, + ) + if updated == text: + raise ValueError(f"Could not update project.version in {path}") + path.write_text(updated) + + +def update_package_json_version(path: Path, version: str) -> None: + payload = json.loads(path.read_text()) + payload["version"] = version + path.write_text(json.dumps(payload, indent=2) + "\n") + + +def update_module_version(path: Path, version: str) -> None: + text = path.read_text() + updated = re.sub( + r'(?m)^__version__ = "[^"]+"$', + f'__version__ = "{version}"', + text, + count=1, + ) + if updated == text: + raise ValueError(f"Could not update __version__ in {path}") + path.write_text(updated) + + +def update_uv_lock_version(path: Path, version: str) -> None: + if not path.exists(): + return + text = path.read_text() + pattern = re.compile( + r'(\[\[package\]\]\nname = "cafitac-agent-memory"\nversion = ")[^"]+("\n)', + re.MULTILINE, + ) + updated, count = pattern.subn(rf"\g<1>{version}\2", text, count=1) + if count != 1: + raise ValueError(f"Could not update cafitac-agent-memory package version in {path}") + path.write_text(updated) + + +def update_release_version_files(project_root: Path, version: str) -> None: + update_pyproject_version(project_root / "pyproject.toml", version) + update_package_json_version(project_root / "package.json", version) + update_module_version(project_root / "src" / "agent_memory" / "__init__.py", version) + update_uv_lock_version(project_root / "uv.lock", version) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Bump synchronized release metadata.") + parser.add_argument( + "--patch", + action="store_true", + help="Increment the current synchronized version's patch component.", + ) + parser.add_argument( + "--set-version", + help="Set an explicit MAJOR.MINOR.PATCH version instead of incrementing patch.", + ) + parser.add_argument( + "--project-root", + type=Path, + default=PROJECT_ROOT, + help="Repository root containing pyproject.toml and package.json.", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.patch == bool(args.set_version): + raise SystemExit("Pass exactly one of --patch or --set-version") + + metadata = load_release_metadata(args.project_root) + next_version = args.set_version or bump_patch_version(metadata.python_package_version) + if SEMVER_RE.match(next_version) is None: + raise SystemExit(f"Invalid release version: {next_version!r}") + + update_release_version_files(args.project_root, next_version) + print(next_version) + + +if __name__ == "__main__": + main() diff --git a/tests/test_bump_release_version.py b/tests/test_bump_release_version.py new file mode 100644 index 0000000..1c85de7 --- /dev/null +++ b/tests/test_bump_release_version.py @@ -0,0 +1,51 @@ +import importlib.util +from pathlib import Path +from types import ModuleType + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = PROJECT_ROOT / "scripts" / "bump_release_version.py" + + +def load_bump_release_version_module() -> ModuleType: + assert SCRIPT_PATH.exists(), "scripts/bump_release_version.py must exist" + spec = importlib.util.spec_from_file_location("bump_release_version", SCRIPT_PATH) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_bump_patch_version_increments_patch_component() -> None: + module = load_bump_release_version_module() + + assert module.bump_patch_version("0.1.9") == "0.1.10" + + +def test_update_release_version_files_syncs_all_release_metadata(tmp_path: Path) -> None: + module = load_bump_release_version_module() + project_root = tmp_path + (project_root / "src" / "agent_memory").mkdir(parents=True) + (project_root / "tests").mkdir() + (project_root / "pyproject.toml").write_text( + '[project]\nname = "cafitac-agent-memory"\nversion = "0.1.9"\n', + ) + (project_root / "package.json").write_text( + '{\n "name": "@cafitac/agent-memory",\n "version": "0.1.9"\n}\n', + ) + (project_root / "src" / "agent_memory" / "__init__.py").write_text( + '__version__ = "0.1.9"\n', + ) + (project_root / "uv.lock").write_text( + '[[package]]\nname = "cafitac-agent-memory"\nversion = "0.1.9"\n', + ) + + module.update_release_version_files(project_root, "0.1.10") + + assert 'version = "0.1.10"' in (project_root / "pyproject.toml").read_text() + assert '"version": "0.1.10"' in (project_root / "package.json").read_text() + assert '__version__ = "0.1.10"' in ( + project_root / "src" / "agent_memory" / "__init__.py" + ).read_text() + assert 'version = "0.1.10"' in (project_root / "uv.lock").read_text() diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index c54bb5a..34f0493 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -11,7 +11,7 @@ def test_release_metadata_versions_and_names_are_synced() -> None: assert metadata.python_package_name == "cafitac-agent-memory" assert metadata.npm_package_name == "@cafitac/agent-memory" - assert metadata.python_package_version == "0.1.9" + assert metadata.python_package_version.count(".") == 2 assert metadata.npm_package_version == metadata.python_package_version assert metadata.module_version == metadata.python_package_version assert metadata.npm_repository_url == "https://github.com/cafitac/agent-memory" @@ -23,8 +23,8 @@ def test_release_metadata_loader_reads_expected_fields() -> None: assert metadata.python_package_name assert metadata.npm_package_name - assert metadata.python_package_version == "0.1.9" - assert metadata.npm_package_version == "0.1.9" - assert metadata.module_version == "0.1.9" + assert metadata.python_package_version.count(".") == 2 + assert metadata.npm_package_version == metadata.python_package_version + assert metadata.module_version == metadata.python_package_version assert metadata.npm_repository_url == "https://github.com/cafitac/agent-memory" diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py new file mode 100644 index 0000000..d0157e5 --- /dev/null +++ b/tests/test_release_workflows.py @@ -0,0 +1,28 @@ +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_auto_release_workflow_bumps_versions_on_main_merges() -> None: + workflow_path = PROJECT_ROOT / ".github" / "workflows" / "auto-release.yml" + + workflow = workflow_path.read_text() + + assert "branches:" in workflow + assert "- main" in workflow + assert "contents: write" in workflow + assert "[skip release]" in workflow + assert "scripts/bump_release_version.py --patch" in workflow + assert "git push origin HEAD:main" in workflow + assert "git push origin \"$TAG\"" in workflow + + +def test_publish_workflow_remains_tag_driven_only() -> None: + workflow = (PROJECT_ROOT / ".github" / "workflows" / "publish.yml").read_text() + + assert "tags:" in workflow + assert "- 'v*'" in workflow + assert "branches:" not in workflow + assert "npm publish --access public --provenance" in workflow + assert "softprops/action-gh-release" in workflow