Skip to content

Automate cleanup of unreachable untagged versions in GHCR #21

Description

@hacker-cb

CLAUDE.md records the consequence but not the remedy:

A pull request therefore leaves untagged blobs in GHCR and moves no tag. They
accumulate; clean them up periodically.

Nothing does. This issue is the mechanism, plus the measurements that constrain it.
Everything below was measured against the live registry on 2026-08-05; the
scripts to reproduce it are in the last section.

Update 2026-08-06 — the backlog was cleared by hand. 426 of 456 package
versions were deleted across the three packages, keeping only the 6898d72
build: six tagged images and their 24 children, 30 versions in total, 0
unreachable. Integrity was verified afterwards — every tag and every child
resolves, all 183 blobs are served, and both digests jetcontrol-cpp pins
(esp-idf@sha256:e79da88e…, esp-matter@sha256:14563823…) still pull.

This changes nothing about the case for automation, and the inventory in §2
stands as the record of the rate at which garbage appears, which is the
actual argument. It simply no longer describes current state: the counter was
reset to zero, and it starts climbing again with the next pull request.

1. Why the obvious implementation destroys the registry

"Untagged" is not a synonym for "garbage". A published tag is an OCI index, and
every one of its children is a separate untagged package version:

idf-v5.5.5  →  sha256:… (application/vnd.oci.image.index.v1+json)
               ├─ sha256:cdca1755…  linux/amd64
               ├─ sha256:36b7d421…  attestation → cdca1755
               ├─ sha256:d78c6832…  linux/arm64
               └─ sha256:e31b0912…  attestation → d78c6832

Across the three packages:

package tags root indices reachable versions of which untagged children
jethome-dev-esp-idf 41 34 99 65
jethome-dev-esp-matter 39 31 97 66
jethome-dev-platformio 34 28 80 52
total 114 93 276 183

183 untagged versions must survive any cleanup. Deleting them leaves all 93
indices with holes while the tags still resolve — the failure is silent until
someone pulls. There are more tags than indices because latest, idf-v5.5.5 and
idf-v5.5.5-sha-bf18308 are three names for one object.

This is exactly what actions/delete-package-versions does with
delete-only-untagged-versions — see
actions/delete-package-versions#162,
open since 2024 with no release since 2024-01. That action is not a candidate.

The correct predicate is reachability, not taggedness: a version is garbage
only if it is untagged and is not a child of any manifest.

2. What actually accumulates

Every build leg pushes by digest (outputs: type=image,…,push-by-digest=true), and
docker buildx imagetools create copies the children of each leg's index into
the new index — not the leg index itself. So each leg's own index is orphaned.
Verified on two real legs, both idf-v5.5.5/linux-arm64:

PR run #17          sha256:fa59aab2…   unreachable
  ├─ f0359bf1…      linux/arm64        unreachable → garbage
  └─ 0eb9a4f0…      attestation        unreachable → garbage

master run (bf18308) sha256:f8c082e5…  unreachable
  ├─ d78c6832…      linux/arm64        reachable → protected
  └─ e31b0912…      attestation        reachable → protected

Note the second case: master runs orphan a leg index too, not just pull
requests. The difference is only in the children — on master they were copied into
the tagged index (d78c6832… and e31b0912… are literally the entries of
idf-v5.5.5 shown in §1), on a pull request they were copied nowhere. So master
costs 1 version per leg, a pull request costs 3.

Full inventory of unreachable untagged versions:

jethome-dev-esp-idf      leg index         38
jethome-dev-esp-idf      platform image    24
jethome-dev-esp-idf      attestation       24
jethome-dev-esp-matter   leg index         18
jethome-dev-platformio   leg index         10
                                          ───
                                          114

The asymmetry confirms the model rather than contradicting it. esp-matter-build
and platformio-build keep push=${{ github.ref_name == 'master' }}, so they only
ever push on master and their children are always adopted — hence leg indices and
nothing else
. esp-idf-build pushes by digest on every run (which is what makes
ESP-Matter validatable on a pull request), so its pull-request legs leave full
triples: 24 PR legs → 24 indices + 24 images + 24 attestations, plus 14 master leg
indices = 86.

Age

2026-08-03   90
2026-08-05   24

Nothing older. 22fdfa2 ("Hand images between jobs by digest, not by tag") landed
on 2026-08-03; before it, legs pushed tagged per-architecture images, so there is
no pre-refactor pile hiding anywhere.

Reclaimed storage

jethome-dev-esp-idf      286 MB
jethome-dev-esp-matter     0 B
jethome-dev-platformio     0 B

