fix(docker): report source build version

This commit is contained in:
Vinay Gupta 2026-07-07 09:02:44 -04:00
parent 48201345be
commit 6266a1d774
9 changed files with 99 additions and 7 deletions

View file

@ -7,6 +7,7 @@ ARG PYTHON_SITE_PACKAGES=/usr/local/lib/python${PYTHON_VERSION}/site-packages
FROM python:${PYTHON_VERSION}-slim AS builder
ARG UV_VERSION
ARG HEADROOM_BUILD_VERSION=""
# build-essential / g++ for any C extension wheels uv may need to build
# from source. curl + ca-certificates are required by the rustup
@ -51,6 +52,10 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=cache,target=/build/target \
uv pip install --system ".[${HEADROOM_EXTRAS}]"
RUN if [ -n "${HEADROOM_BUILD_VERSION}" ]; then \
cd /tmp && HEADROOM_BUILD_VERSION="${HEADROOM_BUILD_VERSION}" python -c "import os; from pathlib import Path; import headroom._version as v; p = Path(v.__file__).with_name('_build_info.py'); p.write_text('BUILD_VERSION = ' + repr(os.environ['HEADROOM_BUILD_VERSION']) + '\n', encoding='utf-8'); print('baked Headroom build version: ' + os.environ['HEADROOM_BUILD_VERSION'])"; \
fi
# Build-stage smoke check: verify the extension loads end-to-end inside
# the build image before we copy site-packages into the runtime image.
# If this fails, the runtime image would fail Phase A0's fail-loud
@ -145,4 +150,4 @@ ENTRYPOINT ["python3", "-m", "headroom.cli", "proxy"]
CMD ["--host", "0.0.0.0", "--port", "8787"]
# Default published image remains python-slim runtime
FROM runtime-slim-base AS runtime
FROM runtime-slim-base AS runtime

View file

@ -1,6 +1,9 @@
services:
headroom-proxy:
build: .
build:
context: .
args:
HEADROOM_BUILD_VERSION: ${HEADROOM_BUILD_VERSION:-source-build}
command: ["--host", "0.0.0.0"]
environment:
- HEADROOM_HOST=0.0.0.0

View file

@ -2,10 +2,39 @@
from __future__ import annotations
import importlib
import os
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
UNKNOWN_VERSION = "unknown"
VERSION_ENV_VARS = ("HEADROOM_VERSION", "HEADROOM_BUILD_VERSION")
def _clean_version(value: object) -> str | None:
"""Return a non-empty version string, if one is present."""
if not isinstance(value, str):
return None
stripped = value.strip()
return stripped or None
def _env_version() -> str | None:
"""Return an explicit runtime/build version override."""
for name in VERSION_ENV_VARS:
value = _clean_version(os.environ.get(name))
if value:
return value
return None
def _packaged_build_version() -> str | None:
"""Return Docker/image build metadata baked into the installed package."""
try:
build_info = importlib.import_module("headroom._build_info")
except ModuleNotFoundError:
return None
return _clean_version(getattr(build_info, "BUILD_VERSION", None))
def _source_root() -> Path | None:
@ -46,12 +75,20 @@ def _source_tree_version(root: Path) -> str | None:
def get_version() -> str:
"""Return Headroom's runtime version."""
env_version = _env_version()
if env_version:
return env_version
root = _source_root()
if root is not None:
source_version = _source_tree_version(root)
if source_version:
return source_version
build_version = _packaged_build_version()
if build_version:
return build_version
try:
return version("headroom-ai")
except PackageNotFoundError:

View file

