Skip to content

Tag

tag

GitTagTool — one-shot semver tag: preflight + compute + create + verify + push.

GitTagTool

Bases: AXMTool

Create a semver release tag in one call.

Performs preflight checks (clean tree, CI status), computes the next version from Conventional Commits, creates an annotated tag, verifies hatch-vcs resolution, and pushes to origin.

Registered as git_tag via axm.tools entry point.

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
class GitTagTool(AXMTool):
    """Create a semver release tag in one call.

    Performs preflight checks (clean tree, CI status), computes the
    next version from Conventional Commits, creates an annotated tag,
    verifies hatch-vcs resolution, and pushes to origin.

    Registered as ``git_tag`` via axm.tools entry point.
    """

    @property
    def name(self) -> str:
        """Tool name used for MCP registration."""
        return "git_tag"

    def execute(
        self,
        *,
        path: str = ".",
        version: str | None = None,
        **kwargs: object,
    ) -> ToolResult:
        """Create and push a semver tag.

        Args:
            path: Project root (required).
            version: Version override (optional, e.g. ``"v1.0.0"``).

        Returns:
            ToolResult with tag, version, and push status.
        """
        resolved = Path(path).resolve()
        tag_prefix = get_tag_prefix(resolved)

        try:
            # 1. Preflight: repo, clean tree, CI, commits
            result = _preflight(resolved, tag_prefix=tag_prefix)
            if isinstance(result, ToolResult):
                return result
            ci_check, current_tag, commits = result

            # 2. Compute version
            next_version, bump_type, breaking = resolve_version(
                version, current_tag, commits, tag_prefix=tag_prefix
            )
            logger.info(
                "Tagging %s (bump=%s, breaking=%s)",
                next_version,
                bump_type,
                breaking,
            )

            # 3. Create annotated tag
            full_tag = f"{tag_prefix}{next_version}"
            tag_result = run_git(["tag", "-a", full_tag, "-m", full_tag], resolved)
            if tag_result.returncode != 0:
                error = f"Failed to create tag: {tag_result.stderr.strip()}"
                return ToolResult(
                    success=False,
                    error=error,
                    text=render_failure_text(error=error, data=None),
                )

            # 4. Verify hatch-vcs (best-effort)
            resolved_version = None
            pkg_name = detect_package_name(resolved)
            if pkg_name:
                resolved_version = verify_hatch_vcs(resolved, pkg_name)

            # 5. Push tag
            push = run_git(["push", "origin", full_tag], resolved)
        except ValueError as exc:
            error = str(exc)
            return ToolResult(
                success=False,
                error=error,
                text=render_failure_text(error=error, data=None),
            )
        except subprocess.TimeoutExpired as exc:
            return _timeout_error_result(exc)

        data: dict[str, object] = {
            "tag": next_version,
            "full_tag": full_tag,
            "bump": bump_type,
            "breaking": breaking,
            "resolved_version": resolved_version,
            "pushed": push.returncode == 0,
            "ci_check": ci_check,
            "commits_included": len(commits),
            "current_tag": current_tag or "none",
        }
        return ToolResult(success=True, data=data, text=render_text(data))
name property

Tool name used for MCP registration.

execute(*, path='.', version=None, **kwargs)

Create and push a semver tag.

Parameters:

Name Type Description Default
path str

Project root (required).

'.'
version str | None

Version override (optional, e.g. "v1.0.0").

None

Returns:

Type Description
ToolResult

ToolResult with tag, version, and push status.

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
def execute(
    self,
    *,
    path: str = ".",
    version: str | None = None,
    **kwargs: object,
) -> ToolResult:
    """Create and push a semver tag.

    Args:
        path: Project root (required).
        version: Version override (optional, e.g. ``"v1.0.0"``).

    Returns:
        ToolResult with tag, version, and push status.
    """
    resolved = Path(path).resolve()
    tag_prefix = get_tag_prefix(resolved)

    try:
        # 1. Preflight: repo, clean tree, CI, commits
        result = _preflight(resolved, tag_prefix=tag_prefix)
        if isinstance(result, ToolResult):
            return result
        ci_check, current_tag, commits = result

        # 2. Compute version
        next_version, bump_type, breaking = resolve_version(
            version, current_tag, commits, tag_prefix=tag_prefix
        )
        logger.info(
            "Tagging %s (bump=%s, breaking=%s)",
            next_version,
            bump_type,
            breaking,
        )

        # 3. Create annotated tag
        full_tag = f"{tag_prefix}{next_version}"
        tag_result = run_git(["tag", "-a", full_tag, "-m", full_tag], resolved)
        if tag_result.returncode != 0:
            error = f"Failed to create tag: {tag_result.stderr.strip()}"
            return ToolResult(
                success=False,
                error=error,
                text=render_failure_text(error=error, data=None),
            )

        # 4. Verify hatch-vcs (best-effort)
        resolved_version = None
        pkg_name = detect_package_name(resolved)
        if pkg_name:
            resolved_version = verify_hatch_vcs(resolved, pkg_name)

        # 5. Push tag
        push = run_git(["push", "origin", full_tag], resolved)
    except ValueError as exc:
        error = str(exc)
        return ToolResult(
            success=False,
            error=error,
            text=render_failure_text(error=error, data=None),
        )
    except subprocess.TimeoutExpired as exc:
        return _timeout_error_result(exc)

    data: dict[str, object] = {
        "tag": next_version,
        "full_tag": full_tag,
        "bump": bump_type,
        "breaking": breaking,
        "resolved_version": resolved_version,
        "pushed": push.returncode == 0,
        "ci_check": ci_check,
        "commits_included": len(commits),
        "current_tag": current_tag or "none",
    }
    return ToolResult(success=True, data=data, text=render_text(data))

check_ci(path)

Check CI status via gh for the CI run matching HEAD.

Returns one of green/red/pending/skipped/error. The status is derived from the run whose headSha matches the current HEAD SHA, never from runs[0] unconditionally: a stale green (HEAD moved past it) or a red on an unrelated commit must not influence the verdict. When no run matches HEAD, returns pending (block tagging until CI exists for the exact commit being tagged).

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
def check_ci(path: Path) -> str:
    """Check CI status via ``gh`` for the CI run matching HEAD.

    Returns one of green/red/pending/skipped/error. The status is derived
    from the run whose ``headSha`` matches the current HEAD SHA, never from
    ``runs[0]`` unconditionally: a stale green (HEAD moved past it) or a red
    on an unrelated commit must not influence the verdict. When no run
    matches HEAD, returns ``pending`` (block tagging until CI exists for the
    exact commit being tagged).
    """
    if not gh_available():
        return "skipped"
    try:
        ci = run_gh(
            [
                "run",
                "list",
                "--branch",
                "main",
                "--limit",
                "3",
                "--json",
                "status,conclusion,headSha",
            ],
            path,
        )
        if ci.returncode != 0 or not ci.stdout.strip():
            return "skipped"
        runs = json.loads(ci.stdout)
        if not runs:
            return "skipped"
        head_sha = _head_sha(path)
        if head_sha is None:
            return "pending"
        for run in runs:
            if _sha_matches(str(run.get("headSha") or ""), head_sha):
                return _verdict(run)
        return "pending"
    except (json.JSONDecodeError, FileNotFoundError):
        return "error"

get_tag_prefix(path)

Read tag prefix from pyproject.toml tag-pattern (e.g. git/).

Returns the prefix string (e.g. "git/") or "" if none.

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
def get_tag_prefix(path: Path) -> str:
    """Read tag prefix from pyproject.toml ``tag-pattern`` (e.g. ``git/``).

    Returns the prefix string (e.g. ``"git/"``) or ``""`` if none.
    """
    pyproject = path / "pyproject.toml"
    if not pyproject.exists():
        return ""
    try:
        with open(pyproject, "rb") as f:
            data = tomllib.load(f)
        pattern = (
            data.get("tool", {})
            .get("hatch", {})
            .get("version", {})
            .get("tag-pattern", "")
        )
        # Extract prefix before "v" from patterns like "git/v(?P<version>.*)"
        m = re.match(r"^(.+?)v\(", pattern)
        return m.group(1) if m else ""
    except (OSError, tomllib.TOMLDecodeError):
        return ""

resolve_version(version_override, current_tag, commits, *, tag_prefix='')

Resolve the next version tag.

Returns:

Type Description
tuple[str, str, bool]

(next_version, bump_type, breaking).

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
def resolve_version(
    version_override: str | None,
    current_tag: str | None,
    commits: list[str],
    *,
    tag_prefix: str = "",
) -> tuple[str, str, bool]:
    """Resolve the next version tag.

    Returns:
        ``(next_version, bump_type, breaking)``.
    """
    if version_override:
        v = (
            version_override
            if version_override.startswith("v")
            else f"v{version_override}"
        )
        override_tuple = parse_tag(v)
        if current_tag and override_tuple <= parse_tag(current_tag):
            msg = (
                f"Version override {v!r} is not strictly greater than "
                f"the current tag {current_tag!r}"
            )
            raise ValueError(msg)
        return v, "override", False

    base = current_tag or "v0.0.0"
    if tag_prefix and base.startswith(tag_prefix):
        base = base[len(tag_prefix) :]
    bump_result = compute_bump(commits, base)
    return bump_result.next, bump_result.bump, bump_result.breaking

verify_hatch_vcs(path, pkg_name)

Rebuild package and read resolved version (best-effort).

Source code in packages/axm-git/src/axm_git/tools/tag.py
Python
def verify_hatch_vcs(path: Path, pkg_name: str) -> str | None:
    """Rebuild package and read resolved version (best-effort)."""
    try:
        sync = subprocess.run(
            ["uv", "sync", "--reinstall-package", pkg_name],
            cwd=str(path),
            capture_output=True,
            text=True,
            check=False,
            timeout=600,
        )
        if sync.returncode != 0:
            return None
        ver = subprocess.run(
            [
                "uv",
                "run",
                "python",
                "-c",
                f"from importlib.metadata import version; print(version('{pkg_name}'))",
            ],
            cwd=str(path),
            capture_output=True,
            text=True,
            check=False,
            timeout=60,
        )
        if ver.returncode == 0:
            return ver.stdout.strip()
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
    return None