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:
AIOSAI
2026-07-02 19:02:02 -07:00
parent f62fbcfc17
commit f83a003a73
7 changed files with 549 additions and 21 deletions
+9 -6
View File
@@ -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."}
+39 -5
View File
@@ -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",
+296
View File
@@ -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