mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(docker): report source build version (#1862)
## Description Closes #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.
This commit is contained in:
parent
5af5e22862
commit
38074888ac
15 changed files with 316 additions and 22 deletions
|
|
@ -1,5 +1,9 @@
|
|||
# VCS
|
||||
.git
|
||||
.git/*
|
||||
!.git/HEAD
|
||||
!.git/packed-refs
|
||||
!.git/refs/
|
||||
!.git/refs/**
|
||||
.github
|
||||
.github/*
|
||||
!.github/plugin/
|
||||
|
|
|
|||
81
Dockerfile
81
Dockerfile
|
|
@ -7,6 +7,8 @@ ARG PYTHON_SITE_PACKAGES=/usr/local/lib/python${PYTHON_VERSION}/site-packages
|
|||
FROM python:${PYTHON_VERSION}-slim AS builder
|
||||
|
||||
ARG UV_VERSION
|
||||
ARG PYTHON_SITE_PACKAGES
|
||||
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 +53,85 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||
--mount=type=cache,target=/build/target \
|
||||
uv pip install --system ".[${HEADROOM_EXTRAS}]"
|
||||
|
||||
RUN --mount=type=bind,source=.,target=/context,readonly \
|
||||
HEADROOM_BUILD_VERSION="${HEADROOM_BUILD_VERSION}" PYTHON_SITE_PACKAGES="${PYTHON_SITE_PACKAGES}" python - <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def git_revision(context: Path) -> str | None:
|
||||
git_dir = context / ".git"
|
||||
head_path = git_dir / "HEAD"
|
||||
if not head_path.exists():
|
||||
return None
|
||||
head = head_path.read_text(encoding="utf-8").strip()
|
||||
if head.startswith("ref: "):
|
||||
ref_name = head.removeprefix("ref: ").strip()
|
||||
ref_path = git_dir / ref_name
|
||||
if ref_path.exists():
|
||||
head = ref_path.read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
packed_refs = git_dir / "packed-refs"
|
||||
if not packed_refs.exists():
|
||||
return None
|
||||
for line in packed_refs.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("#") or not line.strip():
|
||||
continue
|
||||
sha, _, name = line.partition(" ")
|
||||
if name.strip() == ref_name:
|
||||
head = sha
|
||||
break
|
||||
else:
|
||||
return None
|
||||
return head[:12] if len(head) >= 7 and all(c in "0123456789abcdef" for c in head.lower()) else None
|
||||
|
||||
|
||||
def source_digest(root: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
inputs = (
|
||||
"pyproject.toml",
|
||||
"uv.lock",
|
||||
"README.md",
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
"rust-toolchain.toml",
|
||||
"crates",
|
||||
"headroom",
|
||||
)
|
||||
for name in inputs:
|
||||
path = root / name
|
||||
if not path.exists():
|
||||
continue
|
||||
files = [path] if path.is_file() else sorted(p for p in path.rglob("*") if p.is_file())
|
||||
for file in files:
|
||||
digest.update(file.relative_to(root).as_posix().encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
digest.update(file.read_bytes())
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()[:12]
|
||||
|
||||
|
||||
build_version = os.environ["HEADROOM_BUILD_VERSION"].strip()
|
||||
if not build_version:
|
||||
print("no Headroom build version override provided; using installed package metadata")
|
||||
raise SystemExit(0)
|
||||
if build_version == "source-build":
|
||||
revision = git_revision(Path("/context"))
|
||||
build_version = (
|
||||
f"source-build+g{revision}"
|
||||
if revision
|
||||
else f"source-build+sha256.{source_digest(Path('/build'))}"
|
||||
)
|
||||
|
||||
package_dir = Path(os.environ["PYTHON_SITE_PACKAGES"]) / "headroom"
|
||||
(package_dir / "_build_info.py").write_text(
|
||||
"BUILD_VERSION = " + repr(build_version) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("baked Headroom build version: " + build_version)
|
||||
PY
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,10 +2,63 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
UNKNOWN_VERSION = "unknown"
|
||||
VERSION_ENV_VARS = ("HEADROOM_VERSION", "HEADROOM_BUILD_VERSION")
|
||||
RELEASE_VERSION_RE = re.compile(r"^v?\d+\.\d+\.\d+$")
|
||||
|
||||
|
||||
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 is_release_version(value: object) -> bool:
|
||||
"""Return whether a value is a comparable release version."""
|
||||
cleaned = _clean_version(value)
|
||||
return bool(cleaned and RELEASE_VERSION_RE.fullmatch(cleaned))
|
||||
|
||||
|
||||
def normalize_release_version(value: object) -> str | None:
|
||||
"""Return a comparable release version without a display prefix."""
|
||||
cleaned = _clean_version(value)
|
||||
if cleaned is None or RELEASE_VERSION_RE.fullmatch(cleaned) is None:
|
||||
return None
|
||||
return cleaned[1:] if cleaned.startswith("v") else cleaned
|
||||
|
||||
|
||||
def format_version_label(value: object) -> str:
|
||||
"""Return a user-facing version label without prefixing source labels."""
|
||||
cleaned = _clean_version(value) or UNKNOWN_VERSION
|
||||
if is_release_version(cleaned) and not cleaned.startswith("v"):
|
||||
return f"v{cleaned}"
|
||||
return cleaned
|
||||
|
||||
|
||||
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 +99,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:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from typing import Any
|
|||
|
||||
import click
|
||||
|
||||
from headroom._version import format_version_label, normalize_release_version
|
||||
from headroom.install.health import probe_json
|
||||
from headroom.install.paths import claude_settings_path, codex_config_path
|
||||
from headroom.install.state import list_manifests
|
||||
|
|
@ -97,7 +98,7 @@ def check_proxy_liveness(livez: dict[str, Any] | None, base_url: str) -> CheckRe
|
|||
return CheckResult(
|
||||
name="proxy",
|
||||
status=PASS,
|
||||
summary=f"running at {base_url} ({uptime_text}, v{version})",
|
||||
summary=f"running at {base_url} ({uptime_text}, {format_version_label(version)})",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -112,14 +113,26 @@ def check_version_drift(livez: dict[str, Any] | None, installed: str) -> CheckRe
|
|||
status=WARN,
|
||||
summary=f"cannot compare versions (proxy {running}, installed {installed})",
|
||||
)
|
||||
if running != installed:
|
||||
running_release = normalize_release_version(running)
|
||||
installed_release = normalize_release_version(installed)
|
||||
if running_release is None or installed_release is None:
|
||||
return CheckResult(
|
||||
name="version",
|
||||
status=SKIP,
|
||||
summary=f"source/non-release version label (proxy {running}, installed {installed})",
|
||||
)
|
||||
if running_release != installed_release:
|
||||
return CheckResult(
|
||||
name="version",
|
||||
status=WARN,
|
||||
summary=f"version drift: proxy {running}, installed {installed}",
|
||||
hint="restart the proxy to pick up new code: headroom proxy",
|
||||
)
|
||||
return CheckResult(name="version", status=PASS, summary=f"proxy matches installed v{installed}")
|
||||
return CheckResult(
|
||||
name="version",
|
||||
status=PASS,
|
||||
summary=f"proxy matches installed {format_version_label(installed)}",
|
||||
)
|
||||
|
||||
|
||||
def check_claude_routing(settings_path: Path, port: int) -> CheckResult:
|
||||
|
|
@ -398,7 +411,9 @@ def _render(checks: list[CheckResult], port: int, installed: str) -> None:
|
|||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
console.print(f"[bold]Headroom Doctor[/bold] [dim]v{installed} · port {port}[/dim]\n")
|
||||
console.print(
|
||||
f"[bold]Headroom Doctor[/bold] [dim]{format_version_label(installed)} · port {port}[/dim]\n"
|
||||
)
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("check")
|
||||
table.add_column("status")
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import click
|
|||
|
||||
from headroom import fsutil
|
||||
from headroom._version import __version__ as _HEADROOM_VERSION
|
||||
from headroom._version import normalize_release_version as _normalize_release_version
|
||||
from headroom.agent_savings import (
|
||||
apply_agent_savings_env_defaults,
|
||||
)
|
||||
|
|
@ -2531,11 +2532,12 @@ def _proxy_version(payload: dict[str, Any] | None) -> str | None:
|
|||
def _proxy_needs_version_restart(payload: dict[str, Any] | None) -> bool:
|
||||
"""Return True when a running Headroom proxy uses a different package version."""
|
||||
running_version = _proxy_version(payload)
|
||||
running_release = _normalize_release_version(running_version)
|
||||
current_release = _normalize_release_version(_HEADROOM_VERSION)
|
||||
return (
|
||||
running_version is not None
|
||||
and running_version != "unknown"
|
||||
and _HEADROOM_VERSION != "unknown"
|
||||
and running_version != _HEADROOM_VERSION
|
||||
running_release is not None
|
||||
and current_release is not None
|
||||
and running_release != current_release
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -1795,7 +1795,7 @@
|
|||
stats: {},
|
||||
historyStats: {},
|
||||
healthy: true,
|
||||
version: '0.3.0',
|
||||
version: 'loading',
|
||||
lastUpdate: 'never',
|
||||
viewMode: 'session',
|
||||
historyGranularity: 'daily',
|
||||
|
|
@ -1862,6 +1862,12 @@
|
|||
}
|
||||
},
|
||||
|
||||
formatVersion(value) {
|
||||
const label = String(value || 'unknown').trim();
|
||||
if (label === 'loading' || label === 'unknown') return label;
|
||||
return /^\d+\.\d+\.\d+$/.test(label) ? 'v' + label : label;
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
try {
|
||||
const [statsRes, healthRes] = await Promise.all([
|
||||
|
|
@ -1872,7 +1878,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
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ from __future__ import annotations
|
|||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as package_version
|
||||
from threading import Lock
|
||||
from typing import Any, Literal
|
||||
|
||||
from opentelemetry import metrics
|
||||
from opentelemetry.metrics import CallbackOptions, Observation
|
||||
|
||||
from headroom._version import get_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MetricExporter = Literal["console", "otlp_http"]
|
||||
|
|
@ -28,10 +28,7 @@ _owned_metrics_config: OTelMetricsConfig | None = None
|
|||
|
||||
|
||||
def _headroom_version() -> str:
|
||||
try:
|
||||
return package_version("headroom-ai")
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
return get_version()
|
||||
|
||||
|
||||
def _parse_bool(raw: str | None, default: bool = False) -> bool:
|
||||
|
|
|
|||
|
|
@ -1405,7 +1405,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}")
|
||||
|
|
|
|||
|
|
@ -290,6 +290,22 @@ def test_ensure_proxy_restarts_idle_stale_ephemeral_proxy(monkeypatch) -> None:
|
|||
assert calls[1][0] == "start"
|
||||
|
||||
|
||||
def test_proxy_version_restart_ignores_non_release_source_labels(monkeypatch) -> None:
|
||||
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.29.0")
|
||||
assert wrap_cli._proxy_needs_version_restart({"version": "source-build+g6266a1d774b5"}) is False
|
||||
assert (
|
||||
wrap_cli._proxy_needs_version_restart({"version": "source-build+sha.abcdef123456"}) is False
|
||||
)
|
||||
assert wrap_cli._proxy_needs_version_restart({"version": "6266a1d"}) is False
|
||||
assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0+gabcdef0"}) is False
|
||||
|
||||
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "source-build+sha.abcdef123456")
|
||||
assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0"}) is False
|
||||
|
||||
monkeypatch.setattr(wrap_cli, "_HEADROOM_VERSION", "0.29.1")
|
||||
assert wrap_cli._proxy_needs_version_restart({"version": "0.29.0"}) is True
|
||||
|
||||
|
||||
def test_ensure_proxy_restarts_ephemeral_proxy_for_openai_api_url_mismatch(monkeypatch) -> None:
|
||||
calls: list[object] = []
|
||||
health = {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ class TestProxyLiveness:
|
|||
assert "v0.26.0" in result.summary
|
||||
assert "3d" in result.summary
|
||||
|
||||
def test_up_leaves_source_label_unprefixed(self):
|
||||
livez = {**LIVEZ_OK, "version": "source-build+sha.abcdef123456"}
|
||||
result = check_proxy_liveness(livez, "http://127.0.0.1:8787")
|
||||
assert result.status == PASS
|
||||
assert "source-build+sha.abcdef123456" in result.summary
|
||||
assert "vsource-build" not in result.summary
|
||||
|
||||
|
||||
class TestVersionDrift:
|
||||
def test_match_passes(self):
|
||||
|
|
@ -74,6 +81,21 @@ class TestVersionDrift:
|
|||
assert check_version_drift({"version": "unknown"}, "0.26.0").status == WARN
|
||||
assert check_version_drift(LIVEZ_OK, "unknown").status == WARN
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("running", "installed"),
|
||||
[
|
||||
("source-build+g6266a1d774b5", "0.26.0"),
|
||||
("source-build+sha.abcdef123456", "0.26.0"),
|
||||
("6266a1d", "0.26.0"),
|
||||
("0.26.0+gabcdef0", "0.26.0"),
|
||||
("0.26.0", "source-build+sha.abcdef123456"),
|
||||
],
|
||||
)
|
||||
def test_non_release_version_labels_skip_drift_comparison(self, running, installed):
|
||||
result = check_version_drift({"version": running}, installed)
|
||||
assert result.status == SKIP
|
||||
assert "drift" not in result.summary
|
||||
|
||||
|
||||
class TestClaudeRouting:
|
||||
def test_missing_file_warns(self, tmp_path):
|
||||
|
|
|
|||
|
|
@ -14,3 +14,21 @@ 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")
|
||||
dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
|
||||
|
||||
assert "HEADROOM_BUILD_VERSION: ${HEADROOM_BUILD_VERSION:-source-build}" in compose
|
||||
assert 'ARG HEADROOM_BUILD_VERSION=""' in dockerfile
|
||||
assert "ARG PYTHON_SITE_PACKAGES" in dockerfile
|
||||
assert "if not build_version:" in dockerfile
|
||||
assert "source-build+g{revision}" in dockerfile
|
||||
assert "source-build+sha256." in dockerfile
|
||||
assert "_build_info.py" in dockerfile
|
||||
assert "import headroom._version" not in dockerfile
|
||||
assert ".git/*" in dockerignore
|
||||
assert "!.git/HEAD" in dockerignore
|
||||
assert "!.git/refs/**" in dockerignore
|
||||
|
|
|
|||
|
|
@ -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,66 @@ 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_label_helpers_only_prefix_release_versions() -> None:
|
||||
assert version_module.is_release_version("0.29.0") is True
|
||||
assert version_module.is_release_version("v0.29.0") is True
|
||||
assert version_module.normalize_release_version("v0.29.0") == "0.29.0"
|
||||
assert version_module.is_release_version("source-build+g6266a1d774b5") is False
|
||||
assert version_module.is_release_version("source-build+sha.abcdef123456") is False
|
||||
assert version_module.is_release_version("6266a1d") is False
|
||||
assert version_module.is_release_version("0.29.0+gabcdef0") is False
|
||||
|
||||
assert version_module.format_version_label("0.29.0") == "v0.29.0"
|
||||
assert version_module.format_version_label("v0.29.0") == "v0.29.0"
|
||||
assert (
|
||||
version_module.format_version_label("source-build+sha.abcdef123456")
|
||||
== "source-build+sha.abcdef123456"
|
||||
)
|
||||
assert (
|
||||
version_module.format_version_label("source-build+g6266a1d774b5")
|
||||
== "source-build+g6266a1d774b5"
|
||||
)
|
||||
assert version_module.format_version_label("6266a1d") == "6266a1d"
|
||||
assert version_module.format_version_label(None) == version_module.UNKNOWN_VERSION
|
||||
|
||||
|
||||
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_observability_version_uses_runtime_version(monkeypatch) -> None:
|
||||
from headroom.observability import metrics as metrics_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
metrics_module,
|
||||
"get_version",
|
||||
lambda: "source-build+sha.abcdef123456",
|
||||
)
|
||||
|
||||
assert metrics_module._headroom_version() == "source-build+sha.abcdef123456"
|
||||
|
||||
|
||||
def test_version_prefers_source_tree_release_history() -> None:
|
||||
with (
|
||||
patch.object(version_module, "_source_root", return_value=Path(".")),
|
||||
|
|
|
|||
|
|
@ -591,6 +591,12 @@ 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+\\.\\d+\\.\\d+$/.test(label)" in html
|
||||
assert "return /^\\d/.test(value)" not 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue