Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/package-retention.yml
Original file line number Diff line number Diff line change
@@ -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
143 changes: 143 additions & 0 deletions scripts/prune_ghcr_versions.py
Original file line number Diff line number Diff line change
@@ -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())
45 changes: 45 additions & 0 deletions tests/test_package_retention.py
Original file line number Diff line number Diff line change
@@ -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
Loading