feat(drone): add 'drone @git tag <vX.Y.Z>' — guarded release-tag push, automate the merge playbook's last manual step
devpulse-tier (owner) verb: fetch origin, VERSION GUARD (tag X.Y.Z must match origin/main pyproject + __init__), EXISTS GUARD (refuse if tag exists), tag origin/main + push -> fires publish.yml. 'tag --list' is global tier. Removes the last user-input step from releases (Patrick request, S274). Merge playbook (merge.md) updated to use it. Verb proven live: cut v2.6.1.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."}
|
||||
@@ -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 <vX.Y.Z> — 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 <vX.Y.Z> 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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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<version>`** (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<version> origin/main
|
||||
git push origin v<version>
|
||||
```
|
||||
- [ ] **Push the tag — `drone @git tag v<version>` (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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user