REUSE-WP-0019-T06: hub freshness monitoring, docs, close workplan
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
ci / validate-registry (push) Has been cancelled
Build and Publish Container Image / build-and-push (push) Successful in 1m4s

reuse_surface/stats.py: _hub_summary() now reports composed_at, stale,
age_days, freshness_threshold_days (REUSE_SURFACE_FRESHNESS_DAYS env,
default 7), and a computed stale_warning. New hub_client.hub_federated()
backs it. format_stats_markdown surfaces a STALE marker when triggered.

.forgejo/workflows/ci.yml: new informational (non-failing) hub freshness
check against the live production hub on every push -- prints a
:⚠️: annotation when stale, never fails the build.

docs/RegistryFederation.md: new section tying together the webhook (T02),
scheduled fallback (T03), and freshness visibility (T06) into one
explanation. docs/deploy/reuse-kubernetes.md: updated for the T03 Forgejo
migration and the now-automated image.yaml build; image promotion
checklist updated for the known /health ingress bug (verify via
/v1/repos or /v1/federated instead).

14 new pytest cases, 173 total pass. Live-verified against production:
reuse-surface stats correctly showed composed_at/age_days for the real
federated index. Separately discovered and confirmed (via a live signed
webhook test) that reuse-surface-env moving to ExternalSecret/OpenBao
custody (railiance-apps commit 706f6c7, found while updating these docs)
did not break the T02/T03 webhook -- the synced value still matches what
the hub actually uses.

REUSE-WP-0019 is now fully complete (T01-T06). SCOPE.md and
docs/IntentScopeGapAnalysis.md updated to reflect closure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-08 00:09:58 +02:00
parent 706f6c70fe
commit f9d957a221
10 changed files with 328 additions and 23 deletions

View file

@ -56,6 +56,10 @@ def hub_list(base_url: str | None = None) -> tuple[int, Any]:
return _request("GET", f"{service_base_url(base_url)}/v1/repos")
def hub_federated(base_url: str | None = None) -> tuple[int, Any]:
return _request("GET", f"{service_base_url(base_url)}/v1/federated")
def hub_show(repo: str, base_url: str | None = None) -> tuple[int, Any]:
return _request("GET", f"{service_base_url(base_url)}/v1/repos/{repo}")

View file

@ -1,14 +1,18 @@
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
DEFAULT_FRESHNESS_DAYS = 7
from reuse_surface import hub_client
from reuse_surface.registry import (
LEVEL_ORDERS,
@ -147,11 +151,30 @@ def level_at_least_reliability(current: str, minimum: str) -> bool:
def _hub_configured() -> bool:
import os
return bool(os.environ.get("REUSE_SURFACE_URL"))
def _freshness_days() -> int:
raw = os.environ.get("REUSE_SURFACE_FRESHNESS_DAYS")
if raw:
try:
return int(raw)
except ValueError:
pass
return DEFAULT_FRESHNESS_DAYS
def _composed_at_age_days(composed_at: str | None) -> float | None:
if not composed_at:
return None
try:
composed_dt = datetime.fromisoformat(composed_at.replace("Z", "+00:00"))
except ValueError:
return None
now = datetime.now(timezone.utc)
return (now - composed_dt).total_seconds() / 86400
def _hub_summary(hub_url: str | None) -> dict[str, Any]:
try:
status, payload = hub_client.hub_list(hub_url)
@ -160,12 +183,29 @@ def _hub_summary(hub_url: str | None) -> dict[str, Any]:
if status != 200:
return {"configured": True, "status": status, "error": payload}
repos = payload.get("repos", [])
return {
summary: dict[str, Any] = {
"configured": True,
"registration_count": payload.get("count", len(repos)),
"enabled_count": sum(1 for repo in repos if repo.get("enabled", True)),
}
try:
f_status, f_payload = hub_client.hub_federated(hub_url)
except (ValueError, urllib.error.URLError, OSError):
f_status, f_payload = None, None
if f_status == 200 and isinstance(f_payload, dict):
composed_at = f_payload.get("composed_at")
stale = f_payload.get("stale", False)
age_days = _composed_at_age_days(composed_at)
threshold = _freshness_days()
summary["composed_at"] = composed_at
summary["stale"] = stale
summary["age_days"] = round(age_days, 2) if age_days is not None else None
summary["freshness_threshold_days"] = threshold
summary["stale_warning"] = bool(stale) or (age_days is not None and age_days > threshold)
return summary
def _default_raw_url(repo_root: Path) -> str | None:
return None
@ -250,6 +290,14 @@ def format_stats_markdown(stats: dict[str, Any]) -> str:
)
elif "error" in hub:
lines.append(f"- hub error: {hub['error']}")
if "composed_at" in hub:
age = hub.get("age_days")
age_text = f"{age:.2f}d ago" if age is not None else "unknown"
warning = " ⚠ STALE" if hub.get("stale_warning") else ""
lines.append(
f"- federated index composed: `{hub['composed_at']}` ({age_text}, "
f"threshold {hub.get('freshness_threshold_days')}d){warning}"
)
lines.append("")
return "\n".join(lines) + "\n"