← Standards

image-cleanup

GitLab Container Registry retains every push by default. Repos that publish on every main commit (catalog ci-* images, app image rebuilds) accumulate one image per push indefinitely. The cleanup policy keeps the registry footprint bounded:

  • Keep all release/promoted-tag images (vX.Y.Z, X.Y.Z, with optional pre-release/build suffixes) and their cosign signatures.
  • Keep the last N short-sha builds (default: 10).
  • Drop everything older.

The policy is configured at the project level via GitLab's UI or API.

Enabled is not sufficient. A cleanup policy with enabled: true and a name_regex_keep that doesn't match the shape of the tags the project actually publishes is worse than no policy: it looks conformant, GitLab runs it on schedule, and it deletes the images the project meant to keep. enabled == true alone cannot catch this class of bug; only checking the regex against real tags can. See ./check.sh for the incident this generalizes from.

The estate template

^(latest|v?\d+\.\d+\.\d+([-.][\w.-]+)?|sha256-[a-f0-9]{64}(\.(sig|att))?)$

Applied as name_regex_keep across 14 projects as of 2026-08, widened 2026-09-28 for the referrers-fallback shape below. Each alternation exists for a specific, load-bearing reason:

  • v?: the estate is deliberately split between v-prefixed (v2.11.4) and bare (2.11.4) tag conventions across different projects. The ? makes the pattern work for either without a per-project fork.
  • ([-.][\w.-]+)?: the promotion-suffix group. A tag isn't always a clean X.Y.Z: promoted/repromoted builds carry suffixes like -2 (v2.11.4-2, the caddy shape), and pre-release or build metadata looks like -rc.1 or +build.5. Without this group, a keep-pattern anchored on bare X.Y.Z matches the first promotion of a version and silently drops every repromotion after it, which is the exact caddy failure mode, since GitLab's cleanup job doesn't care that a tag is "basically a release," only whether the regex matches the literal string.
  • sha256-[a-f0-9]{64}(\.(sig|att))?: cosign keyless-signing artifacts, both shapes cosign has used. Classic cosign publishes the signature and the SLSA attestation as their own sibling registry tags (sha256-<digest>.sig, sha256-<digest>.att), not annotations on the image tag. Current cosign instead publishes a single OCI 1.1 referrers-fallback tag — a bare sha256-<digest> naming an image index whose entry carries the signature/attestation as an OCI artifact — whenever the registry doesn't serve the Referrers API, which is every GitLab registry in the estate. Neither shape looks like a version tag, so a keep-pattern written only against version-looking tags never matches either: they age out on the same schedule as short-sha builds, silently detaching signatures and attestations from images that are otherwise still kept. This is easy to miss in review because the image tag looks protected; the signature tag next to it is not. The digest group is {64}, not +: a sha256 hex digest is always exactly 64 characters, and nothing else in the estate's own pipelines tags an image sha256-<hex> for any other reason, so anchoring the length keeps the alternation from ever protecting a tag that only happens to start with sha256-. othala's registry lost the referrers-fallback signature for v0.4.0, v0.5.0 and v0.6.0 to the narrower pattern before this widening; v0.6.1 and v0.6.2 (verified present, 2026-09-28) were next on its 7-day cleanup cadence.
  • latest: the floating pointer tag, kept unconditionally so image:latest always resolves.

Adapting the template. When a project needs its own carve-outs, add alternations rather than replacing the shape above: stable and any project-specific keep-.* prefix convention (several cairn/ and immutable/ projects use keep-<label> to pin an image outside the normal rotation) both compose cleanly inside the same alternation group, e.g.:

^(latest|stable|keep-.*|v?\d+\.\d+\.\d+([-.][\w.-]+)?|sha256-[a-f0-9]{64}(\.(sig|att))?)$

What this standard checks

Generated from ./check.sh, in the order the check runs them.

assertion target pattern or value note
applies at all, or stops the run *Containerfile* or *Dockerfile* or .gitlab-ci.yml:gitlab\.com/dunn\.dev/pipeline/container-image@ a repo that pushes no image has an empty registry, so a cleanup policy has nothing to keep or delete; governs projects that push images to the container registry
GitLab setting reads container_expiration_policy.enabled true with no policy running, every short-sha build stays in the registry forever
registry cleanup covers live tags container_expiration_policy.name_regex_keep matches every release and cosign tag, and not every tag a keep-regex that matches no live tag deletes the images production is running

Two shapes sit outside the coverage assertion rather than failing it. A policy deliberately left enabled: false skips coverage entirely, an operator decision answered by a deviation against the enabled assertion rather than by a red check. A repository holding exactly one tag (the pinned vendor mirrors, 36 of them in dunn.dev/images) cannot accumulate the garbage the assertion exists to catch.

How to satisfy

GitLab UI: Project → Deploy → Container Registry → Cleanup policy. Set: enabled, keep the N most recent tags, keep tags matching the estate template above, remove tags older than 30 days.

Or via API:

glab api -X PUT projects/${PROJECT_ID}/container_expiration_policy \
  -f enabled=true \
  -f keep_n=10 \
  -f name_regex_keep='^(latest|v?\d+\.\d+\.\d+([-.][\w.-]+)?|sha256-[a-f0-9]{64}(\.(sig|att))?)$' \
  -f older_than=30d

After setting it, verify against the project's real tags before trusting it. A regex that looks right by inspection is exactly how the caddy incident happened:

glab api "projects/${PROJECT_ID}/registry/repositories" | \
  jq -r '.[].id' | while read -r rid; do
    glab api "projects/${PROJECT_ID}/registry/repositories/${rid}/tags" --paginate --output ndjson
  done | jq -r '.name'

Every release-shaped tag and every sha256-<64hex> tag — bare or .sig/.att-suffixed — in that output must match name_regex_keep, or it will be pruned on the next cleanup run.

Deviations

A deviation is for a project with a registry that does not meet an assertion; a repo that pushes no image reports N/A instead.

fetch as markdown
curl https://reference.dunn.dev/standards/image-cleanup/index.md
check conformance
just -f ~/gitlab.com/dunn.dev/reference/justfile check