feat(automation): weekly Forgejo package prune shell resolver (WP-0020)
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:
parent
a853cd3118
commit
5b6d9f4e95
7 changed files with 176 additions and 7 deletions
35
activity-definitions/weekly-forgejo-package-prune.md
Normal file
35
activity-definitions/weekly-forgejo-package-prune.md
Normal 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).
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
62
src/activity_core/context_resolvers/forgejo_prune.py
Normal file
62
src/activity_core/context_resolvers/forgejo_prune.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
39
tests/test_forgejo_prune_context_resolver.py
Normal file
39
tests/test_forgejo_prune_context_resolver.py
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue