Skip to content

chore(deploy): prune old git-* image tags after a healthy deploy - #115

Merged
h3nr1-d14z merged 2 commits into
stagingfrom
chore/deploy-prune-old-image-tags
Sep 23, 2026
Merged

h3nr1-d14z merged 2 commits into
stagingfrom
chore/deploy-prune-old-image-tags

Conversation

@h3nr1-d14z

@h3nr1-d14z h3nr1-d14z commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

What

Adds step 6 to deployments/selfhost/deploy.sh: after the deploy is confirmed
healthy, prune old :git-* image tags per repo. Untagging is the only removal
the step performs — nothing sweeps the dangling layers it orphans.

Why

Every deploy tags all 6 images twice — :$TAG and :git-$SHA — and nothing ever
removed the :git-* half, so each deploy added a permanent set. ~100 stale tags
had built up on sv-2, whose root disk reached 86%. docker builder prune
(step 5) does not touch them: these are tagged images, not builder cache.

Protections

Staging (:staging) and production (:companymain) deploy on the same host
and share these repositories, so a naive keep-the-newest-N would eventually take
out the other environment's image. Four rules, cheapest first:

# Rule Why it exists
1 Only :git-* tags are candidates :$TAG, :staging, :companymain are out of scope by construction
2 Skip any image referenced by any container, running or stopped, any project docker compose up -d recreates from the image on disk; docker start fails on a pruned one
3 Skip any image a non-git- tag of the same repo resolves to :staging / :companymain survive while no container references them (between deploys, or after docker compose down)
4 Keep the newest $KEEP_GIT_TAGS (new env var, default 5) A rollback to a recent build needs no rebuild

Rule 3 is scoped per repo by the repo: prefix, so plane-backend:staging cannot
protect a plane-frontend tag that happens to share an image ID.

docker rmi is called without -f: if any rule above missed a case, the
daemon refuses rather than yanking an image out from under a live container. A
refusal is logged as kept and never retried with -f.

No docker image prune — scope, and redundant anyway

Untagging is the only removal this step performs. There is deliberately no
docker image prune
call here.

sv-2 shares one docker daemon with Jenkins and Unity builds, so a
daemon-wide dangling sweep deletes their orphaned layers too — not this
deploy's to reclaim, and outside what a Plane deploy should be touching on a
shared host.

It is also unnecessary: docker rmi of an image's last tag already removes the
image
, so every tag this step untags is gone regardless. Leaving a few dangling
layers behind is harmless; taking out a co-tenant build's cache is not.

The whole step is best-effort (prune_git_tags || true) so a prune that cannot
run never fails a deploy that just went green.

Two design notes

docker inspect, not docker ps --format {{.Image}}. The brief suggested
ps --format; {{.Image}} there reports the reference the container was
started with — a tag, a short id, or a full sha depending on how it was
created. That reference stops matching the moment the prune it guards against
removes that tag, which is exactly when the guard is needed. Reading .Image
from each container's own config via docker inspect is always the canonical
sha256: and keeps resolving afterwards. Same containers, same set, more robust
resolution — noted here because it deviates from the letter of the brief.

KEEP_GIT_TAGS is validated as numeric. A non-numeric value makes every
-le comparison fail, and since the step is best-effort that failure is silent —
the prune would no-op forever while the log said it ran. A WARN + fallback to 5
turns that into a visible message.

Verification

bash -n clean. shellcheck -S style clean (0 findings) on deploy.sh, v0.10.0.

The prune was exercised against a fake docker shim — a stub on PATH
serving canned docker images / ps -aq / inspect output and logging rmi
calls, with rmi mutating its own image list so a later images reflects the
removal (which is what proves the prune is bounded, not merely that it issued
some rmi calls). The two functions are extracted verbatim from the shipped
deploy.sh by awk, so this tests the real code and not a transcription.

Fixture: 72 :git-* tags across the 6 repos (12 each), plus the non-git tags
both environments deploy from, plus 4 containers — 2 running (production), 1
stopped staging container, 1 stopped container in an unrelated project.
Expectation is derived independently from the fixture, and asserted by name,
so any rule that is wrong changes the removed set rather than the count.

--- 1. run the prune (KEEP_GIT_TAGS=5) ---
==> pruned 37 old git-* tags

  PASS  untagged set matches expectation exactly (37 refs, by name)
  PASS  rmi call count
  PASS  reported 'pruned N'
  PASS  git tags remaining

--- 2. the newest 5 of every repo survive ---
  PASS  plane-frontend newest 5 all present
  PASS  plane-space newest 5 all present
  PASS  plane-admin newest 5 all present
  PASS  plane-live newest 5 all present
  PASS  plane-backend newest 5 all present
  PASS  plane-proxy newest 5 all present

--- 3. co-tenant + container protection ---
  PASS  non-git tag plane-frontend:companymain present
  PASS  non-git tag plane-backend:companymain present
  PASS  non-git tag plane-backend:staging present
  PASS  held back makeplane/plane-frontend:git-0000009
  PASS  held back makeplane/plane-backend:git-0000fa7
  PASS  held back makeplane/plane-backend:git-0000fa8
  PASS  held back makeplane/plane-backend:git-0000faa
  PASS  held back makeplane/plane-admin:git-00007db
  PASS  container ids resolved

