272 lines
8.2 KiB
Bash
Executable File
272 lines
8.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# prune-ghcr.sh
|
|
# Deletes old container images from GitHub Container Registry (GHCR)
|
|
# according to retention and protection rules.
|
|
|
|
OWNER=${OWNER:-${GITHUB_REPOSITORY_OWNER:-Wikid82}}
|
|
IMAGE_NAME=${IMAGE_NAME:-charon}
|
|
|
|
KEEP_DAYS=${KEEP_DAYS:-30}
|
|
KEEP_LAST_N=${KEEP_LAST_N:-30}
|
|
|
|
DRY_RUN=${DRY_RUN:-false}
|
|
PROTECTED_REGEX=${PROTECTED_REGEX:-'["^v","^latest$","^main$","^develop$"]'}
|
|
|
|
PRUNE_UNTAGGED=${PRUNE_UNTAGGED:-true}
|
|
PRUNE_SBOM_TAGS=${PRUNE_SBOM_TAGS:-true}
|
|
|
|
LOG_PREFIX="[prune-ghcr]"
|
|
|
|
cutoff_ts=$(date -d "$KEEP_DAYS days ago" +%s 2>/dev/null || date -d "-$KEEP_DAYS days" +%s)
|
|
|
|
dry_run=false
|
|
case "${DRY_RUN,,}" in
|
|
true|1|yes|y|on) dry_run=true ;;
|
|
*) dry_run=false ;;
|
|
esac
|
|
|
|
TOTAL_CANDIDATES=0
|
|
TOTAL_CANDIDATES_BYTES=0
|
|
TOTAL_DELETED=0
|
|
TOTAL_DELETED_BYTES=0
|
|
|
|
echo "$LOG_PREFIX starting with OWNER=$OWNER IMAGE_NAME=$IMAGE_NAME KEEP_DAYS=$KEEP_DAYS KEEP_LAST_N=$KEEP_LAST_N DRY_RUN=$dry_run"
|
|
echo "$LOG_PREFIX PROTECTED_REGEX=$PROTECTED_REGEX PRUNE_UNTAGGED=$PRUNE_UNTAGGED PRUNE_SBOM_TAGS=$PRUNE_SBOM_TAGS"
|
|
|
|
require() {
|
|
command -v "$1" >/dev/null 2>&1 || { echo "$LOG_PREFIX missing required command: $1" >&2; exit 1; }
|
|
}
|
|
require curl
|
|
require jq
|
|
|
|
is_protected_tag() {
|
|
local tag="$1"
|
|
local rgx
|
|
while IFS= read -r rgx; do
|
|
[[ -z "$rgx" ]] && continue
|
|
if [[ "$tag" =~ $rgx ]]; then
|
|
return 0
|
|
fi
|
|
done < <(echo "$PROTECTED_REGEX" | jq -r '.[]')
|
|
return 1
|
|
}
|
|
|
|
tag_is_sbom() {
|
|
local tag="$1"
|
|
[[ "$tag" == *.sbom ]]
|
|
}
|
|
|
|
human_readable() {
|
|
local bytes=${1:-0}
|
|
if [[ -z "$bytes" ]] || (( bytes <= 0 )); then
|
|
echo "0 B"
|
|
return
|
|
fi
|
|
local unit=(B KiB MiB GiB TiB)
|
|
local i=0
|
|
local value=$bytes
|
|
while (( value > 1024 )) && (( i < 4 )); do
|
|
value=$((value / 1024))
|
|
i=$((i + 1))
|
|
done
|
|
printf "%s %s" "${value}" "${unit[$i]}"
|
|
}
|
|
|
|
# All echo/log statements go to stderr so stdout remains pure JSON
|
|
ghcr_list_all_versions_json() {
|
|
local namespace_type="$1"
|
|
local page=1
|
|
local per_page=100
|
|
local all='[]'
|
|
|
|
while :; do
|
|
local url="https://api.github.com/${namespace_type}/${OWNER}/packages/container/${IMAGE_NAME}/versions?per_page=$per_page&page=$page"
|
|
|
|
local resp
|
|
resp=$(curl -sS \
|
|
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
"$url" || true)
|
|
|
|
if ! echo "$resp" | jq -e . >/dev/null 2>&1; then
|
|
echo "$LOG_PREFIX GHCR returned non-JSON for url=$url" >&2
|
|
echo "$LOG_PREFIX GHCR response (first 200 chars): $(echo "$resp" | head -c 200 | tr '\n' ' ')" >&2
|
|
echo "[]"
|
|
return 0
|
|
fi
|
|
|
|
if echo "$resp" | jq -e 'has("message")' >/dev/null 2>&1; then
|
|
local msg
|
|
msg=$(echo "$resp" | jq -r '.message')
|
|
|
|
if [[ "$msg" == "Not Found" ]]; then
|
|
echo "$LOG_PREFIX GHCR ${namespace_type} endpoint returned Not Found" >&2
|
|
echo "[]"
|
|
return 0
|
|
fi
|
|
|
|
echo "$LOG_PREFIX GHCR API error: $msg" >&2
|
|
doc=$(echo "$resp" | jq -r '.documentation_url // empty')
|
|
[[ -n "$doc" ]] && echo "$LOG_PREFIX GHCR docs: $doc" >&2
|
|
echo "[]"
|
|
return 0
|
|
fi
|
|
|
|
local count
|
|
count=$(echo "$resp" | jq -r 'length')
|
|
if [[ -z "$count" || "$count" == "0" ]]; then
|
|
break
|
|
fi
|
|
|
|
all=$(jq -s 'add' <(echo "$all") <(echo "$resp"))
|
|
((page++))
|
|
done
|
|
|
|
echo "$all"
|
|
}
|
|
|
|
action_delete_ghcr() {
|
|
echo "$LOG_PREFIX -> GHCR cleanup for $OWNER/$IMAGE_NAME (dry-run=$dry_run)"
|
|
|
|
if [[ -z "${GITHUB_TOKEN:-}" ]]; then
|
|
echo "$LOG_PREFIX GITHUB_TOKEN not set; skipping GHCR cleanup"
|
|
return
|
|
fi
|
|
|
|
local all
|
|
local namespace_type="orgs"
|
|
all=$(ghcr_list_all_versions_json "$namespace_type")
|
|
if [[ "$(echo "$all" | jq -r 'length')" == "0" ]]; then
|
|
namespace_type="users"
|
|
all=$(ghcr_list_all_versions_json "$namespace_type")
|
|
fi
|
|
|
|
local total
|
|
total=$(echo "$all" | jq -r 'length')
|
|
if [[ -z "$total" || "$total" == "0" ]]; then
|
|
echo "$LOG_PREFIX GHCR: no versions found (or insufficient access)."
|
|
return
|
|
fi
|
|
|
|
echo "$LOG_PREFIX GHCR: fetched $total versions total"
|
|
|
|
local normalized
|
|
normalized=$(echo "$all" | jq -c '
|
|
map({
|
|
id: .id,
|
|
created_at: .created_at,
|
|
tags: (.metadata.container.tags // []),
|
|
tags_csv: ((.metadata.container.tags // []) | join(",")),
|
|
created_ts: (.created_at | fromdateiso8601),
|
|
size: (.metadata.container.size // .size // 0)
|
|
})
|
|
')
|
|
|
|
local keep_ids
|
|
keep_ids=$(echo "$normalized" | jq -r --argjson n "${KEEP_LAST_N:-0}" '
|
|
(sort_by(.created_ts) | reverse) as $s
|
|
| ($s[0:$n] | map(.id)) | join(" ")
|
|
')
|
|
|
|
if [[ -n "$keep_ids" ]]; then
|
|
echo "$LOG_PREFIX GHCR: keeping newest KEEP_LAST_N ids: $KEEP_LAST_N"
|
|
fi
|
|
|
|
local ver protected all_sbom candidate_bytes
|
|
while IFS= read -r ver; do
|
|
local id created created_ts tags_csv
|
|
all_sbom=false
|
|
id=$(echo "$ver" | jq -r '.id')
|
|
created=$(echo "$ver" | jq -r '.created_at')
|
|
created_ts=$(echo "$ver" | jq -r '.created_ts')
|
|
tags_csv=$(echo "$ver" | jq -r '.tags_csv')
|
|
|
|
if [[ -n "$keep_ids" && " $keep_ids " == *" $id "* ]]; then
|
|
echo "$LOG_PREFIX keep (last_n): id=$id tags=$tags_csv created=$created"
|
|
continue
|
|
fi
|
|
|
|
protected=false
|
|
if [[ -n "$tags_csv" ]]; then
|
|
while IFS= read -r t; do
|
|
[[ -z "$t" ]] && continue
|
|
if is_protected_tag "$t"; then
|
|
protected=true
|
|
break
|
|
fi
|
|
done < <(echo "$tags_csv" | tr ',' '\n')
|
|
fi
|
|
if $protected; then
|
|
echo "$LOG_PREFIX keep (protected): id=$id tags=$tags_csv created=$created"
|
|
continue
|
|
fi
|
|
|
|
if [[ "${PRUNE_SBOM_TAGS,,}" == "true" && -n "$tags_csv" ]]; then
|
|
all_sbom=true
|
|
while IFS= read -r t; do
|
|
[[ -z "$t" ]] && continue
|
|
if ! tag_is_sbom "$t"; then
|
|
all_sbom=false
|
|
break
|
|
fi
|
|
done < <(echo "$tags_csv" | tr ',' '\n')
|
|
fi
|
|
|
|
# If all tags are SBOM tags and PRUNE_SBOM_TAGS is enabled, skip the age check
|
|
if [[ "${all_sbom:-false}" == "true" ]]; then
|
|
echo "$LOG_PREFIX candidate (sbom-only): id=$id tags=$tags_csv created=$created"
|
|
else
|
|
if (( created_ts >= cutoff_ts )); then
|
|
echo "$LOG_PREFIX keep (recent): id=$id tags=$tags_csv created=$created"
|
|
continue
|
|
fi
|
|
|
|
if [[ "${PRUNE_UNTAGGED,,}" == "true" ]]; then
|
|
if [[ -z "$tags_csv" ]]; then
|
|
echo "$LOG_PREFIX candidate (untagged): id=$id tags=<none> created=$created"
|
|
else
|
|
echo "$LOG_PREFIX candidate: id=$id tags=$tags_csv created=$created"
|
|
fi
|
|
else
|
|
if [[ -z "$tags_csv" ]]; then
|
|
echo "$LOG_PREFIX keep (untagged disabled): id=$id created=$created"
|
|
continue
|
|
fi
|
|
echo "$LOG_PREFIX candidate: id=$id tags=$tags_csv created=$created"
|
|
fi
|
|
fi
|
|
|
|
TOTAL_CANDIDATES=$((TOTAL_CANDIDATES + 1))
|
|
|
|
candidate_bytes=$(echo "$ver" | jq -r '.size // 0')
|
|
TOTAL_CANDIDATES_BYTES=$((TOTAL_CANDIDATES_BYTES + candidate_bytes))
|
|
|
|
if $dry_run; then
|
|
echo "$LOG_PREFIX DRY RUN: would delete GHCR version id=$id (approx $(human_readable "$candidate_bytes"))"
|
|
else
|
|
echo "$LOG_PREFIX deleting GHCR version id=$id (approx $(human_readable "$candidate_bytes"))"
|
|
curl -sS -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
|
"https://api.github.com/${namespace_type}/${OWNER}/packages/container/${IMAGE_NAME}/versions/$id" >/dev/null || true
|
|
TOTAL_DELETED=$((TOTAL_DELETED + 1))
|
|
TOTAL_DELETED_BYTES=$((TOTAL_DELETED_BYTES + candidate_bytes))
|
|
fi
|
|
|
|
done < <(echo "$normalized" | jq -c 'sort_by(.created_ts) | .[]')
|
|
}
|
|
|
|
# Main
|
|
action_delete_ghcr
|
|
|
|
echo "$LOG_PREFIX SUMMARY: total_candidates=${TOTAL_CANDIDATES} total_candidates_bytes=${TOTAL_CANDIDATES_BYTES} total_deleted=${TOTAL_DELETED} total_deleted_bytes=${TOTAL_DELETED_BYTES}"
|
|
echo "$LOG_PREFIX SUMMARY_HUMAN: candidates=${TOTAL_CANDIDATES} candidates_size=$(human_readable "${TOTAL_CANDIDATES_BYTES}") deleted=${TOTAL_DELETED} deleted_size=$(human_readable "${TOTAL_DELETED_BYTES}")"
|
|
|
|
: > prune-summary-ghcr.env
|
|
echo "TOTAL_CANDIDATES=${TOTAL_CANDIDATES}" >> prune-summary-ghcr.env
|
|
echo "TOTAL_CANDIDATES_BYTES=${TOTAL_CANDIDATES_BYTES}" >> prune-summary-ghcr.env
|
|
echo "TOTAL_DELETED=${TOTAL_DELETED}" >> prune-summary-ghcr.env
|
|
echo "TOTAL_DELETED_BYTES=${TOTAL_DELETED_BYTES}" >> prune-summary-ghcr.env
|
|
|
|
echo "$LOG_PREFIX done"
|