@ -109,7 +109,7 @@
<header class="border-b border-border px-6 py-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="flex items-center gap-4">
<h1 class="text-xl font-semibold tracking-tight">HEADROOM</h1>
<span class="text-xs text-gray-500 font-mono" x-text="'v' + version"></span>
<span class="text-xs text-gray-500 font-mono" x-text="formatVersion(version)"></span>
</div>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
<div class="inline-flex rounded-lg border border-border bg-surface p-1">
@ -1767,7 +1767,7 @@
stats: {},
historyStats: {},
healthy: true,
version: '0.3.0',
version: 'loading',
lastUpdate: 'never',
viewMode: 'session',
historyGranularity: 'daily',
@ -1834,6 +1834,11 @@
}
},
formatVersion(value) {
if (value === 'loading' || value === 'unknown') return value;
return /^\d/.test(value) ? 'v' + value : value;
},
async fetchStats() {
try {
const [statsRes, healthRes] = await Promise.all([
@ -1844,7 +1849,7 @@
this.stats = await statsRes.json();
const health = await healthRes.json();
this.healthy = health.status === 'healthy';
this.version = health.version || '0.3.0';
this.version = health.version || 'unknown';
this.log_full_messages = this.stats.log_full_messages || false;
// Update history for sparklines

View file

@ -1404,7 +1404,7 @@ class HeadroomProxy(
self.http_client_h1 = (
self.http_client if not _http2 else httpx.AsyncClient(http2=False, **_client_kwargs)
)
logger.info("Headroom Proxy started")
logger.info("Headroom Proxy started (version %s)", __version__)
logger.info(f"Optimization: {'ENABLED' if self.config.optimize else 'DISABLED'}")
self.config.mode = normalize_proxy_mode(self.config.mode)
logger.info(f"Mode: {self.config.mode}")

View file

@ -14,3 +14,12 @@ def test_top_level_compose_pins_headroom_state_to_named_volume() -> None:
assert "- HOME=/home/nonroot" in compose
assert "- HEADROOM_WORKSPACE_DIR=/home/nonroot/.headroom" in compose
assert "- HEADROOM_CONFIG_DIR=/home/nonroot/.headroom/config" in compose
def test_top_level_compose_marks_source_build_version() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
assert "HEADROOM_BUILD_VERSION: ${HEADROOM_BUILD_VERSION:-source-build}" in compose
assert 'ARG HEADROOM_BUILD_VERSION=""' in dockerfile
assert "_build_info.py" in dockerfile

View file

@ -7,6 +7,7 @@ import os
import subprocess
import sys
import textwrap
import types
from importlib.metadata import PackageNotFoundError
from pathlib import Path
from unittest.mock import patch
@ -64,6 +65,31 @@ def test_version_reports_unknown_when_distribution_metadata_is_missing() -> None
assert version_module.get_version() == version_module.UNKNOWN_VERSION
def test_version_prefers_explicit_build_env(monkeypatch) -> None:
monkeypatch.setenv("HEADROOM_BUILD_VERSION", "source-build")
with patch.object(version_module, "version", return_value="9.8.7") as package_version:
assert version_module.get_version() == "source-build"
package_version.assert_not_called()
def test_version_uses_packaged_build_metadata(
monkeypatch,
) -> None:
build_info = types.ModuleType("headroom._build_info")
build_info.BUILD_VERSION = "0.29.0+gabcdef0"
monkeypatch.setitem(sys.modules, "headroom._build_info", build_info)
with (
patch.object(version_module, "_source_root", return_value=None),
patch.object(version_module, "version", return_value="0.29.0") as package_version,
):
assert version_module.get_version() == "0.29.0+gabcdef0"
package_version.assert_not_called()
def test_version_prefers_source_tree_release_history() -> None:
with (
patch.object(version_module, "_source_root", return_value=Path(".")),

View file

@ -591,6 +591,11 @@ def test_dashboard_uses_cached_stats_and_lazy_history_feed_polling() -> None:
html = get_dashboard_html()
assert "fetch('/stats?cached=1')" in html
assert "version: 'loading'" in html
assert 'x-text="formatVersion(version)"' in html
assert "return /^\\d/.test(value) ? 'v' + value : value" in html
assert "this.version = health.version || 'unknown'" in html
assert "0.3.0" not in html
assert "@click=\"setViewMode('history')\"" in html
assert '@click="toggleFeed()"' in html
assert "this.viewMode === 'history'" in html

View file

@ -8,7 +8,7 @@ pytest.importorskip("httpx")
from fastapi.testclient import TestClient
from headroom.proxy.server import ProxyConfig, create_app
from headroom.proxy.server import ProxyConfig, __version__, create_app
@pytest.fixture
@ -37,6 +37,7 @@ def test_livez_reports_process_health(client):
assert data["service"] == "headroom-proxy"
assert data["status"] == "healthy"
assert data["alive"] is True
assert data["version"] == __version__
assert data["uptime_seconds"] >= 0
@ -73,6 +74,7 @@ def test_health_preserves_backwards_compatible_config_payload(client):
data = response.json()
assert data["status"] == "healthy"
assert data["ready"] is True
assert data["version"] == __version__
config = data["config"]
assert config["backend"] == "anthropic"
assert config["optimize"] is False