REUSE-WP-0019-T06: hub freshness monitoring, docs, close workplan
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:
parent
706f6c70fe
commit
f9d957a221
10 changed files with 328 additions and 23 deletions
|
|
@ -54,6 +54,26 @@ jobs:
|
||||||
working-directory: /tmp/repo
|
working-directory: /tmp/repo
|
||||||
run: reuse-surface stats || true
|
run: reuse-surface stats || true
|
||||||
|
|
||||||
|
- name: Hub freshness check (informational, REUSE-WP-0019-T06)
|
||||||
|
working-directory: /tmp/repo
|
||||||
|
env:
|
||||||
|
REUSE_SURFACE_URL: https://reuse.coulomb.social
|
||||||
|
run: |
|
||||||
|
reuse-surface stats --format json > /tmp/stats.json 2>/dev/null || true
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
hub = json.load(open('/tmp/stats.json')).get('hub', {})
|
||||||
|
except Exception:
|
||||||
|
hub = {}
|
||||||
|
if hub.get('stale_warning'):
|
||||||
|
print(f\"::warning::hub federated index is stale (composed_at={hub.get('composed_at')}, age_days={hub.get('age_days')}, threshold={hub.get('freshness_threshold_days')})\")
|
||||||
|
elif hub.get('configured'):
|
||||||
|
print(f\"hub federated index fresh (age_days={hub.get('age_days')})\")
|
||||||
|
else:
|
||||||
|
print('hub not reachable/configured in CI -- skipped')
|
||||||
|
" || true
|
||||||
|
|
||||||
- name: Workstation roster federation stats (informational)
|
- name: Workstation roster federation stats (informational)
|
||||||
working-directory: /tmp/repo
|
working-directory: /tmp/repo
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
4
SCOPE.md
4
SCOPE.md
|
|
@ -146,7 +146,9 @@ See `tools/README.md` for command reference.
|
||||||
- **Searchable catalog:** `docs/catalog/search.html`.
|
- **Searchable catalog:** `docs/catalog/search.html`.
|
||||||
- **Workplans:** REUSE-WP-0001 through REUSE-WP-0015 finished (archived);
|
- **Workplans:** REUSE-WP-0001 through REUSE-WP-0015 finished (archived);
|
||||||
**REUSE-WP-0017** capability coverage campaign finished (2026-07-07);
|
**REUSE-WP-0017** capability coverage campaign finished (2026-07-07);
|
||||||
**REUSE-WP-0018** plan-check consumption loop active next.
|
**REUSE-WP-0018** plan-check consumption loop finished (2026-07-07);
|
||||||
|
**REUSE-WP-0019** Forgejo automation and reuse telemetry finished
|
||||||
|
(2026-07-08, all six tasks T01–T06).
|
||||||
- **Assessment history:** `history/` — intent/scope assessments, rollout
|
- **Assessment history:** `history/` — intent/scope assessments, rollout
|
||||||
milestone, dedup plan, per-repo follow-up.
|
milestone, dedup plan, per-repo follow-up.
|
||||||
- **Self-assessed vector:** `D5 / A4 / C5 / R3` (see `docs/IntentScopeGapAnalysis.md`).
|
- **Self-assessed vector:** `D5 / A4 / C5 / R3` (see `docs/IntentScopeGapAnalysis.md`).
|
||||||
|
|
|
||||||
|
|
@ -241,3 +241,4 @@ See §4 and archived workplans `workplans/archived/`.
|
||||||
| 2026-07-07 | **REUSE-WP-0018** partially closed priority 29 (`plan-check` deterministic matching + State Hub capability-request bridge; LLM rerank and ecosystem rollout remain open) |
|
| 2026-07-07 | **REUSE-WP-0018** partially closed priority 29 (`plan-check` deterministic matching + State Hub capability-request bridge; LLM rerank and ecosystem rollout remain open) |
|
||||||
| 2026-07-07 | **REUSE-WP-0018-T05** closed following REUSE-WP-0017 completion (61 published capabilities); only T03 (LLM rerank) remains open under priority 29 |
|
| 2026-07-07 | **REUSE-WP-0018-T05** closed following REUSE-WP-0017 completion (61 published capabilities); only T03 (LLM rerank) remains open under priority 29 |
|
||||||
| 2026-07-07 | **REUSE-WP-0018-T03** closed once `llm-connect` came up locally; priority 29 fully closed (all six T01–T06 tasks shipped) |
|
| 2026-07-07 | **REUSE-WP-0018-T03** closed once `llm-connect` came up locally; priority 29 fully closed (all six T01–T06 tasks shipped) |
|
||||||
|
| 2026-07-08 | **REUSE-WP-0019** finished (all six T01–T06 tasks shipped): forge host abstraction + `migrate-host`, hub webhook/staleness tracking, this repo's own Forgejo migration + CI, reuse telemetry store/recording, telemetry aggregation into R-axis evidence, and freshness monitoring in `stats`/CI. Closes SCOPE "not possible yet" item *automatic hub refresh* |
|
||||||
|
|
@ -262,6 +262,32 @@ curl -fsS "$REUSE_SURFACE_URL/v1/federated" | jq '.capabilities | length'
|
||||||
Read endpoints are public; writes require `REUSE_SURFACE_TOKEN` (Bearer). API
|
Read endpoints are public; writes require `REUSE_SURFACE_TOKEN` (Bearer). API
|
||||||
spec: `specs/FederationHubAPI.md`.
|
spec: `specs/FederationHubAPI.md`.
|
||||||
|
|
||||||
|
### Automatic recompose and freshness (REUSE-WP-0019-T02/T03/T06)
|
||||||
|
|
||||||
|
The hub recomposes automatically rather than waiting for a manual
|
||||||
|
`reuse-surface federation compose --refresh`:
|
||||||
|
|
||||||
|
- **Forgejo org webhook** (primary): a single org-level webhook on `coulomb`
|
||||||
|
fires on every push, HMAC-signed. `POST /v1/webhooks/forgejo` verifies the
|
||||||
|
signature, checks whether the push touched `registry/indexes/`, and — only
|
||||||
|
if so — recomposes. It never parses pushed file content, only paths.
|
||||||
|
- **Scheduled fallback**: `.forgejo/workflows/recompose-fallback.yaml` in
|
||||||
|
this repo calls `POST /v1/federated/compose` (token-auth) every 6 hours,
|
||||||
|
in case a webhook delivery is ever missed.
|
||||||
|
- **Freshness visibility**: `GET /v1/federated` carries `composed_at`
|
||||||
|
(timestamp of the last *forced* recompose) and `stale` (set by the
|
||||||
|
webhook, cleared on the next successful recompose). `reuse-surface stats`
|
||||||
|
surfaces both plus an `age_days`/`stale_warning` computed against a
|
||||||
|
threshold (`REUSE_SURFACE_FRESHNESS_DAYS`, default 7 days) — this repo's
|
||||||
|
own CI runs an informational (non-failing) freshness check on every push.
|
||||||
|
|
||||||
|
Setting up the org webhook or rotating its secret is an operator action on
|
||||||
|
the Forgejo instance and the cluster Secret, not something `reuse-surface`
|
||||||
|
itself automates — see `railiance-apps/docs/reuse-surface-on-railiance01.md`
|
||||||
|
(the authoritative operator runbook) and `docs/deploy/reuse-kubernetes.md`
|
||||||
|
for the current secret custody chain (OpenBao → `ExternalSecret` →
|
||||||
|
`reuse-surface-env`).
|
||||||
|
|
||||||
### Hub vs local `sources.yaml`
|
### Hub vs local `sources.yaml`
|
||||||
|
|
||||||
| Workflow | When to use |
|
| Workflow | When to use |
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,16 @@ Companion to **RAILIANCE-WP-0007** (`railiance-apps` Helm release).
|
||||||
|
|
||||||
## Image
|
## Image
|
||||||
|
|
||||||
Repository: `gitea.coulomb.social/coulomb/reuse-surface` (Gitea org `coulomb`, repo `reuse-surface`).
|
This repo's own canonical remote migrated to Forgejo in REUSE-WP-0019-T03
|
||||||
|
(`origin` → `forgejo-remote:coulomb/reuse-surface.git`; the old Gitea copy
|
||||||
|
is kept read-only, not deleted). Currently deployed production image is
|
||||||
|
still `gitea.coulomb.social/coulomb/reuse-surface:e3ae22e` (built manually
|
||||||
|
before the migration, per RAILIANCE-WP-0007) — this doc's manual build
|
||||||
|
commands still target that registry since that's what's actually live.
|
||||||
|
The repo also builds `forgejo.coulomb.social/coulomb/reuse-surface:latest`
|
||||||
|
automatically on every push via `.forgejo/workflows/image.yaml`; switching
|
||||||
|
the deployed registry over is a deliberate follow-up, not done as part of
|
||||||
|
T06.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t gitea.coulomb.social/coulomb/reuse-surface:<tag> .
|
docker build -t gitea.coulomb.social/coulomb/reuse-surface:<tag> .
|
||||||
|
|
@ -103,13 +112,31 @@ cert-manager / companion operator logs.
|
||||||
|
|
||||||
### Image promotion checklist
|
### Image promotion checklist
|
||||||
|
|
||||||
1. Tag image from CI commit: `gitea.coulomb.social/coulomb/reuse-surface:<sha>`.
|
1. Tag image from CI commit. `.forgejo/workflows/image.yaml` already builds
|
||||||
2. Run `pytest -q` and `reuse-surface validate` on that commit.
|
and pushes `forgejo.coulomb.social/coulomb/reuse-surface:main-<short-sha>`
|
||||||
3. Update Helm values image tag in `railiance-apps`.
|
automatically on every push that touches `Dockerfile`/`reuse_surface/**`/
|
||||||
4. Deploy to Railiance01; verify `GET /health` and `GET /v1/repos`.
|
`schemas/**`/`pyproject.toml` — verify it's green rather than building
|
||||||
5. Smoke `reuse-surface hub list` and `GET /v1/federated` capability count.
|
by hand, unless promoting from the still-live `gitea.coulomb.social`
|
||||||
|
registry (current production posture).
|
||||||
|
2. Run `pytest -q` and `reuse-surface validate` on that commit (CI already
|
||||||
|
does this; re-verify locally if promoting outside CI).
|
||||||
|
3. Update Helm values image tag in `railiance-apps`
|
||||||
|
(`helm/reuse-surface-values.yaml`).
|
||||||
|
4. Deploy to Railiance01 (`make reuse-deploy`); verify `GET /v1/federated`
|
||||||
|
and `GET /v1/repos` (not `GET /health` — see the known ingress routing
|
||||||
|
issue below).
|
||||||
|
5. Smoke `reuse-surface hub list` and `GET /v1/federated` capability count;
|
||||||
|
check `reuse-surface stats` shows a fresh `composed_at` post-deploy.
|
||||||
6. Record image digest in workplan or progress log.
|
6. Record image digest in workplan or progress log.
|
||||||
|
|
||||||
|
**Known issue (found 2026-07-07, not fixed):** the public ingress's
|
||||||
|
exact-path `/health` rule 404s (shadowed by the catch-all `/` rule to the
|
||||||
|
landing page) — confirmed ingress-layer only via direct port-forward and
|
||||||
|
the Deployment's own passing readiness/liveness probes. Use
|
||||||
|
`GET /v1/repos` or `GET /v1/federated` for external verification instead.
|
||||||
|
Flagged to `railiance-apps`; not this repo's fix to make (shared ingress
|
||||||
|
template).
|
||||||
|
|
||||||
### SQLite vs Postgres (cnpg) — decision criteria
|
### SQLite vs Postgres (cnpg) — decision criteria
|
||||||
|
|
||||||
Stay on SQLite while:
|
Stay on SQLite while:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
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]:
|
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}")
|
return _request("GET", f"{service_base_url(base_url)}/v1/repos/{repo}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
DEFAULT_FRESHNESS_DAYS = 7
|
||||||
|
|
||||||
from reuse_surface import hub_client
|
from reuse_surface import hub_client
|
||||||
from reuse_surface.registry import (
|
from reuse_surface.registry import (
|
||||||
LEVEL_ORDERS,
|
LEVEL_ORDERS,
|
||||||
|
|
@ -147,11 +151,30 @@ def level_at_least_reliability(current: str, minimum: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def _hub_configured() -> bool:
|
def _hub_configured() -> bool:
|
||||||
import os
|
|
||||||
|
|
||||||
return bool(os.environ.get("REUSE_SURFACE_URL"))
|
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]:
|
def _hub_summary(hub_url: str | None) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
status, payload = hub_client.hub_list(hub_url)
|
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:
|
if status != 200:
|
||||||
return {"configured": True, "status": status, "error": payload}
|
return {"configured": True, "status": status, "error": payload}
|
||||||
repos = payload.get("repos", [])
|
repos = payload.get("repos", [])
|
||||||
return {
|
summary: dict[str, Any] = {
|
||||||
"configured": True,
|
"configured": True,
|
||||||
"registration_count": payload.get("count", len(repos)),
|
"registration_count": payload.get("count", len(repos)),
|
||||||
"enabled_count": sum(1 for repo in repos if repo.get("enabled", True)),
|
"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:
|
def _default_raw_url(repo_root: Path) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -250,6 +290,14 @@ def format_stats_markdown(stats: dict[str, Any]) -> str:
|
||||||
)
|
)
|
||||||
elif "error" in hub:
|
elif "error" in hub:
|
||||||
lines.append(f"- hub error: {hub['error']}")
|
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("")
|
lines.append("")
|
||||||
|
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,141 @@ def test_collect_roster_stats_federation_ready():
|
||||||
assert "federation_readiness" in stats
|
assert "federation_readiness" in stats
|
||||||
text = format_roster_stats_markdown(stats)
|
text = format_roster_stats_markdown(stats)
|
||||||
assert "publish pass ratio" in text
|
assert "publish pass ratio" in text
|
||||||
|
|
||||||
|
# --- T06: hub freshness (composed_at age + stale flag) ---
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from reuse_surface.stats import _composed_at_age_days, _freshness_days, _hub_summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_summary_includes_freshness_fields(monkeypatch):
|
||||||
|
monkeypatch.setenv("REUSE_SURFACE_URL", "http://example.invalid")
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"reuse_surface.hub_client.hub_list",
|
||||||
|
lambda base_url=None: (200, {"count": 3, "repos": [{"enabled": True}] * 3}),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"reuse_surface.hub_client.hub_federated",
|
||||||
|
lambda base_url=None: (200, {"composed_at": now, "stale": False}),
|
||||||
|
)
|
||||||
|
summary = _hub_summary(None)
|
||||||
|
assert summary["composed_at"] == now
|
||||||
|
assert summary["stale"] is False
|
||||||
|
assert summary["age_days"] is not None
|
||||||
|
assert summary["age_days"] < 1
|
||||||
|
assert summary["stale_warning"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_summary_stale_warning_from_age(monkeypatch):
|
||||||
|
monkeypatch.setenv("REUSE_SURFACE_URL", "http://example.invalid")
|
||||||
|
old = (datetime.now(timezone.utc) - timedelta(days=10)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"reuse_surface.hub_client.hub_list",
|
||||||
|
lambda base_url=None: (200, {"count": 1, "repos": [{"enabled": True}]}),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"reuse_surface.hub_client.hub_federated",
|
||||||
|
lambda base_url=None: (200, {"composed_at": old, "stale": False}),
|
||||||
|
)
|
||||||
|
summary = _hub_summary(None)
|
||||||
|
assert summary["age_days"] > 7
|
||||||
|
assert summary["stale_warning"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_summary_stale_warning_from_flag(monkeypatch):
|
||||||
|
monkeypatch.setenv("REUSE_SURFACE_URL", "http://example.invalid")
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"reuse_surface.hub_client.hub_list",
|
||||||
|
lambda base_url=None: (200, {"count": 1, "repos": [{"enabled": True}]}),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"reuse_surface.hub_client.hub_federated",
|
||||||
|
lambda base_url=None: (200, {"composed_at": now, "stale": True}),
|
||||||
|
)
|
||||||
|
summary = _hub_summary(None)
|
||||||
|
assert summary["stale_warning"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_summary_handles_null_composed_at(monkeypatch):
|
||||||
|
monkeypatch.setenv("REUSE_SURFACE_URL", "http://example.invalid")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"reuse_surface.hub_client.hub_list",
|
||||||
|
lambda base_url=None: (200, {"count": 0, "repos": []}),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"reuse_surface.hub_client.hub_federated",
|
||||||
|
lambda base_url=None: (200, {"composed_at": None, "stale": False}),
|
||||||
|
)
|
||||||
|
summary = _hub_summary(None)
|
||||||
|
assert summary["age_days"] is None
|
||||||
|
assert summary["stale_warning"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_summary_degrades_when_federated_unreachable(monkeypatch):
|
||||||
|
monkeypatch.setenv("REUSE_SURFACE_URL", "http://example.invalid")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"reuse_surface.hub_client.hub_list",
|
||||||
|
lambda base_url=None: (200, {"count": 1, "repos": [{"enabled": True}]}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _raise(base_url=None):
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
raise urllib.error.URLError("no route")
|
||||||
|
|
||||||
|
monkeypatch.setattr("reuse_surface.hub_client.hub_federated", _raise)
|
||||||
|
summary = _hub_summary(None)
|
||||||
|
assert summary["configured"] is True
|
||||||
|
assert "composed_at" not in summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_composed_at_age_days_none_when_missing():
|
||||||
|
assert _composed_at_age_days(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_composed_at_age_days_none_when_malformed():
|
||||||
|
assert _composed_at_age_days("not-a-timestamp") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_freshness_days_default():
|
||||||
|
assert _freshness_days() == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_freshness_days_env_override(monkeypatch):
|
||||||
|
monkeypatch.setenv("REUSE_SURFACE_FRESHNESS_DAYS", "3")
|
||||||
|
assert _freshness_days() == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_freshness_days_env_invalid_falls_back(monkeypatch):
|
||||||
|
monkeypatch.setenv("REUSE_SURFACE_FRESHNESS_DAYS", "not-a-number")
|
||||||
|
assert _freshness_days() == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_stats_markdown_shows_stale_warning():
|
||||||
|
from reuse_surface.stats import format_stats_markdown
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"repo_root": "/tmp/x",
|
||||||
|
"capability_count": 0,
|
||||||
|
"registry_present": True,
|
||||||
|
"index_present": True,
|
||||||
|
"sources_present": True,
|
||||||
|
"reliability": {"r0_r2": 0, "r3_plus": 0},
|
||||||
|
"histograms": {},
|
||||||
|
"vector_drift": [],
|
||||||
|
"hub": {
|
||||||
|
"configured": True,
|
||||||
|
"registration_count": 1,
|
||||||
|
"enabled_count": 1,
|
||||||
|
"composed_at": "2020-01-01T00:00:00Z",
|
||||||
|
"stale": False,
|
||||||
|
"age_days": 100.0,
|
||||||
|
"freshness_threshold_days": 7,
|
||||||
|
"stale_warning": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
text = format_stats_markdown(stats)
|
||||||
|
assert "STALE" in text
|
||||||
|
|
|
||||||
|
|
@ -203,8 +203,16 @@ reuse-surface stats
|
||||||
reuse-surface stats --format json
|
reuse-surface stats --format json
|
||||||
reuse-surface stats --federation-ready --raw-url https://.../capabilities.yaml
|
reuse-surface stats --federation-ready --raw-url https://.../capabilities.yaml
|
||||||
reuse-surface stats --roster registry/federation/local-repo-roster.yaml --federation-ready
|
reuse-surface stats --roster registry/federation/local-repo-roster.yaml --federation-ready
|
||||||
|
export REUSE_SURFACE_URL=https://reuse.coulomb.social
|
||||||
|
reuse-surface stats # hub section gains composed_at/stale/age_days/stale_warning
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Hub freshness (REUSE-WP-0019-T06): when `REUSE_SURFACE_URL` is configured,
|
||||||
|
`stats`'s `hub` section reports the federated index's `composed_at`, `stale`
|
||||||
|
flag, `age_days`, and a computed `stale_warning` (age beyond
|
||||||
|
`REUSE_SURFACE_FRESHNESS_DAYS`, default 7, or the hub's own `stale` flag).
|
||||||
|
CI runs this as an informational (non-failing) check on every push.
|
||||||
|
|
||||||
### establish
|
### establish
|
||||||
|
|
||||||
Bootstrap or discover a capability registry in the current or target repo.
|
Bootstrap or discover a capability registry in the current or target repo.
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
||||||
title: "Forgejo-native federation automation and reuse telemetry"
|
title: "Forgejo-native federation automation and reuse telemetry"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: reuse-surface
|
repo: reuse-surface
|
||||||
status: active
|
status: finished
|
||||||
owner: claude-code
|
owner: claude-code
|
||||||
topic_slug: helix-forge
|
topic_slug: helix-forge
|
||||||
created: "2026-07-06"
|
created: "2026-07-06"
|
||||||
updated: "2026-07-07"
|
updated: "2026-07-08"
|
||||||
state_hub_workstream_id: "569be717-34f8-4039-bb26-497685f60159"
|
state_hub_workstream_id: "569be717-34f8-4039-bb26-497685f60159"
|
||||||
reuse_check: "new — dogfooded 2026-07-07 via reuse-surface plan-check against the full 61-capability federated index; no existing capability covers Forgejo webhook automation or reuse telemetry"
|
reuse_check: "new — dogfooded 2026-07-07 via reuse-surface plan-check against the full 61-capability federated index; no existing capability covers Forgejo webhook automation or reuse telemetry"
|
||||||
---
|
---
|
||||||
|
|
@ -372,18 +372,49 @@ follow-up rather than rushed in.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: REUSE-WP-0019-T06
|
id: REUSE-WP-0019-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "a9f44d45-91e2-4b43-909f-30a5f906cf3b"
|
state_hub_task_id: "a9f44d45-91e2-4b43-909f-30a5f906cf3b"
|
||||||
```
|
```
|
||||||
|
|
||||||
- `reuse-surface stats`: hub `composed_at` age + stale flag; CI informational
|
SCOPE.md's "automatic hub refresh" flip was already done incidentally in
|
||||||
check warns when the hub index is older than N days
|
T02/T03 (moved from "not possible yet" to "possible now"); no further
|
||||||
- `docs/RegistryFederation.md` + `docs/deploy/reuse-kubernetes.md`: webhook
|
change needed there.
|
||||||
setup, recompose endpoint, Forgejo token handling (route credentials per
|
|
||||||
credential-routing rules — no secrets in repo)
|
Implemented:
|
||||||
- `SCOPE.md`: flip "automatic hub refresh" to possible; update federation
|
|
||||||
posture
|
- `reuse_surface/hub_client.py`: new `hub_federated()` (`GET /v1/federated`)
|
||||||
|
- `reuse_surface/stats.py`: `_hub_summary()` now also reports
|
||||||
|
`composed_at`, `stale`, `age_days` (computed from `composed_at`),
|
||||||
|
`freshness_threshold_days` (`REUSE_SURFACE_FRESHNESS_DAYS` env, default
|
||||||
|
7), and a computed `stale_warning` (age beyond threshold OR the hub's own
|
||||||
|
`stale` flag). `format_stats_markdown` surfaces these with a `⚠ STALE`
|
||||||
|
marker when triggered; `format_stats_json` picks them up automatically
|
||||||
|
(no format-specific code needed there)
|
||||||
|
- `.forgejo/workflows/ci.yml`: new informational (non-failing) "Hub
|
||||||
|
freshness check" step against the live production hub — prints a
|
||||||
|
`::warning::` annotation when stale, never fails the build
|
||||||
|
- `docs/RegistryFederation.md`: new "Automatic recompose and freshness"
|
||||||
|
section tying together the webhook (T02/T03), scheduled fallback (T03),
|
||||||
|
and freshness visibility (T06) into one coherent explanation, pointing
|
||||||
|
at the authoritative operator runbook (`railiance-apps`) for actual
|
||||||
|
secret/webhook setup rather than duplicating operational steps here
|
||||||
|
- `docs/deploy/reuse-kubernetes.md`: image section updated to reflect the
|
||||||
|
T03 Forgejo migration (repo canonical remote moved; production image
|
||||||
|
still built from the pre-migration Gitea registry, deliberately not
|
||||||
|
switched over in this task); image promotion checklist updated for the
|
||||||
|
now-automated `.forgejo/workflows/image.yaml` build and the known
|
||||||
|
`/health` ingress bug (use `/v1/repos`/`/v1/federated` for verification
|
||||||
|
instead)
|
||||||
|
- 14 new pytest cases (`test_stats.py`); 173 total pass
|
||||||
|
- **Live-verified** against the real production hub: `reuse-surface stats`
|
||||||
|
correctly showed `composed_at`/`age_days` for the actual federated index
|
||||||
|
(0.08 days old, no stale warning); separately discovered and confirmed
|
||||||
|
(via a live signed webhook test) that an external secrets-management
|
||||||
|
change — `reuse-surface-env` is now ExternalSecret-managed from OpenBao
|
||||||
|
(`railiance-apps` commit `706f6c7`, found while updating these same
|
||||||
|
docs) — did **not** break the T02/T03 webhook: the synced value still
|
||||||
|
matches what the hub actually uses
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -394,7 +425,7 @@ state_hub_task_id: "a9f44d45-91e2-4b43-909f-30a5f906cf3b"
|
||||||
- [x] This repo's CI runs on Forgejo Actions (`.forgejo/workflows/`) (T03, 2026-07-07 — `ci.yml`/`ci-smoke.yaml`/`image.yaml` all verified green on the live push)
|
- [x] This repo's CI runs on Forgejo Actions (`.forgejo/workflows/`) (T03, 2026-07-07 — `ci.yml`/`ci-smoke.yaml`/`image.yaml` all verified green on the live push)
|
||||||
- [x] Reuse events recordable via hub API and CLI (T04, 2026-07-08 — live-verified); `report reuse` aggregation done in T05
|
- [x] Reuse events recordable via hub API and CLI (T04, 2026-07-08 — live-verified); `report reuse` aggregation done in T05
|
||||||
- [x] R-axis evidence rules for observed reuse documented in the maturity standard (T05, 2026-07-08 — `specs/CapabilityMaturityStandard.md` §8.9)
|
- [x] R-axis evidence rules for observed reuse documented in the maturity standard (T05, 2026-07-08 — `specs/CapabilityMaturityStandard.md` §8.9)
|
||||||
- [x] Hub freshness visible (`composed_at`, stale flag) in API and stats (T02, 2026-07-07)
|
- [x] Hub freshness visible (`composed_at`, stale flag) in API and stats (T02 API, T06 `stats`/CI, 2026-07-07/08)
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue