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
82 changes: 82 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -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"
118 changes: 118 additions & 0 deletions scripts/bump_release_version.py
Original file line number Diff line number Diff line change
@@ -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"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[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()
51 changes: 51 additions & 0 deletions tests/test_bump_release_version.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 4 additions & 4 deletions tests/test_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

28 changes: 28 additions & 0 deletions tests/test_release_workflows.py
Original file line number Diff line number Diff line change
@@ -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