diff --git a/.github/workflows/cleanup-ghcr.yml b/.github/workflows/cleanup-ghcr.yml new file mode 100644 index 000000000..1b82ddd40 --- /dev/null +++ b/.github/workflows/cleanup-ghcr.yml @@ -0,0 +1,103 @@ +name: Cleanup GHCR untagged images + +# Deletes untagged (orphan) container versions from GHCR while preserving +# any digest that is still referenced by a tagged multi-arch manifest list. +# Without that cross-reference, off-the-shelf cleanup actions can silently +# break multi-arch tags by deleting referenced platform manifests. +# +# Requires a repo secret `GHCR_CLEANUP_TOKEN` — a classic PAT with +# `read:packages` + `delete:packages` scope. GITHUB_TOKEN cannot delete +# versions of user-owned packages (only org-owned). + +on: + schedule: + - cron: '0 3 * * 0' # Sundays at 03:00 UTC + workflow_dispatch: + inputs: + dry_run: + description: 'List orphans without deleting' + type: boolean + default: false + +jobs: + cleanup: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: [bambuddy, bambuddy-beta] + steps: + - name: Cleanup ${{ matrix.package }} + env: + GH_TOKEN: ${{ secrets.GHCR_CLEANUP_TOKEN }} + OWNER: ${{ github.repository_owner }} + PACKAGE: ${{ matrix.package }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + # 1. Fetch all versions for the package. + gh api "users/${OWNER}/packages/container/${PACKAGE}/versions?per_page=100" \ + --paginate --slurp > all.json + total=$(jq 'add | length' all.json) + echo "Total versions: $total" + + # 2. Build the live-digest set: digests referenced by any tagged + # multi-arch manifest list. Use the registry token (separate from + # GH_TOKEN) for ghcr.io manifest reads. + REG_TOKEN=$(curl -sS \ + "https://ghcr.io/token?scope=repository:${OWNER}/${PACKAGE}:pull&service=ghcr.io" \ + | jq -r .token) + + jq -r 'add | map(select(.metadata.container.tags | length > 0)) + | .[] | .metadata.container.tags[0]' all.json > tags.txt + + : > live.txt + while IFS= read -r tag; do + curl -sS \ + -H "Authorization: Bearer $REG_TOKEN" \ + -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json" \ + "https://ghcr.io/v2/${OWNER}/${PACKAGE}/manifests/${tag}" \ + | jq -r '(.manifests // []) | .[].digest' >> live.txt 2>/dev/null || true + done < tags.txt + sort -u live.txt -o live.txt + echo "Live digests referenced by manifest lists: $(wc -l < live.txt)" + + # 3. Untagged digests minus live = true orphans. + jq -r 'add | map(select(.metadata.container.tags | length == 0)) + | .[] | .name' all.json | sort -u > untagged.txt + comm -23 untagged.txt live.txt > orphan_digests.txt + orphan_count=$(wc -l < orphan_digests.txt) + echo "Orphan digests safe to delete: $orphan_count" + + if [ "$orphan_count" -eq 0 ]; then + echo "Nothing to delete." + exit 0 + fi + + # 4. Map orphan digests -> version IDs. + jq -r --slurpfile orphans <(jq -R . orphan_digests.txt) ' + add + | map(select(.name as $n | ($orphans | flatten | index($n)))) + | .[] | .id + ' all.json > delete_ids.txt + echo "Version IDs queued for deletion: $(wc -l < delete_ids.txt)" + + if [ "${DRY_RUN:-false}" = "true" ]; then + echo "Dry run — not deleting." + head -20 delete_ids.txt + exit 0 + fi + + # 5. Delete. + deleted=0 + failed=0 + while IFS= read -r id; do + if gh api -X DELETE "users/${OWNER}/packages/container/${PACKAGE}/versions/${id}" --silent; then + deleted=$((deleted + 1)) + else + failed=$((failed + 1)) + fi + done < delete_ids.txt + echo "Deleted: $deleted Failed: $failed" + [ "$failed" -eq 0 ]