--- 4. rmi never gets -f; nothing sweeps the daemon ---
  PASS  no 'rmi -f'
  PASS  no -f in any rmi
  PASS  no daemon-wide sweep

--- 5. idempotent: an immediate second run is a no-op ---
  PASS  second run pruned 0
  PASS  no new rmi calls

--- 6. a refused rmi is logged as kept, not force-retried, and never aborts ---
  PASS  exit 0 despite refusal
  PASS  refusal logged as kept
  PASS  no force-retry with -f
  PASS  refused image survives

--- 7. non-numeric KEEP_GIT_TAGS warns and falls back to 5 ---
  PASS  guard block extracted
  PASS  falls back to 5
  PASS  warns on stdout
  PASS  valid value untouched

--- 8. empty daemon: no images, no containers ---
  PASS  exit 0 on empty
  PASS  pruned 0 on empty

--- 9. KEEP_GIT_TAGS is honoured (KEEP=2 prunes deeper) ---
  PASS  kept = 6*2 + 5 protected
  PASS  reported count

=== 36 passed, 0 failed ===

Four held-back tags are worth calling out — each is older than the newest 5 and
survives only because of a protection rule:

ref held by
plane-frontend:git-000000a rule 3 — is the :companymain image
plane-backend:git-0000fa8 rule 3 — is the :staging image
plane-backend:git-0000faa rule 2 — referenced by the stopped staging container
plane-admin:git-00007db rule 2 — referenced by a stopped container in another project

36 assertions, 0 failures. Covered: exact untagged set by name · newest 5 of
every repo survive · co-tenant :staging/:companymain images survive ·
stopped-container images survive · rmi never gets -f · no daemon-wide
image prune, ever
· idempotent on a second run · a refused rmi is
logged as kept, not force-retried, and never aborts the step · non-numeric
KEEP_GIT_TAGS warns and falls back to 5 · empty daemon (no images, no
containers) exits 0 · KEEP_GIT_TAGS=2 prunes deeper as expected.

The "no daemon-wide sweep" assertion was falsified before being trusted:
re-adding docker image prune -f to a copy of deploy.sh turns it red
(1 failed, exit 1). It is a real gate, not decoration — which required one
harness change. The shim's image branch now logs the invocation rather than
exiting 127: an unknown-subcommand error would be swallowed by the function's
own || true, so the assertion would have passed no matter what deploy.sh
did.

Two further bugs were found and fixed in the test harness (not the shipped
code) while getting to a trustworthy run — both worth recording because each
would have produced a green suite that proved nothing:

  • The shim read docker inspect's container id from $2 when the real call puts
    it in $3, so every lookup returned nothing and rule 2 was never exercised
    — the suite was passing with a silently-dead in-use guard. A container ids resolved self-check now guards the guard.
  • The shim's image prune branch double-printed the subcommand, so the old
    image prune -f assertion was reading a malformed line. (That assertion is
    gone; the branch is now the detector described above.)

Not done

Not merging — this is a PR for review. No gh pr merge.

🤖 Generated with Claude Code

Every deploy tags all 6 images twice — :$TAG and :git-$SHA — and nothing ever
removed the :git-* half, so each deploy added a permanent set. ~100 stale tags
had built up on sv-2, whose root disk reached 86%. `docker builder prune` does
not touch these: they are tagged images, not builder cache.

Adds step 6, which runs only after the deploy is confirmed healthy and prunes
per repo. Four protections, cheapest first:

  1. Only :git-* tags are candidates; :$TAG, :staging, :companymain are out of
     scope by construction.
  2. An image referenced by any container, running OR stopped, in any project is
     skipped. Stopped ones count — `docker start` needs the image on disk.
  3. An image that a non-:git- tag of the same repo resolves to is skipped, so
     the :staging / :companymain images survive while no container references
     them. Scoped per repo so plane-backend':staging cannot protect a
     plane-frontend tag that shares an image ID.
  4. The newest $KEEP_GIT_TAGS tags (new env var, default 5) are skipped, so a
     rollback to a recent build needs no rebuild.

docker rmi is called WITHOUT -f: if any rule missed a case, the daemon refuses
rather than yanking an image out from under a live container. A refusal is
logged as kept and never retried with -f.

Staging and production deploy on the same host and share these repositories,
which is why rules 2 and 3 exist rather than a simple keep-the-newest-N.

The whole step is best-effort (`prune_git_tags || true`) so a prune that cannot
run never fails a deploy that just went green. KEEP_GIT_TAGS is validated as
numeric, because a typo would otherwise make every comparison fail and the step
would silently prune nothing forever while the log said it ran.

Container images are resolved via `docker inspect` on each container id rather
than `docker ps --format {{.Image}}`: the latter reports the reference the
container was STARTED with, which stops matching once that tag is pruned, while
{{.Image}} is always the canonical sha256.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@h3nr1-d14z
h3nr1-d14z changed the base branch from master to staging September 23, 2026 18:37
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@h3nr1-d14z
h3nr1-d14z merged commit d43317d into staging Sep 23, 2026
11 checks passed
@h3nr1-d14z
h3nr1-d14z deleted the chore/deploy-prune-old-image-tags branch September 23, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant