From 479188a2cbc194464df17b7cc0e8b58fa3ec1c4a Mon Sep 17 00:00:00 2001 From: monte Date: Tue, 21 Jul 2026 23:46:43 +0200 Subject: [PATCH] fix(backend): resolve app version in the shipped Docker image The production Dockerfile installs only dependencies (`uv sync --frozen --no-dev --no-install-project`) and copies app source on top without ever installing the project itself, so no dist-info exists in the final image. importlib.metadata.version("guard-proxy-backend") silently falls back to "0.0.0+unknown" there, even though the same lookup works fine in local dev venvs where the project is installed editable. Fall back to parsing pyproject.toml directly via tomllib when package metadata isn't found, and ship pyproject.toml in the final image so that fallback has something to read. Verified inside an actual build of the image: /health now reports the real version instead of the fallback. --- src/backend/Dockerfile | 4 +++ src/backend/app/main.py | 22 +++++++++--- src/backend/tests/unit/test_app_version.py | 42 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 src/backend/tests/unit/test_app_version.py diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index d2ee178..bd9e65d 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -54,6 +54,10 @@ COPY --chown=app:app alembic alembic COPY --chown=app:app app app COPY --chown=app:app scripts scripts COPY --chown=app:app alembic.ini alembic.ini +# The project itself isn't installed as a distribution above (--no-install-project), +# so no dist-info exists for importlib.metadata to read. Ship pyproject.toml so +# app.main can fall back to parsing the version straight from it. +COPY --chown=app:app pyproject.toml pyproject.toml EXPOSE 8000 diff --git a/src/backend/app/main.py b/src/backend/app/main.py index e41327b..c580575 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -1,5 +1,6 @@ import logging import os +import tomllib from collections.abc import AsyncIterator from contextlib import asynccontextmanager from importlib.metadata import PackageNotFoundError @@ -42,15 +43,26 @@ def _resolve_app_version() -> str: - """Return the installed package version, the single source of truth. + """Return the app version, reading from a single source of truth. - Falls back gracefully when the app runs from a source tree that has not - been installed as a distribution (e.g. some local dev setups). + Prefers installed package metadata (present in dev venvs where the + project is installed editable). The production Docker image installs + only dependencies as a distinct layer and copies app source on top + without installing the project itself, so no dist-info exists there — + fall back to parsing `pyproject.toml` directly in that case. """ try: return _package_version("guard-proxy-backend") - except PackageNotFoundError: # pragma: no cover - dev-only fallback - return "0.0.0+unknown" + except PackageNotFoundError: + pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml" + try: + with pyproject_path.open("rb") as f: + version = tomllib.load(f)["project"]["version"] + if not isinstance(version, str): + raise KeyError("project.version is not a string") + return version + except (OSError, KeyError): # pragma: no cover - defensive fallback + return "0.0.0+unknown" APP_VERSION = _resolve_app_version() diff --git a/src/backend/tests/unit/test_app_version.py b/src/backend/tests/unit/test_app_version.py new file mode 100644 index 0000000..9b042c9 --- /dev/null +++ b/src/backend/tests/unit/test_app_version.py @@ -0,0 +1,42 @@ +import tomllib +from collections.abc import Iterator +from importlib.metadata import PackageNotFoundError +from pathlib import Path + +import pytest + +import app.main as main_module + +_PYPROJECT_VERSION = tomllib.loads( + (Path(main_module.__file__).resolve().parent.parent / "pyproject.toml").read_text() +)["project"]["version"] + + +@pytest.fixture +def package_metadata_missing() -> Iterator[None]: + """Simulate the production Docker image, where the project itself is + never installed as a distribution (only its dependencies are), so + importlib.metadata has no dist-info to read. + """ + + def _raise(name: str) -> str: + raise PackageNotFoundError(name) + + original = main_module._package_version + main_module._package_version = _raise + try: + yield + finally: + main_module._package_version = original + + +def test_resolves_from_installed_package_metadata_when_present() -> None: + assert main_module._resolve_app_version() == main_module._package_version( + "guard-proxy-backend" + ) + + +def test_falls_back_to_pyproject_toml_when_package_metadata_missing( + package_metadata_missing: None, +) -> None: + assert main_module._resolve_app_version() == _PYPROJECT_VERSION