Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions scripts/build-binary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ else
echo -e "${YELLOW}UPX not found - binary will not be compressed (install with: brew install upx)${NC}"
fi

# Inject build SHA into version.py
VERSION_FILE="src/apm_cli/version.py"
BUILD_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "")
if [ -n "$BUILD_SHA" ]; then
echo -e "${YELLOW}Injecting build SHA: $BUILD_SHA${NC}"
sed -i.bak "s/^__BUILD_SHA__ = None$/__BUILD_SHA__ = \"$BUILD_SHA\"/" "$VERSION_FILE"
# Guarantee restore on any exit (success, failure, or signal)
trap 'if [ -f "${VERSION_FILE}.bak" ]; then mv "${VERSION_FILE}.bak" "$VERSION_FILE"; fi' EXIT
fi

# Build binary
echo -e "${YELLOW}Building binary with PyInstaller...${NC}"
uv run pyinstaller build/apm.spec
Expand Down
11 changes: 8 additions & 3 deletions src/apm_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from apm_cli.utils.github_host import is_valid_fqdn, default_host

# APM imports - use absolute imports everywhere for consistency
from apm_cli.version import get_version
from apm_cli.version import get_build_sha, get_version
from apm_cli.utils.version_checker import check_for_updates

# APM Dependencies - Import for Task 5 integration
Expand Down Expand Up @@ -230,19 +230,24 @@ def print_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return

version_str = get_version()
sha = get_build_sha()
if sha:
version_str += f" ({sha})"

console = _get_console()
if console:
from rich.panel import Panel # type: ignore
from rich.text import Text # type: ignore

version_text = Text()
version_text.append("Agent Package Manager (APM) CLI", style="bold cyan")
version_text.append(f" version {get_version()}", style="white")
version_text.append(f" version {version_str}", style="white")
console.print(Panel(version_text, border_style="cyan", padding=(0, 1)))
else:
# Graceful fallback when Rich isn't available (e.g., stripped automation environment)
click.echo(
f"{TITLE}Agent Package Manager (APM) CLI{RESET} version {get_version()}"
f"{TITLE}Agent Package Manager (APM) CLI{RESET} version {version_str}"
)

ctx.exit()
Expand Down
31 changes: 30 additions & 1 deletion src/apm_cli/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import sys
from pathlib import Path

# Build-time version constant (will be injected during build)
# Build-time constants (will be injected during build)
# This avoids TOML parsing overhead during runtime
__BUILD_VERSION__ = None
__BUILD_SHA__ = None


def get_version() -> str:
Expand Down Expand Up @@ -65,5 +66,33 @@ def get_version() -> str:
return "unknown"


def get_build_sha() -> str:
"""Get the short git commit SHA for the current build.

Uses the build-time constant when available (shipped binaries),
otherwise falls back to querying git at runtime (development).
"""
if __BUILD_SHA__:
return __BUILD_SHA__

# Fallback: query git at runtime (development only)
if not getattr(sys, 'frozen', False):
import subprocess
try:
repo_root = Path(__file__).parent.parent.parent
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=repo_root,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return ""


# For backward compatibility
__version__ = get_version()
56 changes: 56 additions & 0 deletions tests/unit/test_build_sha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Tests for get_build_sha() function."""

import subprocess
import unittest
from unittest.mock import patch, MagicMock

from apm_cli.version import get_build_sha


class TestGetBuildSha(unittest.TestCase):
"""Test build SHA retrieval across all code paths."""

@patch("apm_cli.version.__BUILD_SHA__", "abc1234")
def test_returns_build_time_constant_when_set(self):
"""Build-time constant takes priority over git."""
assert get_build_sha() == "abc1234"

@patch("apm_cli.version.__BUILD_SHA__", None)
def test_returns_empty_when_frozen_and_no_constant(self):
"""Frozen binary without build constant returns empty string."""
with patch.object(__import__("sys"), "frozen", True, create=True):
assert get_build_sha() == ""

@patch("apm_cli.version.__BUILD_SHA__", None)
@patch("subprocess.run")
def test_falls_back_to_git_in_development(self, mock_run):
"""In development, queries git rev-parse."""
mock_run.return_value = MagicMock(returncode=0, stdout="d1630d1\n")
with patch("sys.frozen", False, create=True):
result = get_build_sha()
assert result == "d1630d1"
mock_run.assert_called_once()
args = mock_run.call_args
assert args[0][0] == ["git", "rev-parse", "--short", "HEAD"]

@patch("apm_cli.version.__BUILD_SHA__", None)
@patch("subprocess.run", side_effect=FileNotFoundError("git not found"))
def test_returns_empty_when_git_unavailable(self, _mock_run):
"""Returns empty string when git is not installed."""
with patch("sys.frozen", False, create=True):
assert get_build_sha() == ""

@patch("apm_cli.version.__BUILD_SHA__", None)
@patch("subprocess.run")
def test_returns_empty_when_git_fails(self, mock_run):
"""Returns empty string when git command fails (e.g., not a repo)."""
mock_run.return_value = MagicMock(returncode=128, stdout="")
with patch("sys.frozen", False, create=True):
assert get_build_sha() == ""

@patch("apm_cli.version.__BUILD_SHA__", None)
@patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="git", timeout=5))
def test_returns_empty_on_timeout(self, _mock_run):
"""Returns empty string when git times out."""
with patch("sys.frozen", False, create=True):
assert get_build_sha() == ""