diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml
index b3495f0f..a93976f9 100644
--- a/.github/workflows/container.yml
+++ b/.github/workflows/container.yml
@@ -14,6 +14,14 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+
+ - name: Resolve git metadata
+ id: gitmeta
+ run: |
+ echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
+ echo "tag=$(git describe --tags --exact-match HEAD 2>/dev/null || true)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
@@ -30,6 +38,9 @@ jobs:
with:
context: .
push: true
+ build-args: |
+ GIT_COMMIT=${{ steps.gitmeta.outputs.sha }}
+ GIT_TAG=${{ steps.gitmeta.outputs.tag }}
tags: |
ghcr.io/routstr/proxy:latest
ghcr.io/routstr/core:latest
diff --git a/Dockerfile b/Dockerfile
index adf5a7df..ffb405a1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,6 +21,10 @@ WORKDIR /app
COPY . .
+ARG GIT_COMMIT=""
+ARG GIT_TAG=""
+ENV GIT_COMMIT=${GIT_COMMIT}
+ENV GIT_TAG=${GIT_TAG}
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
diff --git a/Dockerfile.full b/Dockerfile.full
index 923b6e8f..12db34fc 100644
--- a/Dockerfile.full
+++ b/Dockerfile.full
@@ -41,6 +41,10 @@ RUN uv sync --no-dev
# Copy the built UI from the ui-builder stage
COPY --from=ui-builder /app/ui/out ./ui_out
+ARG GIT_COMMIT=""
+ARG GIT_TAG=""
+ENV GIT_COMMIT=${GIT_COMMIT}
+ENV GIT_TAG=${GIT_TAG}
ENV PORT=8000
ENV PYTHONUNBUFFERED=1
diff --git a/routstr/core/main.py b/routstr/core/main.py
index 7771dd5a..58cbaf43 100644
--- a/routstr/core/main.py
+++ b/routstr/core/main.py
@@ -1,5 +1,4 @@
import asyncio
-import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
@@ -32,16 +31,12 @@ from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .settings import SettingsService
from .settings import settings as global_settings
+from .version import __version__
# Initialize logging first
setup_logging()
logger = get_logger(__name__)
-if os.getenv("VERSION_SUFFIX") is not None:
- __version__ = f"0.4.3-{os.getenv('VERSION_SUFFIX')}"
-else:
- __version__ = "0.4.3"
-
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
diff --git a/routstr/core/version.py b/routstr/core/version.py
new file mode 100644
index 00000000..bd03d185
--- /dev/null
+++ b/routstr/core/version.py
@@ -0,0 +1,81 @@
+"""Application version resolution.
+
+Priority order:
+
+1. ``VERSION_SUFFIX`` env var (manual override; preserves prior behaviour).
+2. Bare base version when HEAD is on the matching release tag (detected via
+ ``GIT_TAG`` env or ``git describe --tags --exact-match HEAD``).
+3. ``GIT_COMMIT`` env var (build-time injection) -> ``+g``.
+4. Local ``.git`` lookup (source checkouts) -> ``+g``.
+5. Fallback: bare base version.
+
+The ``+g`` form is PEP 440 local-version syntax so the result remains a
+valid package version.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from functools import lru_cache
+from pathlib import Path
+
+BASE_VERSION = "0.4.3"
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+_GIT_TIMEOUT_SECONDS = 2.0
+
+
+def _run_git(*args: str) -> str | None:
+ try:
+ result = subprocess.run( # noqa: S603 - fixed argv, no shell
+ ["git", *args],
+ cwd=_REPO_ROOT,
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=_GIT_TIMEOUT_SECONDS,
+ )
+ except (FileNotFoundError, subprocess.SubprocessError, OSError):
+ return None
+ if result.returncode != 0:
+ return None
+ return result.stdout.strip() or None
+
+
+def _git_short_sha() -> str | None:
+ sha = os.getenv("GIT_COMMIT", "").strip()
+ if sha:
+ return sha[:7]
+ return _run_git("rev-parse", "--short=7", "HEAD")
+
+
+def _on_tagged_release() -> bool:
+ tag = os.getenv("GIT_TAG", "").strip()
+ if tag:
+ return tag.lstrip("v") == BASE_VERSION
+ described = _run_git("describe", "--tags", "--exact-match", "HEAD")
+ if not described:
+ return False
+ return described.lstrip("v") == BASE_VERSION
+
+
+@lru_cache(maxsize=1)
+def get_version() -> str:
+ suffix = os.getenv("VERSION_SUFFIX")
+ if suffix is not None:
+ return f"{BASE_VERSION}-{suffix}"
+
+ if _on_tagged_release():
+ return BASE_VERSION
+
+ sha = _git_short_sha()
+ if not sha:
+ return BASE_VERSION
+
+ return f"{BASE_VERSION}+g{sha}"
+
+
+__version__ = get_version()
+
+__all__ = ["BASE_VERSION", "__version__", "get_version"]
diff --git a/routstr/nostr/listing.py b/routstr/nostr/listing.py
index a512add7..7c60e9b1 100644
--- a/routstr/nostr/listing.py
+++ b/routstr/nostr/listing.py
@@ -26,7 +26,7 @@ logger = get_logger(__name__)
def get_app_version() -> str | None:
try:
- from ..core.main import __version__ as imported_version
+ from ..core.version import __version__ as imported_version
return imported_version
except Exception: