Skip to content

Commit 3807488

Browse files
authored
fix(docker): report source build version (headroomlabs-ai#1862)
## Description Closes headroomlabs-ai#1858 Docker/Compose source builds could report stale or misleading version information: the dashboard initially rendered a hardcoded `v0.3.0`, then `/health` replaced it with installed package metadata, which can be stale when building locally from `main` without release metadata in the image. This change makes source Docker Compose builds report an explicit source-build identity, removes the stale dashboard fallback, and keeps CLI/doctor version checks from treating source-build labels as release-version drift. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add `HEADROOM_VERSION` / `HEADROOM_BUILD_VERSION` runtime version overrides and optional packaged `_build_info.py` metadata. - Teach Docker Compose source builds to pass a `source-build` sentinel that the Dockerfile expands to `source-build+g<sha>` when git metadata is available, or `source-build+sha256.<digest>` otherwise. - Keep release/published image builds on normal package metadata when `HEADROOM_BUILD_VERSION` is unset. - Include only minimal `.git` metadata in the Docker build context so the source-build label can identify the checkout without copying git objects. - Treat source-build labels and raw hashes as non-release labels in `wrap` and `doctor`, avoiding false stale-proxy restarts and drift warnings. - Replace the dashboard hardcoded `0.3.0` fallback with `loading` / `unknown` and format non-release build labels without a `v` prefix. - Include the runtime version in proxy startup logs, `/health`, `/livez`, and OTEL service version reporting. ## Testing - [x] Unit tests pass (`pytest` in GitHub CI) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text GitHub CI: all checks passing - CI: build, build-wheel, lint, test shards, test-extras, test-agno, test-dashboard-ui - Docker: docker-native-e2e, docker-wrap-e2e, docker-init-e2e - Native wrappers: macOS, Windows, Ubuntu - Security: CodeQL, gitleaks, pip-audit - Governance: template, label, merge-conflicts, commitlint $ HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1858-version-mismatch pytest tests/test_package_init_lazy.py::test_version_prefers_explicit_build_env tests/test_package_init_lazy.py::test_version_label_helpers_only_prefix_release_versions tests/test_package_init_lazy.py::test_version_uses_packaged_build_metadata tests/test_package_init_lazy.py::test_observability_version_uses_runtime_version tests/test_docker_compose_persistence.py tests/test_cli_doctor.py::TestProxyLiveness::test_up_leaves_source_label_unprefixed tests/test_cli_doctor.py::TestVersionDrift::test_non_release_version_labels_skip_drift_comparison tests/test_cli/test_wrap_persistent.py::test_proxy_version_restart_ignores_non_release_source_labels tests/test_proxy_dashboard_stats_cache.py::test_dashboard_uses_cached_stats_and_lazy_history_feed_polling -q 13 passed, 1 warning $ uvx ruff==0.15.17 check . All checks passed! $ uvx ruff==0.15.17 format --check . 1058 files already formatted $ uvx mypy==1.20.2 headroom --ignore-missing-imports Success: no issues found in 407 source files $ git diff --check # no output $ docker compose config # resolved headroom-proxy build args include HEADROOM_BUILD_VERSION: source-build $ HEADROOM_BUILD_VERSION=6266a1d docker compose config # explicit override is preserved as HEADROOM_BUILD_VERSION: 6266a1d $ docker build --check --build-arg HEADROOM_BUILD_VERSION=source-build . Check complete, no warnings found. ``` ## Real Behavior Proof - Environment: macOS local checkout, Python 3.13.5, Docker Desktop builder `desktop-linux`, plus GitHub Actions CI. - Exact command / steps: `docker compose config`, `HEADROOM_BUILD_VERSION=6266a1d docker compose config`, and `docker build --check --build-arg HEADROOM_BUILD_VERSION=source-build .`. - Observed result: Compose defaults the top-level `headroom-proxy` build arg to the `source-build` sentinel, preserves explicit overrides, and Dockerfile syntax/check validation passes for the source-build path. - Not tested: Full end-to-end release publishing flow; this PR only changes local/source-build reporting. - CI proof: GitHub Actions completed successfully across Docker E2E, CI test shards, lint/type checks, native wrapper checks, security checks, and PR governance. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally/CI with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes Docs and changelog are N/A for this runtime-reporting bug fix. The PR is open and ready for review with all GitHub checks passing.
1 parent 5af5e22 commit 3807488

15 files changed

Lines changed: 316 additions & 22 deletions

‎.dockerignore‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# VCS
2-
.git
2+
.git/*
3+
!.git/HEAD
4+
!.git/packed-refs
5+
!.git/refs/
6+
!.git/refs/**
37
.github
48
.github/*
59
!.github/plugin/

‎Dockerfile‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ ARG PYTHON_SITE_PACKAGES=/usr/local/lib/python${PYTHON_VERSION}/site-packages
77
FROM python:${PYTHON_VERSION}-slim AS builder
88

99
ARG UV_VERSION
10+
ARG PYTHON_SITE_PACKAGES
11+
ARG HEADROOM_BUILD_VERSION=""
1012

1113
# build-essential / g++ for any C extension wheels uv may need to build
1214
# from source. curl + ca-certificates are required by the rustup
@@ -51,6 +53,85 @@ RUN --mount=type=cache,target=/root/.cache/uv \
5153
--mount=type=cache,target=/build/target \
5254
uv pip install --system ".[${HEADROOM_EXTRAS}]"
5355

56+
RUN --mount=type=bind,source=.,target=/context,readonly \
57+
HEADROOM_BUILD_VERSION="${HEADROOM_BUILD_VERSION}" PYTHON_SITE_PACKAGES="${PYTHON_SITE_PACKAGES}" python - <<'PY'
58+
import hashlib
59+
import os
60+
from pathlib import Path
61+
62+
63+
def git_revision(context: Path) -> str | None:
64+
git_dir = context / ".git"
65+
head_path = git_dir / "HEAD"
66+
if not head_path.exists():
67+
return None
68+
head = head_path.read_text(encoding="utf-8").strip()
69+
if head.startswith("ref: "):
70+
ref_name = head.removeprefix("ref: ").strip()
71+
ref_path = git_dir / ref_name
72+
if ref_path.exists():
73+
head = ref_path.read_text(encoding="utf-8").strip()
74+
else:
75+
packed_refs = git_dir / "packed-refs"
76+
if not packed_refs.exists():
77+
return None
78+
for line in packed_refs.read_text(encoding="utf-8").splitlines():
79+
if line.startswith("#") or not line.strip():
80+
continue
81+
sha, _, name = line.partition(" ")
82+
if name.strip() == ref_name:
83+
head = sha
84+
break
85+
else:
86+
return None
87+
return head[:12] if len(head) >= 7 and all(c in "0123456789abcdef" for c in head.lower()) else None
88+
89+
90+
def source_digest(root: Path) -> str:
91+
digest = hashlib.sha256()
92+
inputs = (
93+
"pyproject.toml",
94+
"uv.lock",
95+
"README.md",
96+
"Cargo.toml",
97+
"Cargo.lock",
98+
"rust-toolchain.toml",
99+
"crates",
100+
"headroom",
101+
)
102+
for name in inputs:
103+
path = root / name
104+
if not path.exists():
105+
continue
106+
files = [path] if path.is_file() else sorted(p for p in path.rglob("*") if p.is_file())
107+
for file in files:
108+
digest.update(file.relative_to(root).as_posix().encode("utf-8"))
109+
digest.update(b"\0")
110+
digest.update(file.read_bytes())
111+
digest.update(b"\0")
112+
return digest.hexdigest()[:12]
113+
114+
115+
build_version = os.environ["HEADROOM_BUILD_VERSION"].strip()
116+
if not build_version:
117+
print("no Headroom build version override provided; using installed package metadata")
118+
raise SystemExit(0)
119+
if build_version == "source-build":
120+
revision = git_revision(Path("/context"))
121+
build_version = (
122+
f"source-build+g{revision}"
123+
if revision
124+
else f"source-build+sha256.{source_digest(Path('/build'))}"
125+
)
126+
127+
package_dir = Path(os.environ["PYTHON_SITE_PACKAGES"]) / "headroom"
128+
(package_dir / "_build_info.py").write_text(
129+
"BUILD_VERSION = " + repr(build_version) + "\n",
130+
encoding="utf-8",
131+
)
132+
print("baked Headroom build version: " + build_version)
133+
PY
134+
54135
# Build-stage smoke check: verify the extension loads end-to-end inside
55136
# the build image before we copy site-packages into the runtime image.
56137
# If this fails, the runtime image would fail Phase A0's fail-loud
@@ -145,4 +226,4 @@ ENTRYPOINT ["python3", "-m", "headroom.cli", "proxy"]
145226
CMD ["--host", "0.0.0.0", "--port", "8787"]
146227

147228
# Default published image remains python-slim runtime
148-
FROM runtime-slim-base AS runtime
229+
FROM runtime-slim-base AS runtime

‎docker-compose.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
services:
22
headroom-proxy:
3-
build: .
3+
build:
4+
context: .
5+
args:
6+
HEADROOM_BUILD_VERSION: ${HEADROOM_BUILD_VERSION:-source-build}
47
command: ["--host", "0.0.0.0"]
58
environment:
69
- HEADROOM_HOST=0.0.0.0

‎headroom/_version.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,63 @@
22

33
from __future__ import annotations
44

5+
import importlib
6+
import os
7+
import re
58
from importlib.metadata import PackageNotFoundError, version
69
from pathlib import Path
710

811
UNKNOWN_VERSION = "unknown"
12+
VERSION_ENV_VARS = ("HEADROOM_VERSION", "HEADROOM_BUILD_VERSION")
13+
RELEASE_VERSION_RE = re.compile(r"^v?\d+\.\d+\.\d+$")
14+
15+
16+
def _clean_version(value: object) -> str | None:
17+
"""Return a non-empty version string, if one is present."""
18+
if not isinstance(value, str):
19+
return None
20+
stripped = value.strip()
21+
return stripped or None
22+
23+
24+
def is_release_version(value: object) -> bool:
25+
"""Return whether a value is a comparable release version."""
26+
cleaned = _clean_version(value)
27+
return bool(cleaned and RELEASE_VERSION_RE.fullmatch(cleaned))
28+
29+
30+
def normalize_release_version(value: object) -> str | None:
31+
"""Return a comparable release version without a display prefix."""
32+
cleaned = _clean_version(value)
33+
if cleaned is None or RELEASE_VERSION_RE.fullmatch(cleaned) is None:
34+
return None
35+
return cleaned[1:] if cleaned.startswith("v") else cleaned
36+
37+
38+
def format_version_label(value: object) -> str:
39+
"""Return a user-facing version label without prefixing source labels."""
40+
cleaned = _clean_version(value) or UNKNOWN_VERSION
41+
if is_release_version(cleaned) and not cleaned.startswith("v"):
42+
return f"v{cleaned}"
43+
return cleaned
44+
45+
46+
def _env_version() -> str | None:
47+
"""Return an explicit runtime/build version override."""
48+
for name in VERSION_ENV_VARS:
49+
value = _clean_version(os.environ.get(name))
50+
if value:
51+
return value
52+
return None
53+
54+
55+
def _packaged_build_version() -> str | None:
56+
"""Return Docker/image build metadata baked into the installed package."""
57+
try:
58+
build_info = importlib.import_module("headroom._build_info")
59+
except ModuleNotFoundError:
60+
return None
61+
return _clean_version(getattr(build_info, "BUILD_VERSION", None))
962

1063

1164
def _source_root() -> Path | None:
@@ -46,12 +99,20 @@ def _source_tree_version(root: Path) -> str | None:
4699

47100
def get_version() -> str:
48101
"""Return Headroom's runtime version."""
102+
env_version = _env_version()
103+
if env_version:
104+
return env_version
105+
49106
root = _source_root()
50107
if root is not None:
51108
source_version = _source_tree_version(root)
52109
if source_version:
53110
return source_version
54111

112+
build_version = _packaged_build_version()
113+
if build_version:
114+
return build_version
115+
55116
try:
56117
return version("headroom-ai")
57118
except PackageNotFoundError:

‎headroom/cli/doctor.py‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import click
2424

25+
from headroom._version import format_version_label, normalize_release_version
2526
from headroom.install.health import probe_json
2627
from headroom.install.paths import claude_settings_path, codex_config_path
2728
from headroom.install.state import list_manifests
@@ -97,7 +98,7 @@ def check_proxy_liveness(livez: dict[str, Any] | None, base_url: str) -> CheckRe
9798
return CheckResult(
9899
name="proxy",
99100
status=PASS,
100-
summary=f"running at {base_url} ({uptime_text}, v{version})",
101+
summary=f"running at {base_url} ({uptime_text}, {format_version_label(version)})",
101102
)
102103

103104

@@ -112,14 +113,26 @@ def check_version_drift(livez: dict[str, Any] | None, installed: str) -> CheckRe
112113
status=WARN,
113114
summary=f"cannot compare versions (proxy {running}, installed {installed})",
114115
)
115-
if running != installed:
116+
running_release = normalize_release_version(running)
117+
installed_release = normalize_release_version(installed)
118+
if running_release is None or installed_release is None:
119+
return CheckResult(
120+
name="version",
121+
status=SKIP,
122+
summary=f"source/non-release version label (proxy {running}, installed {installed})",
123+
)
124+
if running_release != installed_release:
116125
return CheckResult(
117126
name="version",
118127
status=WARN,
119128
summary=f"version drift: proxy {running}, installed {installed}",
120129
hint="restart the proxy to pick up new code: headroom proxy",
121130
)
122-
return CheckResult(name="version", status=PASS, summary=f"proxy matches installed v{installed}")
131+
return CheckResult(
132+
name="version",
133+
status=PASS,
134+
summary=f"proxy matches installed {format_version_label(installed)}",
135+
)
123136

124137

125138
def check_claude_routing(settings_path: Path, port: int) -> CheckResult:
@@ -398,7 +411,9 @@ def _render(checks: list[CheckResult], port: int, installed: str) -> None:
398411
from rich.table import Table
399412

400413
console = Console()
401-
console.print(f"[bold]Headroom Doctor[/bold] [dim]v{installed} · port {port}[/dim]\n")
414+
console.print(
415+
f"[bold]Headroom Doctor[/bold] [dim]{format_version_label(installed)} · port {port}[/dim]\n"
416+
)
402417
table = Table(show_header=True, header_style="bold")
403418
table.add_column("check")
404419
table.add_column("status")

‎headroom/cli/wrap.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545

4646
from headroom import fsutil
4747
from headroom._version import __version__ as _HEADROOM_VERSION
48+
from headroom._version import normalize_release_version as _normalize_release_version
4849
from headroom.agent_savings import (
4950
apply_agent_savings_env_defaults,
5051
)
@@ -2531,11 +2532,12 @@ def _proxy_version(payload: dict[str, Any] | None) -> str | None:
25312532
def _proxy_needs_version_restart(payload: dict[str, Any] | None) -> bool:
25322533
"""Return True when a running Headroom proxy uses a different package version."""
25332534
running_version = _proxy_version(payload)
2535+
running_release = _normalize_release_version(running_version)
2536+
current_release = _normalize_release_version(_HEADROOM_VERSION)
25342537
return (
2535-
running_version is not None
2536-
and running_version != "unknown"
2537-
and _HEADROOM_VERSION != "unknown"
2538-
and running_version != _HEADROOM_VERSION
2538+
running_release is not None
2539+
and current_release is not None
2540+
and running_release != current_release
25392541
)
25402542

25412543

‎headroom/dashboard/templates/dashboard.html‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
<header class="border-b border-border px-6 py-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
110110
<div class="flex items-center gap-4">
111111
<h1 class="text-xl font-semibold tracking-tight">HEADROOM</h1>
112-
<span class="text-xs text-gray-500 font-mono" x-text="'v' + version"></span>
112+
<span class="text-xs text-gray-500 font-mono" x-text="formatVersion(version)"></span>
113113
</div>
114114
<div class="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
115115
<div class="inline-flex rounded-lg border border-border bg-surface p-1">
@@ -1795,7 +1795,7 @@ <h1 class="text-xl font-semibold tracking-tight">HEADROOM</h1>
17951795
stats: {},
17961796
historyStats: {},
17971797
healthy: true,
1798-
version: '0.3.0',
1798+
version: 'loading',
17991799
lastUpdate: 'never',
18001800
viewMode: 'session',
18011801
historyGranularity: 'daily',
@@ -1862,6 +1862,12 @@ <h1 class="text-xl font-semibold tracking-tight">HEADROOM</h1>
18621862
}
18631863
},
18641864

1865+
formatVersion(value) {
1866+
const label = String(value || 'unknown').trim();
1867+
if (label === 'loading' || label === 'unknown') return label;
1868+
return /^\d+\.\d+\.\d+$/.test(label) ? 'v' + label : label;
1869+
},
1870+
18651871
async fetchStats() {
18661872
try {
18671873
const [statsRes, healthRes] = await Promise.all([
@@ -1872,7 +1878,7 @@ <h1 class="text-xl font-semibold tracking-tight">HEADROOM</h1>
18721878
this.stats = await statsRes.json();
18731879
const health = await healthRes.json();
18741880
this.healthy = health.status === 'healthy';
1875-
this.version = health.version || '0.3.0';
1881+
this.version = health.version || 'unknown';
18761882
this.log_full_messages = this.stats.log_full_messages || false;
18771883

18781884
// Update history for sparklines

‎headroom/observability/metrics.py‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
import logging
66
import os
77
from dataclasses import dataclass, field
8-
from importlib.metadata import PackageNotFoundError
9-
from importlib.metadata import version as package_version
108
from threading import Lock
119
from typing import Any, Literal
1210

1311
from opentelemetry import metrics
1412
from opentelemetry.metrics import CallbackOptions, Observation
1513

14+
from headroom._version import get_version
15+
1616
logger = logging.getLogger(__name__)
1717

1818
MetricExporter = Literal["console", "otlp_http"]
@@ -28,10 +28,7 @@
2828

2929

3030
def _headroom_version() -> str:
31-
try:
32-
return package_version("headroom-ai")
33-
except PackageNotFoundError:
34-
return "unknown"
31+
return get_version()
3532

3633

3734
def _parse_bool(raw: str | None, default: bool = False) -> bool:

‎headroom/proxy/server.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1405,7 +1405,7 @@ async def startup(self):
14051405
self.http_client_h1 = (
14061406
self.http_client if not _http2 else httpx.AsyncClient(http2=False, **_client_kwargs)
14071407
)
1408-
logger.info("Headroom Proxy started")
1408+
logger.info("Headroom Proxy started (version %s)", __version__)
14091409
logger.info(f"Optimization: {'ENABLED' if self.config.optimize else 'DISABLED'}")
14101410
self.config.mode = normalize_proxy_mode(self.config.mode)
14111411
logger.info(f"Mode: {self.config.mode}")

‎tests/test_cli/test_wrap_persistent.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,22 @@ def test_ensure_proxy_restarts_idle_stale_ephemeral_proxy(monkeypatch) -> None:
290290
assert calls[1][0] == "start"
291291

292292

293+
def test_proxy_version_restart_ignores_non_release_source_labels(monkeypatch) -> None:
294+
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.29.0")
295+
assert wrap_cli._proxy_needs_version_restart({"version": "source-build+g6266a1d774b5"}) is False
296+
assert (
297+
wrap_cli._proxy_needs_version_restart({"version": "source-build+sha.abcdef123456"}) is False
298+
)
299+
assert wrap_cli._proxy_needs_version_restart({"version": "6266a1d"}) is False
300+
assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0+gabcdef0"}) is False
301+
302+
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "source-build+sha.abcdef123456")
303+
assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0"}) is False
304+
305+
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.29.1")
306+
assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0"}) is True
307+
308+
293309
def test_ensure_proxy_restarts_ephemeral_proxy_for_openai_api_url_mismatch(monkeypatch) -> None:
294310
calls: list[object] = []
295311
health = {

0 commit comments

Comments
 (0)