feat(scripts): make GHCR CI image cleanup report its actions

The cleanup step ran silently, so a no-op run was indistinguishable
from a broken one. Add a config banner, per-deletion lines, and a
per-package plus grand-total summary with a kept-reason breakdown, and
an explicit message when nothing matches. Add DRY_RUN support.

Deletion rule: delete a version only when it carries a ci-* tag, has no
semver (v*) or latest tag, and is older than MAX_AGE_DAYS. Released
images and untagged multi-arch child layers are never touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-06-28 02:47:32 +02:00
1 parent 9c427fbefb
commit 293f70d785
1 file changed
+77 -33
+77 -33
View File
@@ -14,6 +14,7 @@ source "${REPO_ROOT}/scripts/meta/env/load.sh"
package_prefix="${PACKAGE_PREFIX:-core}"
max_age_days="${MAX_AGE_DAYS:-30}"
tag_prefix="${TAG_PREFIX:-ci-}"
dry_run="${DRY_RUN:-false}"
if [[ ! "${max_age_days}" =~ ^[0-9]+$ ]]; then
echo "ERROR: MAX_AGE_DAYS must be a non-negative integer." >&2
@@ -25,15 +26,12 @@ if [[ -z "${tag_prefix}" ]]; then
exit 1
fi
if ! command -v gh >/dev/null 2>&1; then
echo "ERROR: gh CLI not found." >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq not found." >&2
exit 1
fi
for tool in gh jq; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "ERROR: ${tool} not found." >&2
exit 1
fi
done
encode_package_name() {
local package="${1}"
@@ -53,24 +51,43 @@ resolve_packages() {
done
}
is_deletable_ci_version() {
local version="${1}"
local tags
classify_version() {
local version="${1}" cutoff_ts="${2}"
local tags updated_ts
tags="$(jq -r '.tags[]?' <<<"${version}")"
[[ -n "${tags}" ]] || {
echo "keep:untagged"
return 0
}
grep -Eq "^(latest|v[0-9].*)$" <<<"${tags}" && {
echo "keep:release-tag"
return 0
}
grep -q "^${tag_prefix}" <<<"${tags}" || {
echo "keep:no-ci-tag"
return 0
}
[[ -n "${tags}" ]] || return 1
updated_ts="$(date -u -d "$(jq -r '.updated_at' <<<"${version}")" +%s)"
[[ "${updated_ts}" -lt "${cutoff_ts}" ]] || {
echo "keep:too-new"
return 0
}
# Keep release tags even when the version also has ci-* tags.
grep -q "^${tag_prefix}" <<<"${tags}" || return 1
grep -Eq "^(latest|v.*)$" <<<"${tags}" && return 1
grep -vq "^${tag_prefix}" <<<"${tags}" && return 1
return 0
echo "delete"
}
cutoff="$(date -u -d "${max_age_days} days ago" +%s)"
echo "GHCR CI image cleanup"
echo " org=${ORG} tag-prefix=${tag_prefix} max-age=${max_age_days}d dry-run=${dry_run}"
echo " cutoff=$(date -u -d "@${cutoff}" +%Y-%m-%dT%H:%M:%SZ) (delete ${tag_prefix}* versions without a semver/latest tag, older than this)"
total_scanned=0
total_deleted=0
declare -A kept_reasons=()
while IFS= read -r package; do
package="${package#"${package%%[![:space:]]*}"}"
package="${package%"${package##*[![:space:]]}"}"
@@ -78,24 +95,51 @@ while IFS= read -r package; do
encoded_package="$(encode_package_name "${package}")"
gh api --paginate \
"/orgs/${ORG}/packages/container/${encoded_package}/versions" \
--jq '.[] | {id: .id, updated_at: .updated_at, tags: .metadata.container.tags}' |
jq -c '.' |
while IFS= read -r version; do
id="$(jq -r '.id' <<<"${version}")"
updated_at="$(jq -r '.updated_at' <<<"${version}")"
updated_ts="$(date -u -d "${updated_at}" +%s)"
pkg_scanned=0
pkg_deleted=0
if [[ "${updated_ts}" -ge "${cutoff}" ]]; then
continue
fi
echo "== ${package} =="
is_deletable_ci_version "${version}" || continue
while IFS= read -r version; do
[[ -n "${version}" ]] || continue
pkg_scanned=$((pkg_scanned + 1))
echo "Deleting ${package} version ${id} updated at ${updated_at}"
decision="$(classify_version "${version}" "${cutoff}")"
if [[ "${decision}" != "delete" ]]; then
kept_reasons["${decision}"]=$((${kept_reasons["${decision}"]:-0} + 1))
continue
fi
id="$(jq -r '.id' <<<"${version}")"
updated_at="$(jq -r '.updated_at' <<<"${version}")"
tags_csv="$(jq -r '[.tags[]?] | join(",")' <<<"${version}")"
if [[ "${dry_run}" == "true" ]]; then
echo " would delete id=${id} tags=${tags_csv} updated=${updated_at}"
else
echo " deleting id=${id} tags=${tags_csv} updated=${updated_at}"
gh api \
--method DELETE \
"/orgs/${ORG}/packages/container/${encoded_package}/versions/${id}"
done
fi
pkg_deleted=$((pkg_deleted + 1))
done < <(gh api --paginate \
"/orgs/${ORG}/packages/container/${encoded_package}/versions" \
--jq '.[] | {id: .id, updated_at: .updated_at, tags: .metadata.container.tags}' |
jq -c '.')
echo " -> scanned ${pkg_scanned}, deleted ${pkg_deleted}"
total_scanned=$((total_scanned + pkg_scanned))
total_deleted=$((total_deleted + pkg_deleted))
done < <(resolve_packages)
echo "== Summary =="
echo " scanned ${total_scanned} versions, deleted ${total_deleted}"
for reason in "${!kept_reasons[@]}"; do
echo " kept ${kept_reasons[${reason}]} (${reason})"
done
if [[ "${total_deleted}" -eq 0 ]]; then
echo " no versions matched the deletion criteria"
fi