Zero for two packages is correct, not a bug in the measurement: a leg index is a few
KB of JSON pointing at children that all remain reachable, so deleting it frees no
layer at all. The 286 MB is the layers unique to the 24 orphaned pull-request
images — small because blobs are content-addressed and shared. Two independent
esp-idf 5.4.1 builds from different eras share 8 of their 12 layers; only the
top layers this repository adds are unique, roughly 12 MB per leg.

So the motive is the version count, not the bytes

At a few gigabytes a year, disk is not the argument. The argument is that 114
garbage versions already outnumber the useful ones from a two-day-old mechanism,
growing by ~12 per image-touching pull request. Left alone, the package version list
stops being readable, and recovery tooling (delete-ghost-images and friends) stops
being usable because damage is no longer distinguishable from litter.

3. The policy this repository needs

  1. Untagged only. Never tagged, and specifically never keep-n-tagged. The
    <prefix>-<version>-sha-<short-commit> tags are documented in the image READMEs
    as the immutable thing to roll back to. A "keep the newest N tags" rule silently
    deletes exactly what they exist for.
  2. Reachability. Children of every index are excluded from candidates before any
    rule is applied.
  3. An age floor. The longest observed gap between a by-digest push and the tag
    that adopts it is ~11 minutes (esp-matter-build consuming an esp-idf-build
    digest). older-than: 7 days makes the race impossible by three orders of
    magnitude. It also means the first real deletion is 2026-08-10 — today the
    policy would delete zero of the 114.

4. Implementation options

Option A — dataaxiom/ghcr-cleanup-action (recommended)

permissions: { packages: write }
concurrency: { group: ghcr-cleanup }   # the action is not parallel-safe per package
steps:
  - uses: dataaxiom/ghcr-cleanup-action@<full-sha>
    with:
      owner: jethome-iot
      packages: jethome-dev-esp-idf,jethome-dev-esp-matter,jethome-dev-platformio
      delete-untagged: true
      older-than: 7 days
      validate: true
      dry-run: true

packages is mandatory here: it defaults to the repository name (jethome-dev),
while the packages are jethome-dev-<image>.

Why it wins, in order of weight:

  • It can be pinned. runs: using: node24, main: dist/index.js, with dist/
    committed and a check-dist.yml that rebuilds it from source in CI and fails on
    any difference. A commit SHA therefore pins the exact bytes that execute — which
    matters more here than anywhere else in this repository, because this is the only
    action that would hold delete rights over the registry.
  • Its model is reachability-first. Step 2 of its documented algorithm removes
    children of multi-arch indices, OCI referrers and cosign artifacts from the
    working set before any rule runs. It also handles the inverse — delete-ghost-images,
    delete-partial-images, delete-orphaned-images — i.e. repairing a registry
    someone already damaged.
  • It is tested end to end. 31 scenarios under tests/ prime a real GHCR
    package, run the action, and assert exact expected-digests and expected-tags
    afterwards — failing both on a missing digest and on a surviving one. The set
    includes attestation-tagged, attestation-untagged, shared-images-tagged,
    shared-images-untagged, 6a_missing-digests-tagged, cosign and
    bare-oci11-referrer.
  • Fail-closed by construction. Manifest fetch errors rethrow to
    core.setFailed, and all analysis precedes all deletion, so a partial read
    deletes nothing.
  • GITHUB_TOKEN is the documented default. No long-lived delete-capable PAT in
    the repository. Per GitHub's docs the token needs admin on the package, granted
    automatically for workflow-published packages — which all three are. (This path is
    still labelled public preview.)
  • Maintained: v1.2.2 on 2026-06-06, last push 2026-08-01, one open issue (its own
    review-tracking ticket).

Option B — snok/container-retention-policy

Rejected, but not because it is careless — its v3.1.0 multi-arch protection is well
built. src/core/filter_out_multi_arch_children.rs clears all deletion
candidates when the registry URL fails to parse, when the client cannot be built,
when manifests cannot be fetched for a package, when the owner cannot be determined,
and select_package_versions.rs clears untagged candidates when pagination was
truncated by rate limiting. I also checked the obvious trap — whether
tag-selection: untagged would leave the "kept tagged" set empty and disable
protection — and it does not: all_tagged is accumulated independently.

