diff --git a/CHANGELOG.md b/CHANGELOG.md
index 951cfdb..572cce2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is intentionally lightweight and human-readable. Group entries by rel
## Unreleased
+### Added
+
+- Added stronger update-alert metadata to `GET /api/update`, including update type, alert level, and recommended action for operators and dashboard consumers
+
## v0.6.0 - 2026-03-12
### Added
diff --git a/README.md b/README.md
index eefd4b2..af5c770 100644
--- a/README.md
+++ b/README.md
@@ -304,7 +304,7 @@ For image-capable providers, `image.policy_tags` can be used as lightweight pres
`GET /api/traces` returns recent enriched routing records from the metrics store, including requested model, modality, resolved client profile, client tag, decision reason, confidence, and attempt order.
-`GET /api/update` returns the cached release-check result for the running service, including the current version, latest known tag, update availability, and the release URL when GitHub lookups succeed.
+`GET /api/update` returns the cached release-check result for the running service, including the current version, latest known tag, update availability, update type (`patch`, `minor`, `major`), alert level, recommended action, and the release URL when GitHub lookups succeed.
## Model Aliases And Routing
diff --git a/docs/FOUNDRYGATE-ROADMAP.md b/docs/FOUNDRYGATE-ROADMAP.md
index 81904e1..1c58df5 100644
--- a/docs/FOUNDRYGATE-ROADMAP.md
+++ b/docs/FOUNDRYGATE-ROADMAP.md
@@ -210,6 +210,8 @@ Primary goals:
This release line is about day-2 operations rather than new routing concepts.
+The first small slice in this line is to turn `GET /api/update` from a plain boolean check into an operator-facing alert surface with update type, alert level, and recommended action.
+
### `v0.8.x`: many-provider and many-client onboarding
Primary goals:
diff --git a/foundrygate/main.py b/foundrygate/main.py
index eb12cda..e4432b0 100644
--- a/foundrygate/main.py
+++ b/foundrygate/main.py
@@ -1567,7 +1567,7 @@ def main():
Healthy Providers
${healthyProviders}/${providers.length}
${unhealthyProviders} unhealthy
Capability Coverage
${coverageEntries.length}
${coverageEntries.map(([name]) => name).slice(0,3).join(', ') || 'none'}
Top Modality
${esc(topModality)}
${modalityRows.length} modality groups
- Release Status
${esc(update.latest_version || update.current_version || 'n/a')}
${update.enabled ? (update.update_available ? 'Update available' : update.status === 'ok' ? 'Up to date' : 'Update check unavailable') : 'Update checks disabled'}
+ Release Status
${esc(update.latest_version || update.current_version || 'n/a')}
${update.enabled ? (update.status === 'ok' ? `${esc(update.update_type || 'current')} / ${esc(update.recommended_action || (update.update_available ? 'Upgrade recommended' : 'No action needed'))}` : esc(update.recommended_action || 'Update check unavailable')) : 'Update checks disabled'}
`;
const providerRows = providers.map(provider => `
diff --git a/foundrygate/updates.py b/foundrygate/updates.py
index 05f7d99..3c22c10 100644
--- a/foundrygate/updates.py
+++ b/foundrygate/updates.py
@@ -38,6 +38,42 @@ def is_update_available(current_version: str, latest_version: str) -> bool:
return latest > current
+def classify_update(current_version: str, latest_version: str) -> str:
+ """Classify a newer release as patch, minor, major, or current."""
+ current = _normalize_version(current_version)
+ latest = _normalize_version(latest_version)
+ if not current or not latest:
+ return "unknown"
+
+ width = max(3, len(current), len(latest))
+ current += (0,) * (width - len(current))
+ latest += (0,) * (width - len(latest))
+ if latest <= current:
+ return "current"
+ if latest[0] > current[0]:
+ return "major"
+ if latest[1] > current[1]:
+ return "minor"
+ return "patch"
+
+
+def alert_level_for_update(update_type: str, *, available: bool, status: str) -> str:
+ """Return an operator-facing alert level for one update status."""
+ if status in {"unavailable"}:
+ return "warning"
+ if status in {"disabled"}:
+ return "disabled"
+ if not available:
+ return "ok"
+ if update_type == "major":
+ return "critical"
+ if update_type == "minor":
+ return "warning"
+ if update_type == "patch":
+ return "info"
+ return "warning"
+
+
@dataclass
class UpdateStatus:
"""Structured update-check result."""
@@ -50,6 +86,9 @@ class UpdateStatus:
release_url: str = ""
checked_at: float = 0.0
status: str = "disabled"
+ update_type: str = "current"
+ alert_level: str = "disabled"
+ recommended_action: str = ""
error: str = ""
def to_dict(self) -> dict[str, Any]:
@@ -62,6 +101,9 @@ def to_dict(self) -> dict[str, Any]:
"release_url": self.release_url,
"checked_at": self.checked_at,
"status": self.status,
+ "update_type": self.update_type,
+ "alert_level": self.alert_level,
+ "recommended_action": self.recommended_action,
"error": self.error,
}
@@ -110,6 +152,9 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus:
repository=self.repository,
checked_at=time.time(),
status="disabled",
+ update_type="current",
+ alert_level="disabled",
+ recommended_action="Update checks are disabled",
)
return self._cached
@@ -132,6 +177,9 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus:
repository=self.repository,
checked_at=now,
status="unavailable",
+ update_type="unknown",
+ alert_level="warning",
+ recommended_action="Inspect release connectivity and retry later",
error=f"Release lookup returned HTTP {response.status_code}",
)
return self._cached
@@ -139,15 +187,27 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus:
payload = response.json()
latest_version = str(payload.get("tag_name") or "").strip()
release_url = str(payload.get("html_url") or "").strip()
+ update_available = is_update_available(self.current_version, latest_version)
+ update_type = classify_update(self.current_version, latest_version)
+ alert_level = alert_level_for_update(
+ update_type,
+ available=update_available,
+ status="ok",
+ )
self._cached = UpdateStatus(
enabled=True,
current_version=self.current_version,
latest_version=latest_version,
- update_available=is_update_available(self.current_version, latest_version),
+ update_available=update_available,
repository=self.repository,
release_url=release_url,
checked_at=now,
status="ok",
+ update_type=update_type,
+ alert_level=alert_level,
+ recommended_action=(
+ "Upgrade to the latest release" if update_available else "No action needed"
+ ),
)
return self._cached
except Exception as exc:
@@ -157,6 +217,9 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus:
repository=self.repository,
checked_at=now,
status="unavailable",
+ update_type="unknown",
+ alert_level="warning",
+ recommended_action="Inspect release connectivity and retry later",
error=str(exc),
)
return self._cached
diff --git a/tests/test_updates.py b/tests/test_updates.py
index 8e84648..42faa83 100644
--- a/tests/test_updates.py
+++ b/tests/test_updates.py
@@ -4,7 +4,12 @@
import pytest
-from foundrygate.updates import UpdateChecker, is_update_available
+from foundrygate.updates import (
+ UpdateChecker,
+ alert_level_for_update,
+ classify_update,
+ is_update_available,
+)
class _FakeResponse:
@@ -37,6 +42,21 @@ def test_version_comparison_detects_newer_release():
assert is_update_available("0.5.1", "v0.5.0") is False
+def test_classify_update_distinguishes_patch_minor_and_major():
+ assert classify_update("0.6.0", "v0.6.1") == "patch"
+ assert classify_update("0.6.0", "v0.7.0") == "minor"
+ assert classify_update("0.6.0", "v1.0.0") == "major"
+ assert classify_update("0.6.0", "v0.6.0") == "current"
+
+
+def test_alert_level_maps_update_type_and_status():
+ assert alert_level_for_update("patch", available=True, status="ok") == "info"
+ assert alert_level_for_update("minor", available=True, status="ok") == "warning"
+ assert alert_level_for_update("major", available=True, status="ok") == "critical"
+ assert alert_level_for_update("current", available=False, status="ok") == "ok"
+ assert alert_level_for_update("unknown", available=False, status="unavailable") == "warning"
+
+
@pytest.mark.asyncio
async def test_update_checker_reports_latest_release():
checker = UpdateChecker(
@@ -59,6 +79,9 @@ async def test_update_checker_reports_latest_release():
assert status.status == "ok"
assert status.latest_version == "v0.5.0"
assert status.update_available is True
+ assert status.update_type == "minor"
+ assert status.alert_level == "warning"
+ assert status.recommended_action == "Upgrade to the latest release"
assert status.release_url.endswith("/v0.5.0")
@@ -86,6 +109,7 @@ async def test_update_checker_uses_cache_until_forced():
assert first.status == "ok"
assert second.status == "ok"
+ assert second.alert_level == "ok"
assert fake_client.calls == 1
@@ -102,4 +126,6 @@ async def test_update_checker_handles_remote_errors():
assert status.status == "unavailable"
assert status.update_available is False
+ assert status.alert_level == "warning"
+ assert status.recommended_action == "Inspect release connectivity and retry later"
assert "network unavailable" in status.error