From 92b9b20af6e3d5432d54a739ded7d3b5549ed751 Mon Sep 17 00:00:00 2001 From: sergio-sisternes-epam Date: Thu, 5 Mar 2026 23:46:39 +0000 Subject: [PATCH 1/2] feat: display build commit SHA in CLI version output Show the short git commit SHA alongside the version number in `apm --version` (e.g. 0.7.4 (772671a)) to give users and maintainers immediate visibility into which exact commit a build was produced from. - Add __BUILD_SHA__ constant to version.py, injected at build time - Fall back to live git query during development - Build script injects SHA before PyInstaller and restores afterward - Gracefully omits SHA when git is unavailable --- scripts/build-binary.sh | 13 +++++++++++++ src/apm_cli/cli.py | 14 +++++++++++--- src/apm_cli/version.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh index d591a099..06cd88e8 100755 --- a/scripts/build-binary.sh +++ b/scripts/build-binary.sh @@ -62,10 +62,23 @@ 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" +fi + # Build binary echo -e "${YELLOW}Building binary with PyInstaller...${NC}" uv run pyinstaller build/apm.spec +# Restore version.py to avoid dirtying the working tree +if [ -f "${VERSION_FILE}.bak" ]; then + mv "${VERSION_FILE}.bak" "$VERSION_FILE" +fi + # Check if build was successful (onedir mode creates dist/apm/apm) if [ ! -f "dist/apm/apm" ]; then echo -e "${RED}Build failed - binary not found${NC}" diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 26148c4e..fc1e308d 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -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 @@ -235,14 +235,22 @@ def print_version(ctx, param, value): from rich.panel import Panel # type: ignore from rich.text import Text # type: ignore + sha = get_build_sha() + version_str = get_version() + if sha: + version_str += f" ({sha})" 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) + sha = get_build_sha() + ver = get_version() + if sha: + ver += f" ({sha})" click.echo( - f"{TITLE}Agent Package Manager (APM) CLI{RESET} version {get_version()}" + f"{TITLE}Agent Package Manager (APM) CLI{RESET} version {ver}" ) ctx.exit() diff --git a/src/apm_cli/version.py b/src/apm_cli/version.py index 54f98176..cd68a814 100644 --- a/src/apm_cli/version.py +++ b/src/apm_cli/version.py @@ -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: @@ -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() From 5d7750b1ff62df7dbb7de819ebfbddda1232d59b Mon Sep 17 00:00:00 2001 From: sergio-sisternes-epam Date: Fri, 6 Mar 2026 08:11:45 +0000 Subject: [PATCH 2/2] fix: address Copilot review feedback on PR #176 - build-binary.sh: use EXIT trap to guarantee version.py restore on failure - cli.py: extract SHA + version string before Rich/plain-text branch (DRY) - tests: add unit tests for get_build_sha() covering all code paths --- scripts/build-binary.sh | 7 ++--- src/apm_cli/cli.py | 15 ++++------ tests/unit/test_build_sha.py | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_build_sha.py diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh index 06cd88e8..702d61f9 100755 --- a/scripts/build-binary.sh +++ b/scripts/build-binary.sh @@ -68,17 +68,14 @@ 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 -# Restore version.py to avoid dirtying the working tree -if [ -f "${VERSION_FILE}.bak" ]; then - mv "${VERSION_FILE}.bak" "$VERSION_FILE" -fi - # Check if build was successful (onedir mode creates dist/apm/apm) if [ ! -f "dist/apm/apm" ]; then echo -e "${RED}Build failed - binary not found${NC}" diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index c1ce1e48..2d2480a1 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -230,27 +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 - sha = get_build_sha() - version_str = get_version() - if sha: - version_str += f" ({sha})" version_text = Text() version_text.append("Agent Package Manager (APM) CLI", style="bold cyan") 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) - sha = get_build_sha() - ver = get_version() - if sha: - ver += f" ({sha})" click.echo( - f"{TITLE}Agent Package Manager (APM) CLI{RESET} version {ver}" + f"{TITLE}Agent Package Manager (APM) CLI{RESET} version {version_str}" ) ctx.exit() diff --git a/tests/unit/test_build_sha.py b/tests/unit/test_build_sha.py new file mode 100644 index 00000000..71c0a374 --- /dev/null +++ b/tests/unit/test_build_sha.py @@ -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() == ""