diff --git a/CHANGELOG.md b/CHANGELOG.md
index 572cce2..338b94e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel
### Added
- Added stronger update-alert metadata to `GET /api/update`, including update type, alert level, and recommended action for operators and dashboard consumers
+- Added an opt-in `auto_update` policy block plus `foundrygate-auto-update` so controlled deployments can gate helper-driven updates without enabling silent self-updates
## v0.6.0 - 2026-03-12
diff --git a/README.md b/README.md
index af5c770..453f00e 100644
--- a/README.md
+++ b/README.md
@@ -509,7 +509,7 @@ Timeouts and connection errors still participate in fallback behavior and health
### Update Check Settings
-FoundryGate can also cache release-update metadata for operators. This is read-only runtime behavior; it does not perform automatic upgrades.
+FoundryGate can also cache release-update metadata for operators. The runtime surface stays read-only; optional helper-driven updates remain explicit and opt-in.
Supported fields in `update_check`:
@@ -532,6 +532,35 @@ update_check:
The status is exposed through `GET /api/update`, the dashboard, and the helper script `foundrygate-update-check`.
+FoundryGate also supports an optional `auto_update` policy block for controlled environments. This stays strictly opt-in and only marks whether the current release state is eligible for a helper-driven update command.
+
+Supported fields in `auto_update`:
+
+- `enabled`
+- `allow_major`
+- `apply_command`
+
+Example:
+
+```yaml
+auto_update:
+ enabled: true
+ allow_major: false
+ apply_command: "foundrygate-update"
+```
+
+What the current runtime does with it:
+
+- exposes eligibility in `GET /api/update` under `auto_update`
+- shows the same state in the dashboard
+- lets `foundrygate-auto-update --apply` run only when the current release state is eligible
+
+What it still does not do:
+
+- it does not self-update over HTTP
+- it does not schedule itself
+- it does not apply major upgrades unless you opt in with `allow_major: true`
+
### Provider Capability Schema
Each provider can expose normalized capability metadata under `capabilities:` in `config.yaml`. This is the first building block for policy-aware routing and future local-worker support.
@@ -777,6 +806,7 @@ Running `./scripts/foundrygate-install` also creates symlinks in `/usr/local/bin
| `foundrygate-logs` | Tails `journalctl -u foundrygate.service` |
| `foundrygate-health` | Calls `GET /health` locally with `curl` |
| `foundrygate-update-check` | Calls `GET /api/update` locally and prints the cached release-check status |
+| `foundrygate-auto-update` | Evaluates the cached update status and, with `--apply`, only runs the configured update command when the release is eligible |
| `foundrygate-update` | Fetches from Git, hard-resets to `origin/main`, cleans untracked files, reinstalls the unit, restarts, and retries health checks |
| `foundrygate-uninstall` | Stops and disables the service, removes the unit file, and removes helper symlinks |
@@ -792,18 +822,21 @@ What it does:
- caches the result for `update_check.check_interval_seconds`
- exposes the cached status in `GET /api/update`
- surfaces the same status in the dashboard and `foundrygate-update-check`
+- exposes opt-in auto-update eligibility and the configured apply command
What it does not do:
- it does not download releases
- it does not modify the checkout
-- it does not auto-update the service
+- it does not auto-update the service unless an operator explicitly wires `foundrygate-auto-update --apply` into their own scheduler
Manual check:
```bash
curl -fsS http://127.0.0.1:8090/api/update
./scripts/foundrygate-update-check
+./scripts/foundrygate-auto-update
+./scripts/foundrygate-auto-update --apply
```
## Community And Security
diff --git a/config.yaml b/config.yaml
index c9703e9..b9bbe69 100644
--- a/config.yaml
+++ b/config.yaml
@@ -882,6 +882,14 @@ update_check:
timeout_seconds: 5
check_interval_seconds: 21600
+# ── Optional Auto-Update Enabler ────────────────────────────────────────────
+# This does not make the API mutate the checkout. It only marks whether the
+# current release status is eligible for a helper-driven update command.
+auto_update:
+ enabled: false
+ allow_major: false
+ apply_command: "foundrygate-update"
+
# ── Prompt Caching ─────────────────────────────────────────────────────────
# DeepSeek: Automatisch server-seitig (prefix-basiert, 64-token Granularität)
diff --git a/docs/FOUNDRYGATE-ROADMAP.md b/docs/FOUNDRYGATE-ROADMAP.md
index 1c58df5..9d47a8f 100644
--- a/docs/FOUNDRYGATE-ROADMAP.md
+++ b/docs/FOUNDRYGATE-ROADMAP.md
@@ -212,6 +212,13 @@ 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.
+The next small slice is to keep auto-update conservative:
+
+- disabled by default
+- no checkout mutation over HTTP
+- helper-driven and operator-triggered only
+- major upgrades still manual unless explicitly allowed
+
### `v0.8.x`: many-provider and many-client onboarding
Primary goals:
@@ -352,8 +359,9 @@ Current baseline:
- cached release checks via `GET /api/update`
- dashboard visibility for current vs latest known release
- local helper access via `foundrygate-update-check`
+- opt-in eligibility reporting and helper-driven apply flow via `foundrygate-auto-update`
-This should remain opt-in and operationally conservative as it expands toward stronger alerts and optional controlled auto-update flows.
+This should remain opt-in and operationally conservative as it expands toward scheduled helper use, stronger rollout controls, and clearer operator approval boundaries.
### 7. Distribution channels
diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md
index b10dff3..13f2295 100644
--- a/docs/ONBOARDING.md
+++ b/docs/ONBOARDING.md
@@ -103,12 +103,12 @@ Current state:
- manual updates via Git or `foundrygate-update`
- cached release update checks via `GET /api/update` and `foundrygate-update-check`
+- optional eligibility reporting and helper-driven apply flow via `foundrygate-auto-update`
- tag-driven release artifacts for Python distributions and container images
- publish dry-run workflow for Python packaging and GHCR container builds
Planned state:
-- stronger update alerts
-- optional auto-update enablers for controlled environments
+- scheduled use of `foundrygate-auto-update --apply` in controlled environments
-These are roadmap items. They are not implemented as automatic runtime behavior today.
+This remains opt-in. FoundryGate does not self-schedule or mutate the checkout over HTTP.
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index 0293bc2..99c6e3c 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -149,6 +149,7 @@ Check the cached runtime view first:
```bash
curl -fsS http://127.0.0.1:8090/api/update
./scripts/foundrygate-update-check
+./scripts/foundrygate-auto-update
```
Common causes:
@@ -174,3 +175,9 @@ Use `force=true` when you need an immediate refresh instead of the cached result
```bash
curl -fsS 'http://127.0.0.1:8090/api/update?force=true'
```
+
+If `foundrygate-auto-update --apply` refuses to run, inspect the `auto_update` block in the JSON response. Common blockers are:
+
+- `auto_update.enabled: false`
+- the latest release is a major upgrade while `allow_major: false`
+- the release lookup itself is unavailable
diff --git a/foundrygate/config.py b/foundrygate/config.py
index bf7368e..222d483 100644
--- a/foundrygate/config.py
+++ b/foundrygate/config.py
@@ -876,6 +876,35 @@ def _normalize_update_check(data: dict[str, Any]) -> dict[str, Any]:
return normalized
+def _normalize_auto_update(data: dict[str, Any]) -> dict[str, Any]:
+ """Validate optional auto-update helper configuration."""
+ raw = data.get("auto_update", {})
+ if raw is None:
+ raw = {}
+ if not isinstance(raw, dict):
+ raise ConfigError("'auto_update' must be a mapping")
+
+ enabled = raw.get("enabled", False)
+ if not isinstance(enabled, bool):
+ raise ConfigError("'auto_update.enabled' must be a boolean")
+
+ allow_major = raw.get("allow_major", False)
+ if not isinstance(allow_major, bool):
+ raise ConfigError("'auto_update.allow_major' must be a boolean")
+
+ apply_command = raw.get("apply_command", "foundrygate-update")
+ if not isinstance(apply_command, str) or not apply_command.strip():
+ raise ConfigError("'auto_update.apply_command' must be a non-empty string")
+
+ normalized = dict(data)
+ normalized["auto_update"] = {
+ "enabled": enabled,
+ "allow_major": allow_major,
+ "apply_command": apply_command.strip(),
+ }
+ return normalized
+
+
class Config:
"""Holds the parsed and expanded configuration."""
@@ -953,6 +982,17 @@ def update_check(self) -> dict:
},
)
+ @property
+ def auto_update(self) -> dict:
+ return self._data.get(
+ "auto_update",
+ {
+ "enabled": False,
+ "allow_major": False,
+ "apply_command": "foundrygate-update",
+ },
+ )
+
def provider(self, name: str) -> dict | None:
return self.providers.get(name)
@@ -978,10 +1018,12 @@ def load_config(path: str | Path | None = None) -> Config:
with path.open() as f:
raw = yaml.safe_load(f)
- expanded = _normalize_update_check(
- _normalize_request_hooks(
- _normalize_client_profiles(
- _normalize_routing_policies(_normalize_providers(_walk_expand(raw)))
+ expanded = _normalize_auto_update(
+ _normalize_update_check(
+ _normalize_request_hooks(
+ _normalize_client_profiles(
+ _normalize_routing_policies(_normalize_providers(_walk_expand(raw)))
+ )
)
)
)
diff --git a/foundrygate/main.py b/foundrygate/main.py
index e4432b0..7abae91 100644
--- a/foundrygate/main.py
+++ b/foundrygate/main.py
@@ -614,6 +614,7 @@ async def lifespan(app: FastAPI):
api_base=str(_config.update_check.get("api_base", "https://api.github.com")),
check_interval_seconds=int(_config.update_check.get("check_interval_seconds", 21600)),
timeout_seconds=float(_config.update_check.get("timeout_seconds", 5.0)),
+ auto_update=_config.auto_update,
)
# Metrics
@@ -1567,7 +1568,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.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'}
+ 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'))}${update.auto_update && update.auto_update.enabled ? ` / auto: ${esc(update.auto_update.eligible ? 'eligible' : (update.auto_update.blocked_reason || 'blocked'))}` : ''}` : 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 3c22c10..fd5ed8a 100644
--- a/foundrygate/updates.py
+++ b/foundrygate/updates.py
@@ -89,6 +89,7 @@ class UpdateStatus:
update_type: str = "current"
alert_level: str = "disabled"
recommended_action: str = ""
+ auto_update: dict[str, Any] | None = None
error: str = ""
def to_dict(self) -> dict[str, Any]:
@@ -104,6 +105,7 @@ def to_dict(self) -> dict[str, Any]:
"update_type": self.update_type,
"alert_level": self.alert_level,
"recommended_action": self.recommended_action,
+ "auto_update": self.auto_update or {},
"error": self.error,
}
@@ -120,6 +122,7 @@ def __init__(
api_base: str = "https://api.github.com",
check_interval_seconds: int = 21600,
timeout_seconds: float = 5.0,
+ auto_update: dict[str, Any] | None = None,
):
self.current_version = current_version
self.enabled = enabled
@@ -127,6 +130,11 @@ def __init__(
self.api_base = api_base.rstrip("/")
self.check_interval_seconds = check_interval_seconds
self.timeout_seconds = timeout_seconds
+ self.auto_update = {
+ "enabled": bool((auto_update or {}).get("enabled", False)),
+ "allow_major": bool((auto_update or {}).get("allow_major", False)),
+ "apply_command": str((auto_update or {}).get("apply_command", "foundrygate-update")),
+ }
self._cached = UpdateStatus(
enabled=enabled,
current_version=current_version,
@@ -143,6 +151,49 @@ def __init__(
async def close(self) -> None:
await self._client.aclose()
+ def _auto_update_status(
+ self,
+ *,
+ status: str,
+ update_available: bool,
+ update_type: str,
+ latest_version: str = "",
+ ) -> dict[str, Any]:
+ """Return opt-in auto-update eligibility for operator tooling."""
+ enabled = bool(self.auto_update.get("enabled", False))
+ allow_major = bool(self.auto_update.get("allow_major", False))
+ apply_command = str(self.auto_update.get("apply_command", "foundrygate-update"))
+ allowed_types = ["patch", "minor"]
+ if allow_major:
+ allowed_types.append("major")
+
+ blocked_reason = ""
+ eligible = False
+ if not enabled:
+ blocked_reason = "Auto-update is disabled"
+ elif status == "disabled":
+ blocked_reason = "Update checks are disabled"
+ elif status != "ok":
+ blocked_reason = "Release status is unavailable"
+ elif not update_available:
+ blocked_reason = "Already on the latest release"
+ elif update_type not in allowed_types:
+ blocked_reason = f"{update_type.capitalize()} updates require manual approval"
+ else:
+ eligible = True
+
+ return {
+ "enabled": enabled,
+ "strategy": "script",
+ "allowed_update_types": allowed_types,
+ "allow_major": allow_major,
+ "eligible": eligible,
+ "blocked_reason": blocked_reason,
+ "apply_command": apply_command,
+ "target_version": latest_version,
+ "requires_operator_trigger": True,
+ }
+
async def get_status(self, *, force: bool = False) -> UpdateStatus:
"""Return cached or freshly fetched update status."""
if not self.enabled:
@@ -155,6 +206,11 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus:
update_type="current",
alert_level="disabled",
recommended_action="Update checks are disabled",
+ auto_update=self._auto_update_status(
+ status="disabled",
+ update_available=False,
+ update_type="current",
+ ),
)
return self._cached
@@ -180,6 +236,11 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus:
update_type="unknown",
alert_level="warning",
recommended_action="Inspect release connectivity and retry later",
+ auto_update=self._auto_update_status(
+ status="unavailable",
+ update_available=False,
+ update_type="unknown",
+ ),
error=f"Release lookup returned HTTP {response.status_code}",
)
return self._cached
@@ -208,6 +269,12 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus:
recommended_action=(
"Upgrade to the latest release" if update_available else "No action needed"
),
+ auto_update=self._auto_update_status(
+ status="ok",
+ update_available=update_available,
+ update_type=update_type,
+ latest_version=latest_version,
+ ),
)
return self._cached
except Exception as exc:
@@ -220,6 +287,11 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus:
update_type="unknown",
alert_level="warning",
recommended_action="Inspect release connectivity and retry later",
+ auto_update=self._auto_update_status(
+ status="unavailable",
+ update_available=False,
+ update_type="unknown",
+ ),
error=str(exc),
)
return self._cached
diff --git a/scripts/foundrygate-auto-update b/scripts/foundrygate-auto-update
new file mode 100755
index 0000000..9720127
--- /dev/null
+++ b/scripts/foundrygate-auto-update
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+api_url="${FOUNDRYGATE_UPDATE_API_URL:-http://127.0.0.1:8090/api/update?force=true}"
+mode="${1:-}"
+
+payload="$(curl -fsS "$api_url")"
+
+if [ "$mode" = "--json" ]; then
+ printf '%s\n' "$payload"
+ exit 0
+fi
+
+export FOUNDRYGATE_UPDATE_PAYLOAD="$payload"
+
+mapfile -t parsed < <(
+python3 - <<'PY'
+import json
+import os
+
+payload = json.loads(os.environ["FOUNDRYGATE_UPDATE_PAYLOAD"])
+auto = payload.get("auto_update") or {}
+
+current = payload.get("current_version") or "unknown"
+latest = payload.get("latest_version") or current
+update_type = payload.get("update_type") or "unknown"
+recommended = payload.get("recommended_action") or ""
+enabled = bool(auto.get("enabled"))
+eligible = bool(auto.get("eligible"))
+blocked = auto.get("blocked_reason") or ""
+apply_command = auto.get("apply_command") or "foundrygate-update"
+
+for value in (
+ current,
+ latest,
+ payload.get("status") or "unknown",
+ update_type,
+ recommended,
+ "true" if enabled else "false",
+ "true" if eligible else "false",
+ blocked,
+ apply_command,
+):
+ print(value)
+PY
+)
+current="${parsed[0]}"
+latest="${parsed[1]}"
+status="${parsed[2]}"
+update_type="${parsed[3]}"
+recommended_action="${parsed[4]}"
+auto_enabled="${parsed[5]}"
+auto_eligible="${parsed[6]}"
+auto_blocked_reason="${parsed[7]}"
+apply_command="${parsed[8]}"
+
+if [ "$mode" = "--apply" ]; then
+ if [ "$auto_enabled" != "true" ]; then
+ echo "auto-update is disabled" >&2
+ exit 1
+ fi
+ if [ "$auto_eligible" != "true" ]; then
+ echo "auto-update not eligible: ${auto_blocked_reason:-blocked}" >&2
+ exit 1
+ fi
+
+ echo "Applying ${update_type} update to ${latest} via: ${apply_command}"
+ exec /bin/sh -lc "$apply_command"
+fi
+
+printf 'Current: %s\nLatest: %s\nStatus: %s\nUpdate type: %s\nAction: %s\n' \
+ "$current" "$latest" "$status" "$update_type" "$recommended_action"
+
+if [ "$auto_enabled" = "true" ]; then
+ if [ "$auto_eligible" = "true" ]; then
+ printf 'Auto-update: eligible via %s\n' "$apply_command"
+ else
+ printf 'Auto-update: blocked (%s)\n' "${auto_blocked_reason:-blocked}"
+ fi
+else
+ printf 'Auto-update: disabled\n'
+fi
diff --git a/scripts/foundrygate-install b/scripts/foundrygate-install
index c55635c..c857632 100755
--- a/scripts/foundrygate-install
+++ b/scripts/foundrygate-install
@@ -6,6 +6,7 @@ helpers=(
foundrygate-bootstrap
foundrygate-doctor
foundrygate-update-check
+ foundrygate-auto-update
foundrygate-install
foundrygate-start
foundrygate-stop
diff --git a/scripts/foundrygate-uninstall b/scripts/foundrygate-uninstall
index 7698e33..525b190 100755
--- a/scripts/foundrygate-uninstall
+++ b/scripts/foundrygate-uninstall
@@ -9,6 +9,7 @@ helpers=(
foundrygate-logs
foundrygate-health
foundrygate-update
+ foundrygate-auto-update
foundrygate-uninstall
)
diff --git a/tests/test_config.py b/tests/test_config.py
index 87e4b8d..09b00fc 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -81,3 +81,10 @@ def test_metrics_db_path_never_dot_slash(monkeypatch):
db_path = cfg.metrics["db_path"]
assert not db_path.startswith("./"), f"unsafe db_path in metrics: {db_path}"
assert db_path.startswith("/"), f"expected absolute path, got: {db_path}"
+
+
+def test_auto_update_defaults_are_exposed():
+ cfg = load_config(Path(__file__).parent.parent / "config.yaml")
+ assert cfg.auto_update["enabled"] is False
+ assert cfg.auto_update["allow_major"] is False
+ assert cfg.auto_update["apply_command"] == "foundrygate-update"
diff --git a/tests/test_updates.py b/tests/test_updates.py
index 42faa83..0dd4da6 100644
--- a/tests/test_updates.py
+++ b/tests/test_updates.py
@@ -63,6 +63,7 @@ async def test_update_checker_reports_latest_release():
current_version="0.4.0",
enabled=True,
repository="typelicious/FoundryGate",
+ auto_update={"enabled": True, "allow_major": False},
)
checker._client = _FakeClient(
_FakeResponse(
@@ -82,6 +83,9 @@ async def test_update_checker_reports_latest_release():
assert status.update_type == "minor"
assert status.alert_level == "warning"
assert status.recommended_action == "Upgrade to the latest release"
+ assert status.auto_update["enabled"] is True
+ assert status.auto_update["eligible"] is True
+ assert status.auto_update["allowed_update_types"] == ["patch", "minor"]
assert status.release_url.endswith("/v0.5.0")
@@ -128,4 +132,49 @@ async def test_update_checker_handles_remote_errors():
assert status.update_available is False
assert status.alert_level == "warning"
assert status.recommended_action == "Inspect release connectivity and retry later"
+ assert status.auto_update["eligible"] is False
+ assert status.auto_update["blocked_reason"] == "Auto-update is disabled"
assert "network unavailable" in status.error
+
+
+@pytest.mark.asyncio
+async def test_major_updates_are_blocked_when_auto_update_disallows_them():
+ checker = UpdateChecker(
+ current_version="0.6.0",
+ enabled=True,
+ repository="typelicious/FoundryGate",
+ auto_update={"enabled": True, "allow_major": False},
+ )
+ checker._client = _FakeClient(
+ _FakeResponse(
+ 200,
+ {
+ "tag_name": "v1.0.0",
+ "html_url": "https://github.com/typelicious/FoundryGate/releases/tag/v1.0.0",
+ },
+ )
+ )
+
+ status = await checker.get_status(force=True)
+
+ assert status.update_type == "major"
+ assert status.auto_update["enabled"] is True
+ assert status.auto_update["eligible"] is False
+ assert status.auto_update["blocked_reason"] == "Major updates require manual approval"
+
+
+@pytest.mark.asyncio
+async def test_auto_update_disabled_status_is_reported_cleanly():
+ checker = UpdateChecker(
+ current_version="0.6.0",
+ enabled=False,
+ repository="typelicious/FoundryGate",
+ auto_update={"enabled": False},
+ )
+
+ status = await checker.get_status(force=False)
+
+ assert status.status == "disabled"
+ assert status.auto_update["enabled"] is False
+ assert status.auto_update["eligible"] is False
+ assert status.auto_update["blocked_reason"] == "Auto-update is disabled"