Workflow: .github/workflows/cleanup-ghcr.yml runs Sundays at 03:00 UTC across both packages, with a manual workflow_dispatch and a dry_run toggle. It builds the live-digest

set the same way we just did, so it won't ever delete a digest still referenced by a tagged manifest.
This commit is contained in:
maziggy
2026-05-01 09:41:01 +02:00
parent ddf3dc0c84
commit 44e4b6b5d7
+103
View File
@@ -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 ]