From 4b2c97df4e83c82d01dcd8519579ea38bfbc972c Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 2 Sep 2026 23:47:22 -0700 Subject: [PATCH] Prune old images, and keep every release Two artefacts, opposite answers. Releases stay. They carry no assets -- the image lives in GHCR -- so one costs a tag, a title and generated notes, and with no CHANGELOG in this repository those notes are the only changelog there is. Deleting one destroys history that cannot be reconstructed, and saves nothing. Images accumulate: a multi-architecture build a week, and the by-digest push leaves two untagged per-architecture manifests behind each time on top of the tagged index. Ten tagged versions are kept, which is roughly a quarter of releases and far more than anything anyone rolls back to. The obvious tool for this is a trap. delete-package-versions with `delete-only-untagged-versions` will delete the per-architecture manifests that a multi-arch tag points at, because they are untagged by design, and nothing appears to break: the tag still resolves and pulls simply start failing for one architecture. This action understands manifest lists and leaves a retained index's children alone, `validate` re-checks every multi-arch manifest against the registry afterwards, and `latest` is excluded from consideration entirely. It is pinned to a commit rather than a major tag. It holds `packages: write` and its whole purpose is deletion, so a tag repointed upstream is not a risk worth carrying for the convenience. Kept in its own file and dispatchable, so a dry run can show exactly what would go without rebuilding and re-pushing an image to find out. --- .github/workflows/cleanup.yml | 68 +++++++++++++++++++++++++++++++++++ .github/workflows/publish.yml | 8 +++++ 2 files changed, 76 insertions(+) create mode 100644 .github/workflows/cleanup.yml diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 0000000..fb9ddbb --- /dev/null +++ b/.github/workflows/cleanup.yml @@ -0,0 +1,68 @@ +# Prune old image versions from GHCR. +# +# Releases are kept forever -- they carry no assets and their generated notes +# are this project's only changelog, so deleting one destroys history that +# cannot be reconstructed for nothing saved. Images are the opposite: a +# multi-arch build a week, and the by-digest push in publish.yml leaves two +# untagged per-architecture manifests behind each time on top of the tagged +# index. Those accumulate and nobody wants fifty of them. +# +# THE FOOTGUN: the obvious tool for this -- delete-package-versions with +# `delete-only-untagged-versions` -- will happily delete the per-architecture +# manifests that a multi-arch tag points *at*, because they are untagged by +# design. Nothing appears to break: the tag still exists, and pulls simply +# start failing for one architecture. This action understands manifest lists +# and will not orphan a retained index, and `validate` re-checks every +# multi-arch manifest against the registry afterwards. +# +# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show +# exactly what would be deleted without rebuilding and re-pushing an image to +# find out. +name: Prune images + +on: + workflow_call: + inputs: + dry_run: + type: boolean + default: false + workflow_dispatch: + inputs: + dry_run: + description: "List what would be deleted, delete nothing" + type: boolean + default: true + +jobs: + prune: + runs-on: ubuntu-latest + permissions: + packages: write + steps: + # Pinned to a commit rather than a moving major tag. This action is + # handed `packages: write` and its whole job is deletion, so a tag + # repointed at something else -- by a compromise or a mistake upstream -- + # is a bad day. v1.2.2. + - uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f + with: + owner: Coffey-Labs + package: ihasmail + token: ${{ secrets.GITHUB_TOKEN }} + # Ten weekly releases is roughly a quarter of history, which is more + # than enough to roll back to and far less than the year's worth that + # would otherwise pile up. Older *releases* stay either way; this + # only removes the images. + keep-n-tagged: 10 + # Belt and braces on top of the action's own manifest awareness: + # `latest` is never a candidate for deletion under any counting. + exclude-tags: latest + delete-untagged: true + # Sweeps the wreckage of a half-failed run: an index whose platform + # images did not all land, and referrers whose parent is gone. + delete-partial-images: true + delete-orphaned-images: true + # Checks every remaining multi-architecture manifest still resolves + # in the registry. This is the step that would catch the footgun + # above rather than leaving a reader to discover it on `docker pull`. + validate: true + dry-run: ${{ inputs.dry_run }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 53f3044..21e5d41 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -192,3 +192,11 @@ jobs: docker buildx imagetools create "${tags[@]}" "${refs[@]}" - name: Show what landed run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.docker_tag }}" + + # Runs only after a successful publish, because that is the only moment the + # package grows. See cleanup.yml for why this is not the obvious one-liner. + prune: + needs: publish + permissions: + packages: write + uses: ./.github/workflows/cleanup.yml