feat(automation): weekly Forgejo package prune shell resolver (WP-0020)
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Build and Publish Container Image / build-and-push (push) Successful in 35s

Add forgejo_package_prune query, weekly activity-definition (disabled),
State Hub evidence sink support for shell sources, and runbook notes.
This commit is contained in:
tegwick 2026-07-12 11:35:04 +02:00
parent a853cd3118
commit 5b6d9f4e95
7 changed files with 176 additions and 7 deletions

View file

@ -0,0 +1,35 @@
---
id: weekly-forgejo-package-prune
name: Weekly Forgejo Package Prune
enabled: false
owner: custodian-agent
governance: custodian
status: proposed
trigger:
type: cron
cron_expression: "30 3 * * 0"
timezone: UTC
misfire_policy: skip
context_sources:
- type: shell
query: forgejo_package_prune
required: true
params:
apply: false
max_versions: 3
evidence_sinks:
- type: state-hub-progress
event_type: forgejo_package_prune
author: activity-core
bind_to: context.prune
---
# Weekly Forgejo Package Prune
Runs every Sunday at 03:30 UTC (after the 02:15 `forgejo-backup` cron). Invokes
`railiance-platform/tools/cmd/forgejo-package-prune` to retain the newest **3**
versions per `coulomb` package (OCI, PyPI, npm, generic). Production image tags
pinned in `railiance-apps` Helm values are protected.
Ships disabled until dry-run evidence is reviewed. Set `params.apply: true` and
`enabled: true` after operator approval (`ACTIVITY-WP-0020` T05).

View file

@ -273,6 +273,14 @@ activity registration issues before the next scheduled run.
## Weekly maintenance definitions ## Weekly maintenance definitions
`weekly-forgejo-package-prune` runs Sundays at 03:30 UTC. It invokes the
`shell` context query `forgejo_package_prune`, which runs
`~/railiance-platform/tools/cmd/forgejo-package-prune` and posts
`forgejo_package_prune` progress to State Hub. Retention depth is **3** versions
per package; production Helm image tags in `railiance-apps` are protected. Ships
`enabled: false` until dry-run evidence is reviewed (`ACTIVITY-WP-0020`). The
worker host must have `FORGEJO_TOKEN` or `FORGEJO_TOKEN_FILE` in its environment.
`weekly-sbom-staleness` is the canonical rule-only weekly maintenance schedule. `weekly-sbom-staleness` is the canonical rule-only weekly maintenance schedule.
It runs Mondays at 09:00 Europe/Berlin, resolves State Hub SBOM status for all It runs Mondays at 09:00 Europe/Berlin, resolves State Hub SBOM status for all
repos, and emits one automated task per stale repo through explicit repos, and emits one automated task per stale repo through explicit

View file

@ -0,0 +1,62 @@
"""Forgejo package retention prune shell context query."""
from __future__ import annotations
import json
import logging
import os
import subprocess
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_DEFAULT_SCRIPT = Path.home() / "railiance-platform" / "tools/cmd/forgejo-package-prune"
_DEFAULT_TIMEOUT_SECONDS = 900
def forgejo_package_prune(params: dict[str, Any]) -> dict[str, Any]:
"""Run the platform prune script and return its JSON summary."""
script = Path(str(params.get("prune_script", _DEFAULT_SCRIPT))).expanduser()
if not script.is_file():
raise FileNotFoundError(f"forgejo_package_prune script not found: {script}")
max_versions = int(params.get("max_versions", 3))
apply = bool(params.get("apply", False))
timeout = float(params.get("timeout_seconds", _DEFAULT_TIMEOUT_SECONDS))
cmd = [str(script), f"--max-versions={max_versions}"]
if apply:
cmd.append("--apply")
env = os.environ.copy()
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=env,
)
stdout = (completed.stdout or "").strip()
if completed.returncode != 0 and not stdout:
stderr = (completed.stderr or "").strip()
snippet = stderr[:500]
raise RuntimeError(
f"forgejo_package_prune failed (exit {completed.returncode}): {snippet}"
)
try:
summary = json.loads(stdout)
except json.JSONDecodeError as exc:
stderr = (completed.stderr or "").strip()
raise RuntimeError(
f"forgejo_package_prune returned invalid JSON: {exc}; stderr={stderr[:300]}"
) from exc
if completed.returncode != 0:
summary.setdefault("errors", []).append(
f"script_exit_code:{completed.returncode}"
)
summary["kind"] = "forgejo_package_prune"
return summary