What disqualifies it here:

  • It cannot be pinned. action.yaml is
    runs: using: 'docker', image: 'docker://ghcr.io/snok/container-retention-policy:v3.1.0'
    — a mutable tag. Pinning the action by SHA pins only that file, which then pulls
    whatever :v3.1.0 resolves to at execution time. For a delete-capable step there
    is no fix short of forking.
  • Referrers are invisible to it. It walks index children only, so a cosign
    signature or an actions/attest-build-provenance attachment would look like
    garbage to it. Weight this modestly: cosign signing did exist here — added in
    36de032 (2025-10-10, closing
    #1) and deliberately
    removed in 21262ab (2025-10-28) as inappropriate for internal dev images. So
    referrer awareness is insurance against reversing a settled decision, not a
    pending requirement.
  • Thinner evidence: one 92-line integration test file.
  • Token handling has two open issues from August 2024 —
    #96 (GITHUB_TOKEN
    failing on a package list) and
    #94 (app tokens
    conflated with PATs) — both on the path this repository would use. Release cadence
    is 2024-06 → 2025-09 → 2026-05.

Its genuine advantages, for the record: a FROM scratch non-root Rust binary is a
smaller runtime surface than Node plus node_modules, and it fetches manifests only
for kept tags, so it inflates GHCR download counts less (relevant because a public
package with any version over 5,000 downloads cannot be deleted at all).

Option C — a script in scripts/

scripts/ghcr-garbage.sh already exists in draft (see §7) and computes the same
predicate: reachability from every tag via the registry API, versions via
/orgs/{owner}/packages/container/{pkg}/versions, minus an age floor. ~70 lines,
no third party on the delete path, and it fits how the rest of this repository is
built (check-versions.sh, update-matter-ref.sh).

Against it: it would have to grow pagination, rate-limit handling, referrer
awareness and restore-ID logging to match Option A, and every one of those is a
place to get registry deletion wrong.

The two are not exclusive, and that is probably the answer: Option A deletes,
Option C runs first in report-only mode as an independent cross-check. If the two
lists disagree, nothing is deleted until we know why.

5. A safety net neither action can offer

Only this repository knows what must be published: scripts/versions-matrix.sh
generates the complete tag list from images/versions.json. A post-cleanup step can
therefore re-resolve every tag × every platform with docker buildx imagetools inspect and fail the job on the first unreachable one.

That matters because validate: true only warns, and because GitHub's restore
window for a deleted package version is 30 days — with the version IDs printed
in the run log. A loud failure inside that window turns a catastrophe into an
incident; a silent one does not.

6. Decisions this needs before it can be implemented

  • Pin by SHA or by floating major. CLAUDE.md states the convention: actions
    are pinned to @v<N> with Dependabot raising the major. A delete-capable action
    is the case for deviating. Whichever way it goes, CLAUDE.md needs the sentence.
  • Cadence. Weekly at minimum — the action caches distilled manifests via
    @actions/cache, and GitHub evicts entries unread for 7 days, so a longer gap
    makes every run a cold one.
  • The 51 legacy -linux-<arch> tags — 18 in esp-idf, 17 in esp-matter, 16 in
    platformio, left over from before 22fdfa2. They are tagged, so an
    untagged-only policy never touches them; removing them is a separate one-time
    delete-tags: *-linux-amd64,*-linux-arm64, and a separate decision about whether
    anything still pulls them. Related: CLAUDE.md says "There are no
    -linux-<arch> tags in GHCR any more", which is true of the workflows and false
    of the registry.
    Not a decision, but worth stating so it is not re-litigated: image signing is
    settled. Cosign was added in 36de032 and removed in 21262ab as inappropriate for
    internal dev images, so no referrer artifacts exist in these packages today and none
    are expected. The only attestations present are buildx's inline provenance, which
    lives as index entries and is covered by plain reachability.

7. Reproducing the measurements

Needs a token with read:packages (gh auth refresh -s read:packages) — the
Packages API rejects packages: write alone for reads.

Draft scripts used for everything above:

  • ghcr-garbage.sh — prints every version that the policy would delete, deleting
    nothing. This is the Option C candidate for scripts/.
  • ghcr-detail.sh — the same set broken down by package, manifest kind and date.
  • ghcr-size.sh — blobs referenced only by garbage, summed.

Reachability alone needs no special scope and can be recomputed from the registry:
list tags, HEAD each manifest for its own digest, union with .manifests[].digest.

Trigger

The 2026-08-06 sweep reset the count to zero, so there is nothing to delete today
and a run would be a no-op — which makes now the easy time to land it: with an
empty backlog, a dry-run that proposes anything at all is a bug in the
configuration rather than a judgement call about what to keep.

After that, revisit when the count becomes annoying again. It grows by ~12 versions
per image-touching pull request and ~12 per full master run, so a month of ordinary
activity is enough to rebuild the pile that was just cleared.


Source: registry and Packages API measurements taken 2026-08-05 against
jethome-iot/jethome-dev-{esp-idf,esp-matter,platformio}; workflow behaviour from
.github/workflows/esp-idf.yml and .github/workflows/platformio.yml at bf18308.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions