cleanup-ghcr.yml 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. name: Cleanup GHCR untagged images
  2. # Deletes untagged (orphan) container versions from GHCR while preserving
  3. # any digest that is still referenced by a tagged multi-arch manifest list.
  4. # Without that cross-reference, off-the-shelf cleanup actions can silently
  5. # break multi-arch tags by deleting referenced platform manifests.
  6. #
  7. # Requires a repo secret `GHCR_CLEANUP_TOKEN` — a classic PAT with
  8. # `read:packages` + `delete:packages` scope. GITHUB_TOKEN cannot delete
  9. # versions of user-owned packages (only org-owned).
  10. on:
  11. schedule:
  12. - cron: '0 3 * * 0' # Sundays at 03:00 UTC
  13. workflow_dispatch:
  14. inputs:
  15. dry_run:
  16. description: 'List orphans without deleting'
  17. type: boolean
  18. default: false
  19. jobs:
  20. cleanup:
  21. runs-on: ubuntu-latest
  22. strategy:
  23. fail-fast: false
  24. matrix:
  25. package: [bambuddy, bambuddy-beta]
  26. steps:
  27. - name: Cleanup ${{ matrix.package }}
  28. env:
  29. GH_TOKEN: ${{ secrets.GHCR_CLEANUP_TOKEN }}
  30. OWNER: ${{ github.repository_owner }}
  31. PACKAGE: ${{ matrix.package }}
  32. DRY_RUN: ${{ inputs.dry_run }}
  33. run: |
  34. set -euo pipefail
  35. # 1. Fetch all versions for the package.
  36. gh api "users/${OWNER}/packages/container/${PACKAGE}/versions?per_page=100" \
  37. --paginate --slurp > all.json
  38. total=$(jq 'add | length' all.json)
  39. echo "Total versions: $total"
  40. # 2. Build the live-digest set: digests referenced by any tagged
  41. # multi-arch manifest list. Use the registry token (separate from
  42. # GH_TOKEN) for ghcr.io manifest reads.
  43. REG_TOKEN=$(curl -sS \
  44. "https://ghcr.io/token?scope=repository:${OWNER}/${PACKAGE}:pull&service=ghcr.io" \
  45. | jq -r .token)
  46. jq -r 'add | map(select(.metadata.container.tags | length > 0))
  47. | .[] | .metadata.container.tags[0]' all.json > tags.txt
  48. : > live.txt
  49. while IFS= read -r tag; do
  50. curl -sS \
  51. -H "Authorization: Bearer $REG_TOKEN" \
  52. -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" \
  53. "https://ghcr.io/v2/${OWNER}/${PACKAGE}/manifests/${tag}" \
  54. | jq -r '(.manifests // []) | .[].digest' >> live.txt 2>/dev/null || true
  55. done < tags.txt
  56. sort -u live.txt -o live.txt
  57. echo "Live digests referenced by manifest lists: $(wc -l < live.txt)"
  58. # 3. Untagged digests minus live = true orphans.
  59. jq -r 'add | map(select(.metadata.container.tags | length == 0))
  60. | .[] | .name' all.json | sort -u > untagged.txt
  61. comm -23 untagged.txt live.txt > orphan_digests.txt
  62. orphan_count=$(wc -l < orphan_digests.txt)
  63. echo "Orphan digests safe to delete: $orphan_count"
  64. if [ "$orphan_count" -eq 0 ]; then
  65. echo "Nothing to delete."
  66. exit 0
  67. fi
  68. # 4. Map orphan digests -> version IDs.
  69. jq -r --slurpfile orphans <(jq -R . orphan_digests.txt) '
  70. add
  71. | map(select(.name as $n | ($orphans | flatten | index($n))))
  72. | .[] | .id
  73. ' all.json > delete_ids.txt
  74. echo "Version IDs queued for deletion: $(wc -l < delete_ids.txt)"
  75. if [ "${DRY_RUN:-false}" = "true" ]; then
  76. echo "Dry run — not deleting."
  77. head -20 delete_ids.txt
  78. exit 0
  79. fi
  80. # 5. Delete.
  81. deleted=0
  82. failed=0
  83. while IFS= read -r id; do
  84. if gh api -X DELETE "users/${OWNER}/packages/container/${PACKAGE}/versions/${id}" --silent; then
  85. deleted=$((deleted + 1))
  86. else
  87. failed=$((failed + 1))
  88. fi
  89. done < delete_ids.txt
  90. echo "Deleted: $deleted Failed: $failed"
  91. [ "$failed" -eq 0 ]