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
- 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.
- Reachability. Children of every index are excluded from candidates before any
rule is applied.
- 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.
CLAUDE.mdrecords the consequence but not the remedy: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.
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:
Across the three packages:
jethome-dev-esp-idfjethome-dev-esp-matterjethome-dev-platformio183 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.5andidf-v5.5.5-sha-bf18308are three names for one object.This is exactly what
actions/delete-package-versionsdoes withdelete-only-untagged-versions— seeactions/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), anddocker buildx imagetools createcopies the children of each leg's index intothe 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: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…ande31b0912…are literally the entries ofidf-v5.5.5shown in §1), on a pull request they were copied nowhere. So mastercosts 1 version per leg, a pull request costs 3.
Full inventory of unreachable untagged versions:
The asymmetry confirms the model rather than contradicting it.
esp-matter-buildand
platformio-buildkeeppush=${{ github.ref_name == 'master' }}, so they onlyever push on master and their children are always adopted — hence leg indices and
nothing else.
esp-idf-buildpushes by digest on every run (which is what makesESP-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
Nothing older.
22fdfa2("Hand images between jobs by digest, not by tag") landedon 2026-08-03; before it, legs pushed tagged per-architecture images, so there is
no pre-refactor pile hiding anywhere.
Reclaimed storage
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-imagesand friends) stopsbeing usable because damage is no longer distinguishable from litter.
3. The policy this repository needs
keep-n-tagged. The<prefix>-<version>-sha-<short-commit>tags are documented in the image READMEsas the immutable thing to roll back to. A "keep the newest N tags" rule silently
deletes exactly what they exist for.
rule is applied.
that adopts it is ~11 minutes (
esp-matter-buildconsuming anesp-idf-builddigest).
older-than: 7 daysmakes the race impossible by three orders ofmagnitude. 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)packagesis mandatory here: it defaults to the repository name (jethome-dev),while the packages are
jethome-dev-<image>.Why it wins, in order of weight:
runs: using: node24, main: dist/index.js, withdist/committed and a
check-dist.ymlthat rebuilds it from source in CI and fails onany 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.
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 registrysomeone already damaged.
tests/prime a real GHCRpackage, run the action, and assert exact
expected-digestsandexpected-tagsafterwards — 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,cosignandbare-oci11-referrer.core.setFailed, and all analysis precedes all deletion, so a partial readdeletes nothing.
GITHUB_TOKENis the documented default. No long-lived delete-capable PAT inthe repository. Per GitHub's docs the token needs
adminon the package, grantedautomatically for workflow-published packages — which all three are. (This path is
still labelled public preview.)
review-tracking ticket).
Option B —
snok/container-retention-policyRejected, but not because it is careless — its v3.1.0 multi-arch protection is well
built.
src/core/filter_out_multi_arch_children.rsclears all deletioncandidates 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.rsclears untagged candidates when pagination wastruncated by rate limiting. I also checked the obvious trap — whether
tag-selection: untaggedwould leave the "kept tagged" set empty and disableprotection — and it does not:
all_taggedis accumulated independently.What disqualifies it here:
action.yamlisruns: 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.0resolves to at execution time. For a delete-capable step thereis no fix short of forking.
signature or an
actions/attest-build-provenanceattachment would look likegarbage 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. Soreferrer awareness is insurance against reversing a settled decision, not a
pending requirement.
#96 (
GITHUB_TOKENfailing 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 scratchnon-root Rust binary is asmaller runtime surface than Node plus
node_modules, and it fetches manifests onlyfor 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.shalready exists in draft (see §7) and computes the samepredicate: 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.shgenerates the complete tag list from
images/versions.json. A post-cleanup step cantherefore re-resolve every tag × every platform with
docker buildx imagetools inspectand fail the job on the first unreachable one.That matters because
validate: trueonly warns, and because GitHub's restorewindow 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
CLAUDE.mdstates the convention: actionsare pinned to
@v<N>with Dependabot raising the major. A delete-capable actionis the case for deviating. Whichever way it goes,
CLAUDE.mdneeds the sentence.@actions/cache, and GitHub evicts entries unread for 7 days, so a longer gapmakes every run a cold one.
-linux-<arch>tags — 18 in esp-idf, 17 in esp-matter, 16 inplatformio, left over from before
22fdfa2. They are tagged, so anuntagged-only policy never touches them; removing them is a separate one-time
delete-tags: *-linux-amd64,*-linux-arm64, and a separate decision about whetheranything still pulls them. Related:
CLAUDE.mdsays "There are no-linux-<arch>tags in GHCR any more", which is true of the workflows and falseof the registry.
Not a decision, but worth stating so it is not re-litigated: image signing is
settled. Cosign was added in
36de032and removed in21262abas inappropriate forinternal 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) — thePackages API rejects
packages: writealone for reads.Draft scripts used for everything above:
ghcr-garbage.sh— prints every version that the policy would delete, deletingnothing. 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,
HEADeach 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-runthat proposes anything at all is a bug in theconfiguration 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.ymland.github/workflows/platformio.ymlatbf18308.