diff --git a/activity-definitions/weekly-forgejo-package-prune.md b/activity-definitions/weekly-forgejo-package-prune.md new file mode 100644 index 0000000..cbf1c19 --- /dev/null +++ b/activity-definitions/weekly-forgejo-package-prune.md @@ -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). \ No newline at end of file diff --git a/docs/runbook.md b/docs/runbook.md index 61d3027..d0c9445 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -273,6 +273,14 @@ activity registration issues before the next scheduled run. ## 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. 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 diff --git a/src/activity_core/context_resolvers/forgejo_prune.py b/src/activity_core/context_resolvers/forgejo_prune.py new file mode 100644 index 0000000..8bbb9cd --- /dev/null +++ b/src/activity_core/context_resolvers/forgejo_prune.py @@ -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 \ No newline at end of file diff --git a/src/activity_core/context_resolvers/reuse_surface.py b/src/activity_core/context_resolvers/reuse_surface.py index 31a1ebf..2676f12 100644 --- a/src/activity_core/context_resolvers/reuse_surface.py +++ b/src/activity_core/context_resolvers/reuse_surface.py @@ -21,6 +21,7 @@ import httpx import yaml 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.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]: if query == "reuse_surface_report_gaps": return reuse_surface_report_gaps(params) + if query == "forgejo_package_prune": + return forgejo_package_prune(params) return KaizenContextResolver().resolve(query, event, params) diff --git a/src/activity_core/ops_evidence_sinks.py b/src/activity_core/ops_evidence_sinks.py index 837f25b..a6a0f8c 100644 --- a/src/activity_core/ops_evidence_sinks.py +++ b/src/activity_core/ops_evidence_sinks.py @@ -40,7 +40,7 @@ def persist_ops_inventory_evidence(payload: dict[str, Any]) -> list[dict[str, An if not isinstance(source, dict): continue 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 params = source.get("params") or {} @@ -135,6 +135,10 @@ def _post_state_hub_progress( compact = probe_result summary = _legacy_meter_summary_text(probe_result) 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: compact = 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: candidates = result.get("retirement_candidate_count", 0) calls = result.get("window_legacy_calls", 0) diff --git a/tests/test_forgejo_prune_context_resolver.py b/tests/test_forgejo_prune_context_resolver.py new file mode 100644 index 0000000..ae0f316 --- /dev/null +++ b/tests/test_forgejo_prune_context_resolver.py @@ -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 \ No newline at end of file diff --git a/workplans/ACTIVITY-WP-0020-weekly-forgejo-package-prune.md b/workplans/ACTIVITY-WP-0020-weekly-forgejo-package-prune.md index d6534d6..f458233 100644 --- a/workplans/ACTIVITY-WP-0020-weekly-forgejo-package-prune.md +++ b/workplans/ACTIVITY-WP-0020-weekly-forgejo-package-prune.md @@ -4,7 +4,7 @@ type: workplan title: "Weekly Forgejo package prune (retain 3 versions)" domain: infotech repo: activity-core -status: proposed +status: active owner: codex topic_slug: activity-core created: "2026-07-12" @@ -86,7 +86,7 @@ workplans, or activity-definition files. Runtime token file path only. ```task id: ACTIVITY-WP-0020-T01 -status: todo +status: done priority: high state_hub_task_id: "d4212ce4-9292-4c56-abcd-952e0c1bbab7" ``` @@ -110,7 +110,7 @@ without reading source. ```task id: ACTIVITY-WP-0020-T02 -status: todo +status: done priority: high state_hub_task_id: "3abec46e-fca8-4583-b681-c0836e0bf666" ``` @@ -139,7 +139,7 @@ Acceptance: dry-run against production lists candidates; no deletes without ```task id: ACTIVITY-WP-0020-T03 -status: todo +status: done priority: high state_hub_task_id: "f8c6eb1a-2682-49b0-a6ab-e96c8174f75f" ``` @@ -163,7 +163,7 @@ exit and stderr snippet (truncated, no secrets). ```task id: ACTIVITY-WP-0020-T04 -status: todo +status: done priority: high state_hub_task_id: "2da2ca5d-1c59-4835-b48e-7bf3bfae97b4" ``` @@ -201,11 +201,15 @@ Acceptance: definition parses via `sync-definitions`; schedule id derivable from ```task id: ACTIVITY-WP-0020-T05 -status: todo +status: progress priority: medium 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 backup analysis (~800 MiB packages). 2. Operator review of dry-run output; confirm protected tags (e.g.