Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/actions/image-tag-cleanup/select-deletions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail

# Pure candidate-selection logic for image-tag-cleanup. Never touches a
# registry itself — cleanup-dockerhub.sh / cleanup-ghcr.sh fetch the real
# data and call this to decide what's safe to delete.
#
# Reads a JSON array on stdin: [{"id":"<registry-specific id>","tags":["..."],"updated_at":"<ISO8601>"}, ...]
# Prints a JSON array of "id"s eligible for deletion.
#
# Safety guarantee: an entry is a deletion CANDIDATE only when EVERY one of
# its tags starts with $PREFIX. An entry carrying any tag that doesn't match
# — a stable release (vX.Y.Z), :latest, :edge, a legacy release-train tag
# (:alpha, :beta, :X.Y.Z-alpha.N, ...) — is always protected and never even
# considered, regardless of age. Among candidates, the $KEEP_MIN most
# recently updated are always kept regardless of age; the rest are selected
# for deletion only once older than $RETENTION_DAYS days.

PREFIX="${1:?ephemeral tag prefix required}"
RETENTION_DAYS="${2:?retention_days required}"
KEEP_MIN="${3:?keep_min required}"
NOW="${4:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"

jq -c \
--arg prefix "$PREFIX" \
--argjson retention_days "$RETENTION_DAYS" \
--argjson keep_min "$KEEP_MIN" \
--arg now "$NOW" '
def is_ephemeral: (.tags | length > 0) and (.tags | all(startswith($prefix)));

[ .[] | select(is_ephemeral) ]
| sort_by(.updated_at) | reverse
| to_entries
| [ .[]
| select(.key >= $keep_min)
| . as $e
| (($now | fromdateiso8601) - ($e.value.updated_at | fromdateiso8601)) as $age_seconds
| select($age_seconds > ($retention_days * 86400))
| $e.value.id
]
'
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ jobs:
- name: Run tests
run: bash tests/release-train-detect.test.sh

image-tag-cleanup-select:
name: Image tag cleanup selection tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7.0.1

- name: Run tests
run: bash tests/image-tag-cleanup-select.test.sh

shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
Expand Down
91 changes: 91 additions & 0 deletions tests/image-tag-cleanup-select.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail

# Tests for .github/actions/image-tag-cleanup/select-deletions.sh
#
# Each scenario feeds a synthetic JSON tag/version list on stdin and asserts
# which "id"s come back as deletion candidates. Run locally or in CI:
# bash tests/image-tag-cleanup-select.test.sh

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SELECT="${REPO_ROOT}/.github/actions/image-tag-cleanup/select-deletions.sh"

PASS=0
FAIL=0
CURRENT_TEST=""

run_select() { # $1=json, $2=prefix, $3=retention_days, $4=keep_min, $5=now
echo "$1" | bash "$SELECT" "$2" "$3" "$4" "$5"
}

assert_eq() { # $1 = description, $2 = expected, $3 = actual
if [ "$2" = "$3" ]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
echo "FAIL [${CURRENT_TEST}] $1: expected '$2', got '$3'"
fi
}

test_case() { CURRENT_TEST="$1"; echo "--- $1"; }

NOW="2026-01-10T00:00:00Z"

test_case "empty input selects nothing"
OUT=$(run_select '[]' "sha-" 7 2 "$NOW")
assert_eq "no candidates" "[]" "$OUT"

test_case "never deletes a tag that doesn't match the ephemeral prefix"
OUT=$(run_select '[{"id":"latest","tags":["latest"],"updated_at":"2020-01-01T00:00:00Z"}]' "sha-" 7 2 "$NOW")
assert_eq "official tag protected" "[]" "$OUT"

test_case "never deletes an entry co-tagged with a non-ephemeral tag"
OUT=$(run_select '[{"id":"mixed","tags":["sha-99","1.2.3"],"updated_at":"2020-01-01T00:00:00Z"}]' "sha-" 7 2 "$NOW")
assert_eq "mixed-tag entry protected" "[]" "$OUT"

test_case "never deletes an untagged entry"
OUT=$(run_select '[{"id":"untagged","tags":[],"updated_at":"2020-01-01T00:00:00Z"}]' "sha-" 7 2 "$NOW")
assert_eq "untagged entry protected" "[]" "$OUT"

test_case "always keeps the keep_min most recent ephemeral entries regardless of age"
OUT=$(run_select '[{"id":"sha-old","tags":["sha-old"],"updated_at":"2020-01-01T00:00:00Z"}]' "sha-" 7 5 "$NOW")
assert_eq "below keep_min, kept even though ancient" "[]" "$OUT"

test_case "deletes ephemeral entries beyond keep_min once older than retention_days"
JSON='[
{"id":"sha-5","tags":["sha-5"],"updated_at":"2026-01-09T00:00:00Z"},
{"id":"sha-4","tags":["sha-4"],"updated_at":"2026-01-08T00:00:00Z"},
{"id":"sha-3","tags":["sha-3"],"updated_at":"2026-01-02T00:00:00Z"},
{"id":"sha-2","tags":["sha-2"],"updated_at":"2026-01-01T00:00:00Z"},
{"id":"sha-1","tags":["sha-1"],"updated_at":"2025-12-01T00:00:00Z"}
]'
OUT=$(run_select "$JSON" "sha-" 7 2 "$NOW")
assert_eq "oldest three selected, two most recent kept" '["sha-3","sha-2","sha-1"]' "$OUT"

test_case "keeps entries beyond keep_min if still within the retention window"
JSON='[
{"id":"sha-2","tags":["sha-2"],"updated_at":"2026-01-09T00:00:00Z"},
{"id":"sha-1","tags":["sha-1"],"updated_at":"2026-01-08T00:00:00Z"}
]'
OUT=$(run_select "$JSON" "sha-" 7 1 "$NOW")
assert_eq "second entry recent enough to keep" "[]" "$OUT"

test_case "GHCR-shaped input (id = numeric version id) works the same way"
JSON='[
{"id":"918273","tags":["sha-abc123"],"updated_at":"2025-12-01T00:00:00Z"},
{"id":"918274","tags":["edge"],"updated_at":"2026-01-09T00:00:00Z"}
]'
OUT=$(run_select "$JSON" "sha-" 7 0 "$NOW")
assert_eq "only the sha-tagged version id is selected, edge protected" '["918273"]' "$OUT"

test_case "a custom ephemeral prefix only matches its own tags"
JSON='[
{"id":"sha-abc","tags":["sha-abc"],"updated_at":"2025-12-01T00:00:00Z"},
{"id":"build-123","tags":["build-123"],"updated_at":"2025-12-01T00:00:00Z"}
]'
OUT=$(run_select "$JSON" "build-" 7 0 "$NOW")
assert_eq "only build- prefixed entry selected" '["build-123"]' "$OUT"

echo
echo "passed: $PASS failed: $FAIL"
[ "$FAIL" -eq 0 ]