0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
309 lines
9.9 KiB
Python
309 lines
9.9 KiB
Python
"""Best-effort "is a newer Headroom released?" check.
|
|
|
|
This module is intentionally dependency-light (stdlib + ``packaging`` only —
|
|
``httpx`` lives in the ``[proxy]`` extra and must not be required by the base
|
|
CLI). It mirrors the telemetry beacon contract: opt-out, cached, fire-and-
|
|
forget, and it must never raise into a caller or block startup.
|
|
|
|
Two halves, deliberately split so a background thread never races stdout:
|
|
|
|
* :func:`maybe_check_async` performs the (rate-limited) network probe on a
|
|
daemon thread and writes the result to a cache file. It prints nothing.
|
|
* :func:`format_update_notice` reads *only* the cache and returns a one-line
|
|
notice string (or ``None``). Callers own the rendering.
|
|
|
|
Opt out with ``HEADROOM_UPDATE_CHECK=off``. Also skipped in ``--stateless``
|
|
mode, in CI, inside Docker, and from a git checkout (developers manage their
|
|
own tree).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PACKAGE_NAME = "headroom-ai"
|
|
_PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
|
|
_CACHE_FILE = "update_check.json"
|
|
|
|
# Probe PyPI at most once per day.
|
|
_CHECK_TTL_SECONDS = 86_400
|
|
|
|
_OFF_VALUES = frozenset(("off", "false", "0", "no", "disable", "disabled"))
|
|
_TRUE_VALUES = frozenset(("on", "true", "1", "yes", "enable", "enabled"))
|
|
|
|
|
|
def _env_off(name: str, default: str = "on") -> bool:
|
|
"""Return True when env var ``name`` is set to a falsey/off value."""
|
|
return os.environ.get(name, default).strip().lower() in _OFF_VALUES
|
|
|
|
|
|
def _env_on(name: str) -> bool:
|
|
"""Return True when env var ``name`` is set to a truthy/on value."""
|
|
return os.environ.get(name, "").strip().lower() in _TRUE_VALUES
|
|
|
|
|
|
def is_update_check_enabled() -> bool:
|
|
"""Whether the update check / banner should run at all.
|
|
|
|
Disabled by ``HEADROOM_UPDATE_CHECK=off``, offline mode
|
|
(``HEADROOM_OFFLINE``), stateless mode
|
|
(``HEADROOM_STATELESS=true``/``1``/``yes``/``on``, matching the proxy's own
|
|
parsing), or any CI environment (``CI`` set).
|
|
"""
|
|
from headroom.offline import is_offline
|
|
|
|
if is_offline():
|
|
return False
|
|
if _env_off("HEADROOM_UPDATE_CHECK"):
|
|
return False
|
|
if _env_on("HEADROOM_STATELESS"):
|
|
return False
|
|
if os.environ.get("CI", "").strip():
|
|
return False
|
|
return True
|
|
|
|
|
|
def _is_source_checkout() -> bool:
|
|
"""True when running from a git checkout (developers manage their tree)."""
|
|
try:
|
|
from headroom._version import _source_root
|
|
|
|
return _source_root() is not None
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _in_docker() -> bool:
|
|
"""Best-effort container detection — image rebuilds, not self-update."""
|
|
try:
|
|
return Path("/.dockerenv").exists() or bool(
|
|
os.environ.get("HEADROOM_IN_DOCKER", "").strip()
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def installed_version() -> str | None:
|
|
"""Return the *installed-distribution* version, or None.
|
|
|
|
Deliberately uses ``importlib.metadata`` rather than
|
|
``headroom._version.get_version()`` — the latter computes a synthetic
|
|
version from git history in a checkout, which would produce a meaningless
|
|
comparison against PyPI.
|
|
"""
|
|
try:
|
|
from importlib.metadata import PackageNotFoundError, version
|
|
|
|
try:
|
|
return version(PACKAGE_NAME)
|
|
except PackageNotFoundError:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _cache_path() -> Path:
|
|
from headroom.paths import workspace_dir
|
|
|
|
return workspace_dir() / _CACHE_FILE
|
|
|
|
|
|
def read_cache() -> dict[str, Any] | None:
|
|
"""Return the cached check result, or None if missing/unreadable."""
|
|
try:
|
|
path = _cache_path()
|
|
if not path.exists():
|
|
return None
|
|
with path.open("r", encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
return data if isinstance(data, dict) else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def write_cache(latest_version: str, *, now: float | None = None) -> None:
|
|
"""Persist the latest-known version + check timestamp. Never raises."""
|
|
try:
|
|
from headroom.paths import ensure_workspace_dir
|
|
|
|
ensure_workspace_dir()
|
|
payload = {
|
|
"last_check": now if now is not None else time.time(),
|
|
"latest_version": latest_version,
|
|
}
|
|
path = _cache_path()
|
|
tmp = path.with_suffix(".json.tmp")
|
|
with tmp.open("w", encoding="utf-8") as fh:
|
|
json.dump(payload, fh)
|
|
tmp.replace(path)
|
|
except Exception:
|
|
logger.debug("update_check: failed to write cache", exc_info=True)
|
|
|
|
|
|
def _select_latest(data: dict[str, Any], *, allow_pre: bool) -> str | None:
|
|
"""Pick the newest non-yanked release from a PyPI JSON payload."""
|
|
from packaging.version import InvalidVersion, Version
|
|
|
|
releases = data.get("releases")
|
|
candidates: list[Version] = []
|
|
if isinstance(releases, dict):
|
|
for ver_str, files in releases.items():
|
|
# Skip releases whose every artifact is yanked.
|
|
if (
|
|
isinstance(files, list)
|
|
and files
|
|
and all(isinstance(f, dict) and f.get("yanked") for f in files)
|
|
):
|
|
continue
|
|
try:
|
|
ver = Version(ver_str)
|
|
except InvalidVersion:
|
|
continue
|
|
if ver.is_prerelease and not allow_pre:
|
|
continue
|
|
candidates.append(ver)
|
|
if candidates:
|
|
return str(max(candidates))
|
|
|
|
# Fallback to info.version when releases is absent/empty.
|
|
info = data.get("info")
|
|
if isinstance(info, dict):
|
|
ver_str = info.get("version")
|
|
if isinstance(ver_str, str):
|
|
try:
|
|
ver = Version(ver_str)
|
|
except InvalidVersion:
|
|
return None
|
|
if ver.is_prerelease and not allow_pre:
|
|
return None
|
|
return str(ver)
|
|
return None
|
|
|
|
|
|
def fetch_latest_version(*, allow_pre: bool = False, timeout: float = 4.0) -> str | None:
|
|
"""Query the PyPI JSON API for the latest release. Returns None on any error.
|
|
|
|
Uses ``urllib`` (stdlib) so the base CLI install needs no HTTP dependency.
|
|
"""
|
|
try:
|
|
req = urllib.request.Request(
|
|
_PYPI_JSON_URL,
|
|
headers={"Accept": "application/json", "User-Agent": "headroom-update-check"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 — fixed https URL
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
return _select_latest(data, allow_pre=allow_pre)
|
|
except Exception:
|
|
logger.debug("update_check: PyPI fetch failed", exc_info=True)
|
|
return None
|
|
|
|
|
|
def should_check(now: float | None = None) -> bool:
|
|
"""True when the cache is stale (older than the TTL) or absent."""
|
|
cache = read_cache()
|
|
if not cache:
|
|
return True
|
|
last = cache.get("last_check")
|
|
if not isinstance(last, (int, float)):
|
|
return True
|
|
now = now if now is not None else time.time()
|
|
return (now - last) >= _CHECK_TTL_SECONDS
|
|
|
|
|
|
def run_check(*, allow_pre: bool = False, now: float | None = None) -> str | None:
|
|
"""Probe PyPI and update the cache. Returns the latest version or None.
|
|
|
|
Synchronous — used directly by ``headroom update`` and indirectly by
|
|
:func:`maybe_check_async`. Honors the enable gate.
|
|
"""
|
|
if not is_update_check_enabled():
|
|
return None
|
|
latest = fetch_latest_version(allow_pre=allow_pre)
|
|
if latest:
|
|
write_cache(latest, now=now)
|
|
return latest
|
|
|
|
|
|
def maybe_check_async() -> threading.Thread | None:
|
|
"""Fire a rate-limited background check on a daemon thread.
|
|
|
|
Returns the spawned thread (for tests) or None when the check is gated off,
|
|
suppressed (checkout/Docker), or still within the TTL window. Never blocks
|
|
and never raises.
|
|
"""
|
|
try:
|
|
if not is_update_check_enabled() or _is_source_checkout() or _in_docker():
|
|
return None
|
|
if not should_check():
|
|
return None
|
|
|
|
def _worker() -> None:
|
|
try:
|
|
run_check()
|
|
except Exception:
|
|
logger.debug("update_check: background check crashed", exc_info=True)
|
|
|
|
thread = threading.Thread(target=_worker, name="headroom-update-check", daemon=True)
|
|
thread.start()
|
|
return thread
|
|
except Exception:
|
|
logger.debug("update_check: maybe_check_async crashed", exc_info=True)
|
|
return None
|
|
|
|
|
|
def format_update_notice() -> str | None:
|
|
"""Return a one-line "update available" notice, or None.
|
|
|
|
Reads only the cache (no network). Returns None when the check is disabled,
|
|
in a checkout/Docker, when the installed version is unknown, or when already
|
|
up to date.
|
|
"""
|
|
try:
|
|
if not is_update_check_enabled() or _is_source_checkout() or _in_docker():
|
|
return None
|
|
cache = read_cache()
|
|
if not cache:
|
|
return None
|
|
latest = cache.get("latest_version")
|
|
current = installed_version()
|
|
if not isinstance(latest, str) or not current:
|
|
return None
|
|
|
|
from packaging.version import InvalidVersion, Version
|
|
|
|
try:
|
|
if Version(latest) <= Version(current):
|
|
return None
|
|
except InvalidVersion:
|
|
return None
|
|
|
|
# ASCII-only: some Windows consoles can't encode unicode and would raise
|
|
# UnicodeEncodeError at the echo site, breaking a "best-effort" banner.
|
|
return f"Update available: Headroom {latest} (you have {current}) - run: headroom update"
|
|
except Exception:
|
|
logger.debug("update_check: format_update_notice crashed", exc_info=True)
|
|
return None
|
|
|
|
|
|
__all__ = [
|
|
"PACKAGE_NAME",
|
|
"fetch_latest_version",
|
|
"format_update_notice",
|
|
"installed_version",
|
|
"is_update_check_enabled",
|
|
"maybe_check_async",
|
|
"read_cache",
|
|
"run_check",
|
|
"should_check",
|
|
"write_cache",
|
|
]
|