| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
- 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 ]
|