From 09dc769efdaa8ea0a48698e75389a5837bbb4a79 Mon Sep 17 00:00:00 2001 From: CaspianG <259116618+CaspianG@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:02:02 +0300 Subject: [PATCH] Add GHCR package retention --- .github/workflows/container.yml | 1 - .github/workflows/package-retention.yml | 54 +++++++++ scripts/prune_ghcr_versions.py | 143 ++++++++++++++++++++++++ tests/test_package_retention.py | 45 ++++++++ 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/package-retention.yml create mode 100644 scripts/prune_ghcr_versions.py create mode 100644 tests/test_package_retention.py diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index b32f517..e791815 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -40,7 +40,6 @@ jobs: type=ref,event=branch type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=sha- - name: Build and publish image uses: docker/build-push-action@v6 diff --git a/.github/workflows/package-retention.yml b/.github/workflows/package-retention.yml new file mode 100644 index 0000000..03463c0 --- /dev/null +++ b/.github/workflows/package-retention.yml @@ -0,0 +1,54 @@ +name: package-retention + +on: + workflow_dispatch: + inputs: + apply: + description: Delete package versions after the preview + required: true + default: false + type: boolean + max_delete: + description: Maximum SHA-only versions to delete in one run + required: true + default: "500" + type: string + schedule: + - cron: "17 3 * * 0" + +concurrency: + group: package-retention + cancel-in-progress: false + +permissions: + contents: read + packages: write + +jobs: + ghcr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Preview SHA-only package versions + if: ${{ github.event_name == 'workflow_dispatch' && ! inputs.apply }} + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python scripts/prune_ghcr_versions.py \ + --max-delete "${{ inputs.max_delete }}" + + - name: Apply package retention + if: ${{ github.event_name == 'schedule' || inputs.apply }} + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + MAX_DELETE="${{ inputs.max_delete }}" + if [ -z "$MAX_DELETE" ]; then MAX_DELETE=500; fi + python scripts/prune_ghcr_versions.py \ + --max-delete "$MAX_DELETE" \ + --apply diff --git a/scripts/prune_ghcr_versions.py b/scripts/prune_ghcr_versions.py new file mode 100644 index 0000000..2f3be57 --- /dev/null +++ b/scripts/prune_ghcr_versions.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Iterable + + +SHA_TAG = re.compile(r"sha-[0-9a-f]+", re.IGNORECASE) + + +def should_delete(tags: Iterable[str]) -> bool: + normalized = [tag.strip() for tag in tags if tag and tag.strip()] + return bool(normalized) and all(SHA_TAG.fullmatch(tag) for tag in normalized) + + +def _request(url: str, token: str, *, method: str = "GET") -> object | None: + request = urllib.request.Request( + url, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "wavemind-package-retention", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + if response.status == 204: + return None + return json.load(response) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"GitHub API {method} {url} failed: {exc.code} {detail}") from exc + + +def list_versions(api_url: str, owner: str, package: str, token: str) -> list[dict]: + package_name = urllib.parse.quote(package, safe="") + versions: list[dict] = [] + for page in range(1, 101): + url = ( + f"{api_url}/users/{urllib.parse.quote(owner, safe='')}/packages/container/" + f"{package_name}/versions?per_page=100&page={page}" + ) + payload = _request(url, token) + if not isinstance(payload, list): + raise RuntimeError("GitHub package versions response is not a list") + versions.extend(item for item in payload if isinstance(item, dict)) + if len(payload) < 100: + break + return versions + + +def version_tags(version: dict) -> list[str]: + metadata = version.get("metadata") + if not isinstance(metadata, dict): + return [] + container = metadata.get("container") + if not isinstance(container, dict): + return [] + tags = container.get("tags") + if not isinstance(tags, list): + return [] + return [str(tag) for tag in tags] + + +def prune( + *, + api_url: str, + owner: str, + package: str, + token: str, + apply: bool, + max_delete: int, +) -> dict: + versions = list_versions(api_url, owner, package, token) + candidates = [version for version in versions if should_delete(version_tags(version))] + candidates.sort(key=lambda item: str(item.get("created_at", ""))) + selected = candidates[:max_delete] + + deleted: list[int] = [] + if apply: + package_name = urllib.parse.quote(package, safe="") + owner_name = urllib.parse.quote(owner, safe="") + for version in selected: + version_id = int(version["id"]) + url = ( + f"{api_url}/users/{owner_name}/packages/container/" + f"{package_name}/versions/{version_id}" + ) + _request(url, token, method="DELETE") + deleted.append(version_id) + + return { + "schema": "wavemind.ghcr_retention.v1", + "owner": owner, + "package": package, + "apply": apply, + "scanned": len(versions), + "candidates": len(candidates), + "selected": len(selected), + "deleted": len(deleted), + "protected": len(versions) - len(candidates), + "candidate_ids": [int(version["id"]) for version in selected], + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Delete GHCR versions whose tags are exclusively one-off sha-* tags." + ) + parser.add_argument("--owner", default=os.getenv("GITHUB_REPOSITORY_OWNER", "CaspianG")) + parser.add_argument("--package", default="wavemind") + parser.add_argument("--api-url", default=os.getenv("GITHUB_API_URL", "https://api.github.com")) + parser.add_argument("--max-delete", type=int, default=500) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + + token = os.getenv("GITHUB_TOKEN", "").strip() + if not token: + parser.error("GITHUB_TOKEN is required") + if args.max_delete < 1: + parser.error("--max-delete must be at least 1") + + result = prune( + api_url=args.api_url.rstrip("/"), + owner=args.owner, + package=args.package, + token=token, + apply=args.apply, + max_delete=args.max_delete, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_package_retention.py b/tests/test_package_retention.py new file mode 100644 index 0000000..445b7fe --- /dev/null +++ b/tests/test_package_retention.py @@ -0,0 +1,45 @@ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "prune_ghcr_versions.py" +SPEC = spec_from_file_location("prune_ghcr_versions", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +MODULE = module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def test_sha_only_versions_are_deletable() -> None: + assert MODULE.should_delete(["sha-a5af202"]) + assert MODULE.should_delete(["sha-a5af202", "sha-deadbeef"]) + + +def test_public_and_release_tags_are_protected() -> None: + assert not MODULE.should_delete([]) + assert not MODULE.should_delete(["latest", "main", "sha-a5af202"]) + assert not MODULE.should_delete(["2.6.1", "2.6", "sha-a5af202"]) + assert not MODULE.should_delete(["custom"]) + + +def test_container_workflow_does_not_publish_sha_tags() -> None: + workflow = (ROOT / ".github" / "workflows" / "container.yml").read_text( + encoding="utf-8" + ) + + assert "type=sha" not in workflow + assert "type=raw,value=latest" in workflow + assert "type=ref,event=branch" in workflow + assert "type=semver,pattern={{version}}" in workflow + + +def test_retention_workflow_has_bounded_write_permissions() -> None: + workflow = (ROOT / ".github" / "workflows" / "package-retention.yml").read_text( + encoding="utf-8" + ) + + assert "packages: write" in workflow + assert "contents: read" in workflow + assert "scripts/prune_ghcr_versions.py" in workflow + assert "--apply" in workflow + assert "schedule:" in workflow