From 293f70d785f4722e975a4aaa5dd00c8278cee7a2 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 28 Jun 2026 02:43:06 +0200 Subject: [PATCH] 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) --- scripts/github/cleanup/ghcr_ci_images.sh | 110 ++++++++++++++++------- 1 file changed, 77 insertions(+), 33 deletions(-) diff --git a/scripts/github/cleanup/ghcr_ci_images.sh b/scripts/github/cleanup/ghcr_ci_images.sh index a1a35b45d0..16bd2f6d02 100755 --- a/scripts/github/cleanup/ghcr_ci_images.sh +++ b/scripts/github/cleanup/ghcr_ci_images.sh @@ -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