A GitHub Action that deletes container images from the GitHub Container Registry (ghcr.io). It safely handles multi-architecture images, attestation and Sigstore cosign referrers, and supports flexible retention rules including tag patterns, age, and "keep the N most recent" policies.
With no options set, the action deletes all untagged images from the current
repository's package. Always test with dry-run: true first.
jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: dataaxiom/ghcr-cleanup-action@v1
with:
dry-run: true
When owner and package are omitted, they default to the current workflow's
project. Deleting a multi-architecture image also deletes its child platform
images automatically.
name: Daily Image Cleanup
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: dataaxiom/ghcr-cleanup-action@v1
name: Cleanup PR Images
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: dataaxiom/ghcr-cleanup-action@v1
with:
delete-tags: pr-${{ github.event.pull_request.number }}
jobs:
cleanup:
runs-on: ubuntu-latest
concurrency:
group: cleanup-images
permissions:
packages: write
steps:
- uses: dataaxiom/ghcr-cleanup-action@v1
with:
packages: 'tiecd/k8s'
delete-tags: '*-rc*'
keep-n-tagged: 3
dev, drop everything elsejobs:
cleanup:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: dataaxiom/ghcr-cleanup-action@v1
with:
keep-n-tagged: 10
exclude-tags: dev
delete-untagged: true
delete-partial-images: true
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: dataaxiom/ghcr-cleanup-action@v1
with:
token: ${{ secrets.MY_PAT }}
owner: dataaxiom
package: tiecd
delete-tags: '^mytag[12]$'
use-regex: true
repository is omitted — the action looks up the package by owner + package
directly, so the calling workflow's repository can be anywhere. This also works
when the target package isn't linked to any repository at all.
| Option | Default | Description |
|---|---|---|
token |
secrets.GITHUB_TOKEN |
Token used to call ghcr.io and the Package API. |
owner |
project owner | GitHub user or organization that owns the package. |
repository |
repository name | Informational only. Shown in logs; does not affect which packages are touched. Safe to omit. |
package(s) |
repository name | Comma-separated list of packages. Accepts package or packages. Wildcards require a PAT (below). |
expand-packages |
false |
Enable wildcard / regular expression matching on package(s). Requires a PAT for token. |
| Option | Default | Description |
|---|---|---|
delete-tags |
Comma-separated tags to delete (wildcards by default; regular expression if use-regex: true). Alias: tags. |
|
exclude-tags |
Tags to always preserve. Takes priority over every other rule. | |
delete-untagged |
depends * | Delete all untagged images. |
keep-n-untagged |
Number of untagged images to keep, newest first. | |
keep-n-tagged |
Number of tagged images to keep, newest first. | |
delete-ghost-images |
false |
Delete multi-arch images whose platform children are all missing. |
delete-partial-images |
false |
Delete multi-arch images whose platform children are partially missing. |
delete-orphaned-images |
false |
Delete tagged referrer / cosign images whose parent no longer exists. |
older-than |
Only include images older than this interval (e.g. 5 days, 6 months, 1 year). |
* When no delete or keep options are set, delete-untagged defaults to true.
| Option | Default | Description |
|---|---|---|
use-regex |
false |
Treat delete-tags, exclude-tags, and package(s) as regular expression patterns. |
skip-regex-checks |
false |
Skip the length and ReDoS safety checks on regular expression patterns. Use only for intentionally large/complex patterns. |
dry-run |
false |
Log everything that would be deleted without making changes. |
validate |
false |
After cleanup, verify all multi-arch images have their platform children. |
registry-url |
https://ghcr.io/ |
Container registry URL. |
github-api-url |
https://api.github.com |
GitHub API URL. |
log-level |
info |
One of error, warn, info, debug. |
?,
*, **) by default. Set use-regex: true to use regular expressions
instead.sha256: digest strings.delete-tagsDeletes the matching tags. If a tag points to an image that also has other tags, the action unlinks the tag without deleting the underlying image — the image is removed only once all its tags are gone.
exclude-tagsTags listed here are excluded from every other rule. Useful as a safety net
alongside keep-n-tagged or older-than.
with:
exclude-tags: dev,latest,pr*
keep-n-tagged: 10
delete-untaggedDeletes every untagged image. This is the default when no other rules are set,
but it can also be combined explicitly with any rule except keep-n-untagged.
keep-n-untaggedSorts untagged images by date and keeps the newest N. Setting it to 0 is
equivalent to delete-untagged: true. Cannot be combined with
delete-untagged.
keep-n-taggedSorts tagged images by date and keeps the newest N. By default it operates on
all tagged images; combine with delete-tags to restrict it to a subset, or
with exclude-tags to protect specific tags.
older-thanRestricts every delete and keep rule to images older than the given interval.
Accepts singular and plural units: seconds, minutes, hours, days,
weeks, months, years (see
human-interval).
with:
older-than: 1 year
keep-n-tagged: 10
The example keeps every image younger than a year, plus the 10 newest images older than a year.
delete-ghost-images / delete-partial-imagesClean up multi-architecture images whose platform children are missing. Run with
validate: true first to see which images would be affected.
delete-orphaned-imagesFinds tagged images named sha256-… whose corresponding sha256: digest no
longer exists, and deletes them. Catches stranded referrer and cosign artifacts.
GITHUB_TOKEN (default)Grant the workflow packages: write:
permissions:
packages: write
Alternatively, set Settings → Actions → General → Workflow permissions to "Read and write permissions" for the repository.
The package itself must also grant the workflow's repository the Admin role under Package Settings → Manage Actions access.
A PAT is required when:
expand-packages: true is used.Create a Classic PAT (the GitHub Registry API does not yet support
fine-grained tokens) with both write:packages and delete:packages scopes,
and pass it via token:
with:
token: ${{ secrets.MY_PAT }}
The action is not safe to run in parallel against the same package. Use a GitHub
concurrency group on busy repositories:
concurrency:
group: cleanup-images
validate: true performs a post-run scan of every multi-architecture image and
warns if any platform children are missing. It is informational — the action
does not fail on warnings.
package (or packages) accepts a comma-separated list. To match by wildcard
or regular expression, set expand-packages: true and use a PAT:
with:
packages: myimage*,someotherimage
expand-packages: true
token: ${{ secrets.MY_PAT }}
The action caches distilled manifest data between runs via @actions/cache,
keyed by digest. Manifests are content-addressed, so cache hits are correct by
construction — staleness is impossible. Warm runs only fetch manifests for
digests that aren't already in the cache; on a stable repository that's a
handful of new manifests per run instead of the entire package.
The cache hit rate is logged at the end of every run, e.g.
manifest cache: 5234/5294 digests served from cache (99%).
GitHub evicts cache entries that haven't been read in 7 days. On a very large repository the first run is expensive — it fetches every manifest to warm the cache. If the action then sits idle for more than 7 days, that warmed entry is evicted and the next run pays the cold-start cost again.
To stay fast on large repos, schedule the action to run at least every 7 days. A weekly schedule (or a daily run that may end up no-op) is enough to refresh the cache's last-access timestamp and avoid eviction.
Cache failures degrade gracefully — if @actions/cache is unavailable or a
restore/save fails, the action falls back to fetching manifests live without
failing the workflow.
GitHub records each manifest fetch as a download against the package. On a cold run (no warm manifest cache) the action fetches every manifest, so the package's download count rises by one per package version. On a warm run only the uncached manifests are fetched — for a stable repository that's a small fraction of the total per run, often single digits. The underlying image layers are never downloaded.
The workflow log prints the ID of every deleted package version. Use the GitHub REST API to restore one:
$ claude mcp add ghcr-cleanup-action \
-- python -m otcore.mcp_server <graph>