View file

@ -21,6 +21,7 @@ import httpx
import yaml import yaml
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver
from activity_core.context_resolvers.forgejo_prune import forgejo_package_prune
from activity_core.context_resolvers.kaizen import KaizenContextResolver from activity_core.context_resolvers.kaizen import KaizenContextResolver
from activity_core.context_resolvers.state_hub import StateHubContextResolver from activity_core.context_resolvers.state_hub import StateHubContextResolver
@ -509,6 +510,8 @@ class ShellContextResolver(ContextResolver):
def resolve(self, query: str, event: Any, params: dict[str, Any]) -> dict[str, Any]: def resolve(self, query: str, event: Any, params: dict[str, Any]) -> dict[str, Any]:
if query == "reuse_surface_report_gaps": if query == "reuse_surface_report_gaps":
return reuse_surface_report_gaps(params) return reuse_surface_report_gaps(params)
if query == "forgejo_package_prune":
return forgejo_package_prune(params)
return KaizenContextResolver().resolve(query, event, params) return KaizenContextResolver().resolve(query, event, params)

View file

@ -40,7 +40,7 @@ def persist_ops_inventory_evidence(payload: dict[str, Any]) -> list[dict[str, An
if not isinstance(source, dict): if not isinstance(source, dict):
continue continue
source_type = source.get("type") source_type = source.get("type")
if source_type not in {"ops-inventory", "state-hub", "core-hub"}: if source_type not in {"ops-inventory", "state-hub", "core-hub", "shell"}:
continue continue
params = source.get("params") or {} params = source.get("params") or {}
@ -135,6 +135,10 @@ def _post_state_hub_progress(
compact = probe_result compact = probe_result
summary = _legacy_meter_summary_text(probe_result) summary = _legacy_meter_summary_text(probe_result)
source_type = "state-hub" source_type = "state-hub"
elif probe_result.get("kind") == "forgejo_package_prune":
compact = probe_result
summary = _forgejo_package_prune_summary_text(probe_result)
source_type = "shell"
elif probe_result.get("checks") is not None: elif probe_result.get("checks") is not None:
compact = probe_result compact = probe_result
summary = _phase5_summary_text(probe_result) summary = _phase5_summary_text(probe_result)
@ -534,6 +538,20 @@ def _core_hub_stabilization_summary_text(result: dict[str, Any]) -> str:
) )
def _forgejo_package_prune_summary_text(result: dict[str, Any]) -> str:
deleted = result.get("deleted_count", 0)
candidates = result.get("candidate_count", 0)
protected = result.get("skipped_protected_count", 0)
apply = result.get("apply", False)
mode = "apply" if apply else "dry-run"
errors = result.get("errors") or []
error_note = f"; {len(errors)} error(s)" if errors else ""
return (
f"Forgejo package prune ({mode}): {deleted} deleted, "
f"{candidates} candidate(s), {protected} protected skip(s){error_note}"
)
def _legacy_meter_summary_text(result: dict[str, Any]) -> str: def _legacy_meter_summary_text(result: dict[str, Any]) -> str:
candidates = result.get("retirement_candidate_count", 0) candidates = result.get("retirement_candidate_count", 0)
calls = result.get("window_legacy_calls", 0) calls = result.get("window_legacy_calls", 0)

View file

@ -0,0 +1,39 @@
from __future__ import annotations
import json
from unittest import mock
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY
class _Completed:
def __init__(self, payload: dict, returncode: int = 0, stderr: str = "") -> None:
self.stdout = json.dumps(payload)
self.stderr = stderr
self.returncode = returncode
def test_shell_resolver_runs_forgejo_package_prune(tmp_path, monkeypatch) -> None:
script = tmp_path / "forgejo-package-prune"
script.write_text("#!/usr/bin/env bash\necho '{}'\n", encoding="utf-8")
script.chmod(0o755)
payload = {
"candidate_count": 2,
"deleted_count": 0,
"skipped_protected_count": 1,
"errors": [],
}
monkeypatch.setattr(
"activity_core.context_resolvers.forgejo_prune.subprocess.run",
lambda *args, **kwargs: _Completed(payload),
)
result = CONTEXT_RESOLVER_REGISTRY["shell"]().resolve(
"forgejo_package_prune",
None,
{"prune_script": str(script), "max_versions": 3, "apply": False},
)
assert result["kind"] == "forgejo_package_prune"
assert result["candidate_count"] == 2

View file

@ -4,7 +4,7 @@ type: workplan
title: "Weekly Forgejo package prune (retain 3 versions)" title: "Weekly Forgejo package prune (retain 3 versions)"
domain: infotech domain: infotech
repo: activity-core repo: activity-core
status: proposed status: active
owner: codex owner: codex
topic_slug: activity-core topic_slug: activity-core
created: "2026-07-12" created: "2026-07-12"
@ -86,7 +86,7 @@ workplans, or activity-definition files. Runtime token file path only.
```task ```task
id: ACTIVITY-WP-0020-T01 id: ACTIVITY-WP-0020-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "d4212ce4-9292-4c56-abcd-952e0c1bbab7" state_hub_task_id: "d4212ce4-9292-4c56-abcd-952e0c1bbab7"
``` ```
@ -110,7 +110,7 @@ without reading source.
```task ```task
id: ACTIVITY-WP-0020-T02 id: ACTIVITY-WP-0020-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "3abec46e-fca8-4583-b681-c0836e0bf666" state_hub_task_id: "3abec46e-fca8-4583-b681-c0836e0bf666"
``` ```
@ -139,7 +139,7 @@ Acceptance: dry-run against production lists candidates; no deletes without
```task ```task
id: ACTIVITY-WP-0020-T03 id: ACTIVITY-WP-0020-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "f8c6eb1a-2682-49b0-a6ab-e96c8174f75f" state_hub_task_id: "f8c6eb1a-2682-49b0-a6ab-e96c8174f75f"
``` ```
@ -163,7 +163,7 @@ exit and stderr snippet (truncated, no secrets).
```task ```task
id: ACTIVITY-WP-0020-T04 id: ACTIVITY-WP-0020-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "2da2ca5d-1c59-4835-b48e-7bf3bfae97b4" state_hub_task_id: "2da2ca5d-1c59-4835-b48e-7bf3bfae97b4"
``` ```
@ -201,11 +201,15 @@ Acceptance: definition parses via `sync-definitions`; schedule id derivable from
```task ```task
id: ACTIVITY-WP-0020-T05 id: ACTIVITY-WP-0020-T05
status: todo status: progress
priority: medium priority: medium
state_hub_task_id: "debc74f0-d43d-401e-b65f-c6dbb5b399d7" state_hub_task_id: "debc74f0-d43d-401e-b65f-c6dbb5b399d7"
``` ```
2026-07-12: Implementation landed (script, resolver, activity-definition,
runbook). `enabled: false` and `apply: false` until operator dry-run against
production with `FORGEJO_TOKEN` and approves enable.
1. Run resolver smoke with `apply: false`; capture candidate counts vs Jul 12 1. Run resolver smoke with `apply: false`; capture candidate counts vs Jul 12
backup analysis (~800 MiB packages). backup analysis (~800 MiB packages).
2. Operator review of dry-run output; confirm protected tags (e.g. 2. Operator review of dry-run output; confirm protected tags (e.g.