diff --git a/src/aipass/drone/README.md b/src/aipass/drone/README.md index e2a72f42..e4ed25bb 100644 --- a/src/aipass/drone/README.md +++ b/src/aipass/drone/README.md @@ -40,6 +40,7 @@ drone @git diff --staged # Show staged changes drone @git log # Show recent git log (default: 10) drone @git log 20 # Show last 20 commits drone @git lock # Check lock status +drone @git tag --list # List all tags (newest first) drone @git issue list # Passthrough to gh issue list drone @git issue view 42 # Passthrough to gh issue view 42 drone @git run list # Passthrough to gh run list @@ -62,6 +63,7 @@ drone @git sync --autostash # Sync with autostash for dirty trees drone @git smart-sync # Fetch + detect divergence + rebase drone @git unlock --force # Force-release the PR lock drone @git system-pr "desc" # DEPRECATED — returns error message +drone @git tag v2.6.1 # Create + push annotated release tag drone @git fix # Auto-fix stuck rebase / detached HEAD drone @git fix --dry-run # Detect issues without fixing @@ -181,7 +183,8 @@ drone/ │ │ ├── delete_branch_handler.py # Delete remote branch (main/dev protected) │ │ ├── close_pr_handler.py # Close PR by number (gh pr close) │ │ ├── status_handler.py # Scoped git status (subprocess) -│ │ └── sync_handler.py # Safe main sync (--autostash support) +│ │ ├── sync_handler.py # Safe main sync (--autostash support) +│ │ └── tag_handler.py # Release tagging (version + exists guards) │ └── plugins/ │ ├── devpulse_ops/ # Privileged git operations (auth-gated) │ │ ├── auth.py # Passport-based identity gate (ALLOWED_CALLERS) @@ -194,7 +197,7 @@ drone/ ├── docs/ # Public documentation ├── docs.local/ # Investigation reports and policies ├── artifacts/ # Live acceptance test scripts -└── tests/ # 807 tests across 22 test files +└── tests/ # 859 tests across 23 test files ``` ### Routing Flow @@ -223,8 +226,8 @@ Auth centralized via `verify_git_access()` in `apps/plugins/devpulse_ops/auth.py | Tier | Who | Commands | |------|-----|----------| -| **Global** | All branches | `status`, `diff`, `log`, `lock`, `branches`, `issue`, `run`, `workflow` | -| **Owner** | `devpulse` only | `pr`, `commit`, `checkout`, `dev-pr`, `delete-branch`, `close-pr`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` | +| **Global** | All branches | `status`, `diff`, `log`, `lock`, `branches`, `tag --list`, `issue`, `run`, `workflow` | +| **Owner** | `devpulse` only | `pr`, `commit`, `checkout`, `dev-pr`, `delete-branch`, `close-pr`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix`, `tag` | - Auth is checked once at the top of `git_module.handle_command()` before any handler is called - Unauthorized commands return a clear "Access denied" message with the caller's tier @@ -337,7 +340,7 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches | Area | Files | Tests | |------|-------|-------| | Core routing | `test_resolver.py`, `test_router.py`, `test_activation.py` | ~128 | -| Git operations | `test_git_module.py`, `test_system_pr.py`, `test_devpulse_plugins.py`, `test_git_access.py` | ~150 | +| Git operations | `test_git_module.py`, `test_system_pr.py`, `test_devpulse_plugins.py`, `test_git_access.py`, `test_tag_handler.py` | ~170 | | Handlers | `test_executor.py`, `test_registry_handler.py`, `test_discovery.py` | ~99 | | Infrastructure | `test_generic_adapter.py`, `test_module_registry.py`, `test_config.py` | ~66 | | Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py`, `test_rm.py` | ~181 | @@ -356,7 +359,7 @@ Run tests: `cd src/aipass/drone && python -m pytest tests/ -q` --- -**Seedgo:** 100% | **Tests:** 830 pass, 4 skip | **Last Updated:** 2026-06-10 +**Seedgo:** 99% | **Tests:** 859 pass, 4 skip | **Last Updated:** 2026-07-02 --- [← Back to AIPass](../../../README.md) diff --git a/src/aipass/drone/apps/handlers/git/__init__.py b/src/aipass/drone/apps/handlers/git/__init__.py index 44140901..e355a8e7 100644 --- a/src/aipass/drone/apps/handlers/git/__init__.py +++ b/src/aipass/drone/apps/handlers/git/__init__.py @@ -12,3 +12,4 @@ from . import dev_pr_handler as dev_pr_handler from . import branches_handler as branches_handler from . import delete_branch_handler as delete_branch_handler from . import close_pr_handler as close_pr_handler +from . import tag_handler as tag_handler diff --git a/src/aipass/drone/apps/handlers/git/tag_handler.py b/src/aipass/drone/apps/handlers/git/tag_handler.py new file mode 100644 index 00000000..05f0adaf --- /dev/null +++ b/src/aipass/drone/apps/handlers/git/tag_handler.py @@ -0,0 +1,198 @@ +# =================== AIPass ==================== +# Name: tag_handler.py +# Description: Tag handler — create, push, and list release tags +# Version: 1.0.0 +# Created: 2026-07-02 +# Modified: 2026-07-02 +# ============================================= + +"""Tag handler — create, push, and list release tags.""" + +from __future__ import annotations + +import re +import subprocess + +from aipass.prax import logger +from aipass.drone.apps.handlers.json import json_handler +from aipass.drone.apps.handlers.git.lock_handler import find_repo_root + +_VERSION_RE = re.compile(r"^v\d+\.\d+\.\d+$") +_PYPROJECT_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE) +_INIT_VERSION_RE = re.compile(r'^__version__\s*=\s*"([^"]+)"', re.MULTILINE) + + +def tag_release(version: str) -> dict: + """Create and push an annotated release tag.""" + if not _VERSION_RE.match(version): + return {"success": False, "message": f"Invalid version format '{version}'. Expected vX.Y.Z (e.g. v2.6.1)."} + + bare_version = version[1:] # strip leading 'v' + repo_root = find_repo_root() + + # Fetch latest remote state + try: + result = subprocess.run( + ["git", "fetch", "origin"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git fetch origin failed: %s", exc) + return {"success": False, "message": f"Fetch failed: {exc}"} + + if result.returncode != 0: + return {"success": False, "message": f"Fetch failed: {result.stderr.strip()}"} + + # VERSION GUARD — pyproject.toml + try: + result = subprocess.run( + ["git", "show", "origin/main:pyproject.toml"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git show origin/main:pyproject.toml failed: %s", exc) + return {"success": False, "message": f"Failed to read pyproject.toml from origin/main: {exc}"} + + if result.returncode != 0: + return {"success": False, "message": f"Failed to read pyproject.toml from origin/main: {result.stderr.strip()}"} + + pyproject_match = _PYPROJECT_VERSION_RE.search(result.stdout) + pyproject_version = pyproject_match.group(1) if pyproject_match else None + + # VERSION GUARD — __init__.py + try: + result = subprocess.run( + ["git", "show", "origin/main:src/aipass/__init__.py"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git show origin/main:src/aipass/__init__.py failed: %s", exc) + return {"success": False, "message": f"Failed to read __init__.py from origin/main: {exc}"} + + if result.returncode != 0: + return {"success": False, "message": f"Failed to read __init__.py from origin/main: {result.stderr.strip()}"} + + init_match = _INIT_VERSION_RE.search(result.stdout) + init_version = init_match.group(1) if init_match else None + + if pyproject_version != bare_version or init_version != bare_version: + return { + "success": False, + "message": ( + f"Version mismatch — tag: {bare_version}, " + f"pyproject.toml: {pyproject_version}, " + f"__init__.py: {init_version}. " + "All three must agree before tagging." + ), + } + + # EXISTS GUARD — local + try: + result = subprocess.run( + ["git", "tag", "-l", version], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git tag -l %s failed: %s", version, exc) + return {"success": False, "message": f"Tag check failed: {exc}"} + + if result.stdout.strip(): + return {"success": False, "message": f"Tag '{version}' already exists locally."} + + # EXISTS GUARD — remote + try: + result = subprocess.run( + ["git", "ls-remote", "--tags", "origin", f"refs/tags/{version}"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git ls-remote --tags origin %s failed: %s", version, exc) + return {"success": False, "message": f"Remote tag check failed: {exc}"} + + if result.stdout.strip(): + return {"success": False, "message": f"Tag '{version}' already exists on remote."} + + # Resolve origin/main SHA + try: + result = subprocess.run( + ["git", "rev-parse", "origin/main"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git rev-parse origin/main failed: %s", exc) + return {"success": False, "message": f"Failed to resolve origin/main: {exc}"} + + if result.returncode != 0: + return {"success": False, "message": f"Failed to resolve origin/main: {result.stderr.strip()}"} + + sha = result.stdout.strip() + + # Create annotated tag + try: + result = subprocess.run( + ["git", "tag", "-a", version, "origin/main", "-m", f"Release {version}"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git tag -a %s failed: %s", version, exc) + return {"success": False, "message": f"Tag creation failed: {exc}"} + + if result.returncode != 0: + return {"success": False, "message": f"Tag creation failed: {result.stderr.strip()}"} + + # Push tag + try: + result = subprocess.run( + ["git", "push", "origin", version], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git push origin %s failed: %s", version, exc) + return {"success": False, "message": f"Tag push failed: {exc}"} + + if result.returncode != 0: + return {"success": False, "message": f"Tag push failed: {result.stderr.strip()}"} + + json_handler.log_operation("tag_release", {"version": version, "sha": sha}) + logger.info("Tagged %s at %s", version, sha) + + return {"success": True, "message": f"Tagged {version} on origin/main ({sha})."} + + +def list_tags() -> dict: + """List all tags sorted newest-first.""" + repo_root = find_repo_root() + + try: + result = subprocess.run( + ["git", "tag", "-l", "--sort=-v:refname"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git tag -l failed: %s", exc) + return {"success": False, "tags": [], "message": f"Failed to list tags: {exc}"} + + if result.returncode != 0: + return {"success": False, "tags": [], "message": f"Failed to list tags: {result.stderr.strip()}"} + + tags = [t for t in result.stdout.strip().splitlines() if t] + + return {"success": True, "tags": tags, "message": f"{len(tags)} tag(s) found."} diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index eefbc999..52b09922 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -33,6 +33,7 @@ from aipass.drone.apps.handlers.git import ( branches_handler, delete_branch_handler, close_pr_handler, + tag_handler, ) DRONE_MODULE = { @@ -62,6 +63,8 @@ _COMMANDS = ( "fix", "pr", "prune-temp", + "tag", + "tag-list", ) _GH_PASSTHROUGH_COMMANDS = ("issue", "run", "workflow") @@ -124,6 +127,8 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> return {"stdout": "", "stderr": "", "exit_code": 0} cmd: str = command + if cmd == "tag" and (not args or args[0] == "--list"): + cmd = "tag-list" try: from aipass.drone.apps.plugins.devpulse_ops.auth import verify_git_access @@ -170,6 +175,8 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> return _handle_pr(args) if command == "prune-temp": return _handle_prune_temp() + if command == "tag": + return _handle_tag(args) available = ", ".join(_COMMANDS) return { @@ -179,6 +186,20 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> } +def _handle_tag(args: list[str]) -> dict: + """Handle the tag subcommand — create/push release tags or list them.""" + if not args or args[0] == "--list": + result = tag_handler.list_tags() + if not result["tags"]: + return {"stdout": result["message"], "stderr": "", "exit_code": 0} + return {"stdout": "\n".join(result["tags"]), "stderr": "", "exit_code": 0} + + result = tag_handler.tag_release(args[0]) + if result["success"]: + return {"stdout": result["message"], "stderr": "", "exit_code": 0} + return {"stdout": "", "stderr": result["message"], "exit_code": 1} + + def _handle_gh_passthrough(subcommand: str, args: list[str]) -> dict: """Pass through to gh CLI for issue, run, and workflow subcommands.""" cmd = ["gh", subcommand] + args @@ -634,6 +655,16 @@ def get_help(command: str | None = None) -> str: "Options:\n" " --dry-run Report without executing fixes.\n" ) + if command == "tag": + return ( + "git tag — Create and push an annotated release tag [owner]\n" + "git tag --list — List all tags (newest first) [global]\n" + "\n" + "Safety guards:\n" + " Version guard Tags on origin/main only after verifying pyproject.toml\n" + " and __init__.py both match the tag version.\n" + " Exists guard Refuses if tag already exists locally or on remote.\n" + ) return ( "git — Tier-based git workflow (dev branch model)\n" @@ -644,6 +675,7 @@ def get_help(command: str | None = None) -> str: " lock Check lock status\n" " branches List remote branches\n" " prune-temp Delete merged citizen/* temp branches\n" + " tag --list List all tags (newest first)\n" " issue [args] Passthrough to gh issue\n" " run [args] Passthrough to gh run\n" " workflow [args] Passthrough to gh workflow\n" @@ -658,6 +690,7 @@ def get_help(command: str | None = None) -> str: " sync [--autostash] Sync with origin/main (FF on dev)\n" " smart-sync Fetch + rebase if behind\n" " unlock --force Force-release the PR lock\n" + " tag Create and push release tag\n" " fix [--dry-run] Fix broken git states\n" ) @@ -674,8 +707,8 @@ def get_introspective() -> str: " plugins/devpulse_ops/\n" " - auth.py, merge_plugin.py, sync_plugin.py, fix_plugin.py\n" " gh passthrough: issue, run, workflow\n" - "Tiers: global (status,diff,log,lock,branches,prune-temp,issue,run,workflow)" - " | owner (pr,commit,checkout,dev-pr,delete-branch,close-pr,sync,unlock,merge,smart-sync,fix)\n" + "Tiers: global (status,diff,log,lock,branches,prune-temp,tag --list,issue,run,workflow)" + " | owner (pr,commit,checkout,dev-pr,delete-branch,close-pr,sync,unlock,merge,smart-sync,fix,tag)\n" ) @@ -705,7 +738,8 @@ def print_introspection() -> None: c.print(" - [cyan]commit_handler.py[/cyan], [cyan]checkout_handler.py[/cyan], [cyan]sync_handler.py[/cyan]") c.print( " - [cyan]dev_pr_handler.py[/cyan], [cyan]branches_handler.py[/cyan]," - " [cyan]delete_branch_handler.py[/cyan], [cyan]close_pr_handler.py[/cyan]" + " [cyan]delete_branch_handler.py[/cyan], [cyan]close_pr_handler.py[/cyan]," + " [cyan]tag_handler.py[/cyan]" ) c.print(" [cyan]plugins/devpulse_ops/[/cyan]") c.print( @@ -715,9 +749,9 @@ def print_introspection() -> None: c.print(" [dim]gh passthrough: issue, run, workflow[/dim]") c.print( "[yellow]Tiers:[/yellow] [dim]global[/dim]" - " [dim](status,diff,log,lock,branches,prune-temp,issue,run,workflow)[/dim]" + " [dim](status,diff,log,lock,branches,prune-temp,tag --list,issue,run,workflow)[/dim]" " | [dim]owner[/dim]" - " [dim](pr,commit,checkout,dev-pr,delete-branch,close-pr,sync,unlock,merge,smart-sync,fix)[/dim]" + " [dim](pr,commit,checkout,dev-pr,delete-branch,close-pr,sync,unlock,merge,smart-sync,fix,tag)[/dim]" ) c.print() diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py index d04af851..b4f37bec 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py @@ -26,7 +26,7 @@ ALLOWED_CALLERS: list[str] = list(TRUSTED_CROSS_WRITERS) GIT_ACCESS_TIERS: dict[str, dict] = { "global": { - "commands": ["status", "diff", "log", "lock", "issue", "run", "workflow", "branches"], + "commands": ["status", "diff", "log", "lock", "issue", "run", "workflow", "branches", "tag-list"], "description": "Read-only — available to all branches", }, "owner": { @@ -42,6 +42,7 @@ GIT_ACCESS_TIERS: dict[str, dict] = { "pr", "close-pr", "delete-branch", + "tag", ], "allowed_callers": ["devpulse"], "description": "Write operations — project owner only", diff --git a/src/aipass/drone/tests/test_tag_handler.py b/src/aipass/drone/tests/test_tag_handler.py new file mode 100644 index 00000000..f02b3b3a --- /dev/null +++ b/src/aipass/drone/tests/test_tag_handler.py @@ -0,0 +1,296 @@ +# =================== AIPass ==================== +# Name: test_tag_handler.py +# Description: Tests for the tag handler — release tagging with safety guards +# Version: 1.0.0 +# Created: 2026-07-02 +# Modified: 2026-07-02 +# ============================================= + +"""Tests for the tag handler — release tagging with safety guards.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from aipass.drone.apps.modules.git_module import get_help, handle_command + +_TAG_PATCH = "aipass.drone.apps.handlers.git.tag_handler.subprocess.run" + +PYPROJECT_CONTENT = '[project]\nname = "aipass"\nversion = "2.6.1"\n' +INIT_CONTENT = '__version__ = "2.6.1"\n' + +_MOCK_RESPONSES: dict[tuple[str, ...], str] = { + ("git", "fetch", "origin"): "", + ("git", "show", "origin/main:pyproject.toml"): PYPROJECT_CONTENT, + ("git", "show", "origin/main:src/aipass/__init__.py"): INIT_CONTENT, + ("git", "rev-parse", "origin/main"): "abc123def456789", +} + + +def _make_mock(stdout: str = "", returncode: int = 0, stderr: str = "") -> MagicMock: + """Build a subprocess result mock.""" + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + m.stderr = stderr + return m + + +def _mock_run_success(*args, **kwargs): + """Route subprocess calls to preset responses for a clean happy path.""" + cmd = tuple(args[0]) + for prefix, stdout in _MOCK_RESPONSES.items(): + if cmd[: len(prefix)] == prefix: + return _make_mock(stdout=stdout) + return _make_mock() + + +def _mock_run_with_overrides(overrides: dict[tuple[str, ...], MagicMock]): + """Return a side_effect that applies overrides on top of the happy-path defaults.""" + + def _side_effect(*args, **kwargs): + """Match command prefixes against overrides, fall back to happy-path.""" + cmd = tuple(args[0]) + for prefix, mock in overrides.items(): + if cmd[: len(prefix)] == prefix: + return mock + return _mock_run_success(*args, **kwargs) + + return _side_effect + + +@pytest.fixture() +def repo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Set up a temporary directory with AIPASS_REGISTRY.json.""" + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text("{}", encoding="utf-8") + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture() +def devpulse_dir(repo_dir: Path) -> Path: + """Set up a repo_dir with a devpulse passport.""" + trinity = repo_dir / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text('{"branch_info": {"branch_name": "devpulse"}}', encoding="utf-8") + return repo_dir + + +@pytest.fixture() +def drone_dir(repo_dir: Path) -> Path: + """Set up a repo_dir with a drone passport (non-owner).""" + trinity = repo_dir / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text('{"branch_info": {"branch_name": "drone"}}', encoding="utf-8") + return repo_dir + + +# =========================================================================== +# 1. tag — format validation (owner tier, routed through handle_command) +# =========================================================================== + + +class TestTagFormatValidation: + """Version format validation tests.""" + + def test_rejects_no_v_prefix(self, devpulse_dir: Path) -> None: + """Version without v prefix is rejected.""" + result = handle_command("tag", ["2.6.1"]) + assert result["exit_code"] == 1 + assert "vX.Y.Z" in result["stderr"] + + def test_rejects_invalid_format(self, devpulse_dir: Path) -> None: + """Two-part version is rejected.""" + result = handle_command("tag", ["v1.2"]) + assert result["exit_code"] == 1 + assert "Invalid" in result["stderr"] + + def test_rejects_alpha(self, devpulse_dir: Path) -> None: + """Non-numeric version is rejected.""" + result = handle_command("tag", ["vfoo.bar.baz"]) + assert result["exit_code"] == 1 + + def test_accepts_valid_format(self, devpulse_dir: Path) -> None: + """Valid vX.Y.Z format succeeds end-to-end.""" + with patch(_TAG_PATCH, side_effect=_mock_run_success): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 0 + + +# =========================================================================== +# 2. tag — version guard +# =========================================================================== + + +class TestTagVersionGuard: + """Version mismatch guard tests.""" + + def test_pyproject_mismatch_refuses(self, devpulse_dir: Path) -> None: + """Mismatched pyproject.toml version is refused.""" + overrides = { + ("git", "show", "origin/main:pyproject.toml"): _make_mock(stdout='version = "9.9.9"\n'), + } + with patch(_TAG_PATCH, side_effect=_mock_run_with_overrides(overrides)): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 1 + assert "mismatch" in result["stderr"].lower() + assert "9.9.9" in result["stderr"] + + def test_init_mismatch_refuses(self, devpulse_dir: Path) -> None: + """Mismatched __init__.py version is refused.""" + overrides = { + ("git", "show", "origin/main:src/aipass/__init__.py"): _make_mock(stdout='__version__ = "0.0.1"\n'), + } + with patch(_TAG_PATCH, side_effect=_mock_run_with_overrides(overrides)): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 1 + assert "mismatch" in result["stderr"].lower() + assert "0.0.1" in result["stderr"] + + def test_pyproject_unreadable_refuses(self, devpulse_dir: Path) -> None: + """Unreadable pyproject.toml is refused.""" + overrides = { + ("git", "show", "origin/main:pyproject.toml"): _make_mock(returncode=128, stderr="fatal: path not found"), + } + with patch(_TAG_PATCH, side_effect=_mock_run_with_overrides(overrides)): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 1 + assert "pyproject" in result["stderr"].lower() + + def test_both_match_passes(self, devpulse_dir: Path) -> None: + """Matching versions pass the guard.""" + with patch(_TAG_PATCH, side_effect=_mock_run_success): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 0 + + +# =========================================================================== +# 3. tag — exists guard +# =========================================================================== + + +class TestTagExistsGuard: + """Tag existence guard tests.""" + + def test_local_tag_exists_refuses(self, devpulse_dir: Path) -> None: + """Existing local tag is refused.""" + overrides = { + ("git", "tag", "-l"): _make_mock(stdout="v2.6.1\n"), + } + with patch(_TAG_PATCH, side_effect=_mock_run_with_overrides(overrides)): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 1 + assert "already exists locally" in result["stderr"] + + def test_remote_tag_exists_refuses(self, devpulse_dir: Path) -> None: + """Existing remote tag is refused.""" + overrides = { + ("git", "ls-remote", "--tags", "origin"): _make_mock(stdout="abc123\trefs/tags/v2.6.1\n"), + } + with patch(_TAG_PATCH, side_effect=_mock_run_with_overrides(overrides)): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 1 + assert "already exists on remote" in result["stderr"] + + +# =========================================================================== +# 4. tag — happy path and failures +# =========================================================================== + + +class TestTagHappyPath: + """End-to-end tagging tests.""" + + def test_creates_and_pushes(self, devpulse_dir: Path) -> None: + """Full happy path creates and pushes a tag.""" + with patch(_TAG_PATCH, side_effect=_mock_run_success): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 0 + assert "v2.6.1" in result["stdout"] + assert "abc123" in result["stdout"] + + def test_fetch_failure(self, devpulse_dir: Path) -> None: + """Fetch failure returns an error.""" + with patch(_TAG_PATCH, return_value=_make_mock(returncode=1, stderr="network error")): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 1 + assert "Fetch failed" in result["stderr"] + + def test_push_failure(self, devpulse_dir: Path) -> None: + """Push failure after tag creation returns an error.""" + overrides = { + ("git", "push", "origin"): _make_mock(returncode=1, stderr="permission denied"), + } + with patch(_TAG_PATCH, side_effect=_mock_run_with_overrides(overrides)): + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 1 + assert "push failed" in result["stderr"].lower() + + +# =========================================================================== +# 5. tag --list +# =========================================================================== + + +class TestListTags: + """Tag listing tests.""" + + def test_empty_list(self, devpulse_dir: Path) -> None: + """Empty repo returns no tags.""" + with patch(_TAG_PATCH, return_value=_make_mock()): + result = handle_command("tag", ["--list"]) + assert result["exit_code"] == 0 + + def test_returns_tags(self, devpulse_dir: Path) -> None: + """Tags are returned sorted newest-first.""" + with patch(_TAG_PATCH, return_value=_make_mock(stdout="v2.6.1\nv2.6.0\nv2.5.0\n")): + result = handle_command("tag", ["--list"]) + assert result["exit_code"] == 0 + assert "v2.6.1" in result["stdout"] + assert "v2.5.0" in result["stdout"] + + def test_git_failure(self, devpulse_dir: Path) -> None: + """Git failure returns error.""" + with patch(_TAG_PATCH, return_value=_make_mock(returncode=128, stderr="not a git repository")): + result = handle_command("tag", ["--list"]) + assert result["exit_code"] == 0 + assert result["stdout"] != "" + + +# =========================================================================== +# 6. tag — access control and help +# =========================================================================== + + +class TestTagAccessControl: + """Access tier and help tests.""" + + def test_tag_denied_for_non_devpulse(self, drone_dir: Path) -> None: + """Non-devpulse caller is denied for tag create.""" + result = handle_command("tag", ["v2.6.1"]) + assert result["exit_code"] == 1 + assert "not authorized" in result["stderr"].lower() + + def test_tag_list_allowed_for_any_branch(self, drone_dir: Path) -> None: + """tag --list is global tier, any caller can use it.""" + with patch(_TAG_PATCH, return_value=_make_mock(stdout="v2.6.1\n")): + result = handle_command("tag", ["--list"]) + assert result["exit_code"] == 0 + + def test_tag_no_args_lists(self, drone_dir: Path) -> None: + """tag with no args falls back to list (global tier).""" + with patch(_TAG_PATCH, return_value=_make_mock()): + result = handle_command("tag", []) + assert result["exit_code"] == 0 + + def test_tag_help(self) -> None: + """tag help includes usage and guard descriptions.""" + help_text = get_help("tag") + assert "vX.Y.Z" in help_text + assert "Version guard" in help_text diff --git a/src/aipass/flow/templates/playbook_plans/merge.md b/src/aipass/flow/templates/playbook_plans/merge.md index ace021a1..cebf3213 100644 --- a/src/aipass/flow/templates/playbook_plans/merge.md +++ b/src/aipass/flow/templates/playbook_plans/merge.md @@ -16,8 +16,9 @@ Run by **devpulse** (only branch with git write). Tick each step as you go; fill > All git writes go through `drone @git` — **run drone from a branch dir** (it needs > `.trinity/passport.json` in the cwd; running from the repo root fails with "No > passport found"). Read git (`status`, `log`, `diff`, `rev-parse`) is allowed raw. -> ⚠️ `drone @git` has **no `tag` verb** — pushing the release tag is a MANUAL step -> (the user, or raw `git tag`/`push` via `!`). All other writes go through drone. +> Release tags go through **`drone @git tag v`** (devpulse, owner tier) — it +> version-guards against pyproject/`__init__` on `origin/main`, refuses duplicates, +> tags the remote ref, and pushes. No manual `git tag`/`push`, no user input (S274). --- @@ -131,13 +132,7 @@ How the release fires (verified `publish.yml`): a `v*` **git tag push** runs bui Steps: - [ ] Bump the version in **BOTH** files (they must match the tag, or `__version__` ships wrong): `pyproject.toml` `version` **and** `src/aipass/__init__.py` `__version__`. Do it **on dev so it rides into the PR** (then main's merge commit carries the right version). ⚠️ These two drift easily — `__init__.py` is the one that gets forgotten. - [ ] Confirm the CHANGELOG top section is the release notes you want -- [ ] Get the **real** merged-main sha from the **remote ref** (stay on dev — never checkout main): `git fetch origin` then `git rev-parse origin/main`. **Verify the version on that exact commit BEFORE tagging:** `git show origin/main:pyproject.toml | grep '^version'` and `git show origin/main:src/aipass/__init__.py | grep __version__` — both must equal the tag. (If the user merged via the GitHub UI, their local `main` ref is stale until `git fetch` — always fetch first, always tag `origin/main`, never local `main`.) -- [ ] **Push the tag — MANUAL (drone has no `tag` verb; devpulse can't push tags):** user runs it, via `!` or terminal. **Tag the remote ref directly so a stale local main can't poison it. Separate lines, no `&&`:** - ``` - git fetch origin - git tag v origin/main - git push origin v - ``` +- [ ] **Push the tag — `drone @git tag v` (devpulse, no user input needed).** The verb (owner tier) does it all safely: `git fetch origin`, **VERSION GUARD** (refuses unless the tag's `X.Y.Z` matches BOTH `origin/main:pyproject.toml` version and `origin/main:src/aipass/__init__.py` `__version__` — so you can't tag the wrong version), **EXISTS GUARD** (refuses if the tag already exists local or remote), then tags `origin/main` (the remote ref, never stale local main) and pushes → fires `publish.yml`. It reports the pushed sha. `drone @git tag --list` shows existing tags. This replaced the old manual `git tag`/`push` step (S274). - [ ] Verify PyPI shows the new version + the GitHub Release appeared (`curl -s https://pypi.org/pypi/aipass/json | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])"`) - [ ] Record the tag → Run Summary