cleanup-ghcr.yml 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  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. # This workflow authenticates exclusively via GHCR_CLEANUP_TOKEN (a classic PAT)
  20. # and never reads/writes via the default GITHUB_TOKEN. Strip every permission
  21. # from the GITHUB_TOKEN so a stolen workflow run can't reach the repo at all
  22. # — least privilege per CodeQL `actions/missing-workflow-permissions`.
  23. permissions: {}
  24. jobs:
  25. cleanup:
  26. runs-on: ubuntu-latest
  27. strategy:
  28. fail-fast: false
  29. matrix:
  30. package: [bambuddy, bambuddy-beta]
  31. steps:
  32. - name: Cleanup ${{ matrix.package }}
  33. env:
  34. GH_TOKEN: ${{ secrets.GHCR_CLEANUP_TOKEN }}
  35. OWNER: ${{ github.repository_owner }}
  36. PACKAGE: ${{ matrix.package }}
  37. DRY_RUN: ${{ inputs.dry_run }}
  38. run: |
  39. set -euo pipefail
  40. # 1. Fetch all versions for the package.
  41. gh api "users/${OWNER}/packages/container/${PACKAGE}/versions?per_page=100" \
  42. --paginate --slurp > all.json
  43. total=$(jq 'add | length' all.json)
  44. echo "Total versions: $total"
  45. # 2. Build the live-digest set: digests referenced by any tagged
  46. # multi-arch manifest list. Use the registry token (separate from
  47. # GH_TOKEN) for ghcr.io manifest reads.
  48. REG_TOKEN=$(curl -sS \
  49. "https://ghcr.io/token?scope=repository:${OWNER}/${PACKAGE}:pull&service=ghcr.io" \
  50. | jq -r .token)
  51. jq -r 'add | map(select(.metadata.container.tags | length > 0))
  52. | .[] | .metadata.container.tags[0]' all.json > tags.txt
  53. : > live.txt
  54. while IFS= read -r tag; do
  55. curl -sS \
  56. -H "Authorization: Bearer $REG_TOKEN" \
  57. -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" \
  58. "https://ghcr.io/v2/${OWNER}/${PACKAGE}/manifests/${tag}" \
  59. | jq -r '(.manifests // []) | .[].digest' >> live.txt 2>/dev/null || true
  60. done < tags.txt
  61. sort -u live.txt -o live.txt
  62. echo "Live digests referenced by manifest lists: $(wc -l < live.txt)"
  63. # 3. Untagged digests minus live = true orphans.
  64. jq -r 'add | map(select(.metadata.container.tags | length == 0))
  65. | .[] | .name' all.json | sort -u > untagged.txt
  66. comm -23 untagged.txt live.txt > orphan_digests.txt
  67. orphan_count=$(wc -l < orphan_digests.txt)
  68. echo "Orphan digests safe to delete: $orphan_count"
  69. if [ "$orphan_count" -eq 0 ]; then
  70. echo "Nothing to delete."
  71. exit 0
  72. fi
  73. # 4. Map orphan digests -> version IDs.
  74. jq -r --slurpfile orphans <(jq -R . orphan_digests.txt) '
  75. add
  76. | map(select(.name as $n | ($orphans | flatten | index($n))))
  77. | .[] | .id
  78. ' all.json > delete_ids.txt
  79. echo "Version IDs queued for deletion: $(wc -l < delete_ids.txt)"
  80. if [ "${DRY_RUN:-false}" = "true" ]; then
  81. echo "Dry run — not deleting."
  82. head -20 delete_ids.txt
  83. exit 0
  84. fi
  85. # 5. Delete.
  86. deleted=0
  87. failed=0
  88. while IFS= read -r id; do
  89. if gh api -X DELETE "users/${OWNER}/packages/container/${PACKAGE}/versions/${id}" --silent; then
  90. deleted=$((deleted + 1))
  91. else
  92. failed=$((failed + 1))
  93. fi
  94. done < delete_ids.txt
  95. echo "Deleted: $deleted Failed: $failed"
  96. [ "$failed" -eq 0 ]