chore(ci): enable scheduled container pruning and report reclaimed space

- Make container prune run perform deletions by default (workflow_dispatch default now false for dry_run)
- Enhance prune script to estimate candidate and deleted image sizes (Docker Hub best-effort; GHCR manifest fallback)
- Emit machine-readable summary (`prune-summary.env`) and human-readable summary to the workflow run
- Upload logs + summary as artifacts and expose `space_saved` in the run summary

Why:
- Previously the scheduled job used dry-run by default and only logged candidates; this change makes scheduled pruning effective and provides visibility into storage reclaimed.

Impact:
- Runs will now remove eligible images by default (use dry_run=true to test)
- Size calculations are best-effort and may be incomplete if registry APIs do not expose sizes
This commit is contained in:
GitHub Actions
2026-02-08 21:34:23 +00:00
parent 903ef191ec
commit 033d1d1dad
2 changed files with 119 additions and 7 deletions

View File

@@ -35,7 +35,7 @@ jobs:
REGISTRIES: ${{ github.event.inputs.registries || 'ghcr,dockerhub' }}
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'true' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
PROTECTED_REGEX: '["^v","^latest$","^main$","^develop$"]'
steps:
- name: Checkout
@@ -45,7 +45,7 @@ jobs:
run: |
sudo apt-get update && sudo apt-get install -y jq curl
- name: Run container prune (dry-run by default)
- name: Run container prune
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -54,10 +54,57 @@ jobs:
chmod +x scripts/prune-container-images.sh
./scripts/prune-container-images.sh 2>&1 | tee prune-${{ github.run_id }}.log
- name: Upload log
- name: Summarize prune results (space reclaimed)
if: ${{ always() }}
run: |
set -euo pipefail
SUMMARY_FILE=prune-summary.env
LOG_FILE=prune-${{ github.run_id }}.log
human() {
local bytes=${1:-0}
if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then
echo "0 B"
return
fi
awk -v b="$bytes" 'function human(x){ split("B KiB MiB GiB TiB",u," "); i=0; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1]} END{human(b)}'
}
if [ -f "$SUMMARY_FILE" ]; then
TOTAL_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
TOTAL_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
TOTAL_DELETED=$(grep -E '^TOTAL_DELETED=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
TOTAL_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
{
echo "## Container prune summary"
echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))"
echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))"
} >> "$GITHUB_STEP_SUMMARY"
printf 'PRUNE_SUMMARY: candidates=%s candidates_bytes=%s deleted=%s deleted_bytes=%s\n' \
"${TOTAL_CANDIDATES}" "${TOTAL_CANDIDATES_BYTES}" "${TOTAL_DELETED}" "${TOTAL_DELETED_BYTES}"
echo "Deleted approximately: $(human "${TOTAL_DELETED_BYTES}")"
echo "space_saved=$(human "${TOTAL_DELETED_BYTES}")" >> "$GITHUB_OUTPUT"
else
deleted_bytes=$(grep -oE '\( *approx +[0-9]+ bytes\)' "$LOG_FILE" | sed -E 's/.*approx +([0-9]+) bytes.*/\1/' | awk '{s+=$1} END {print s+0}' || true)
deleted_count=$(grep -cE 'deleting |DRY RUN: would delete' "$LOG_FILE" || true)
{
echo "## Container prune summary"
echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))"
} >> "$GITHUB_STEP_SUMMARY"
printf 'PRUNE_SUMMARY: deleted_approx=%s deleted_bytes=%s\n' "${deleted_count}" "${deleted_bytes}"
echo "Deleted approximately: $(human "${deleted_bytes}")"
echo "space_saved=$(human "${deleted_bytes}")" >> "$GITHUB_OUTPUT"
fi
- name: Upload prune artifacts
if: ${{ always() }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: prune-log-${{ github.run_id }}
path: |
prune-${{ github.run_id }}.log
prune-summary.env

View File

@@ -28,6 +28,12 @@ LOG_PREFIX="[prune]"
now_ts=$(date +%s)
cutoff_ts=$(date -d "$KEEP_DAYS days ago" +%s 2>/dev/null || date -d "-$KEEP_DAYS days" +%s)
# Totals
TOTAL_CANDIDATES=0
TOTAL_CANDIDATES_BYTES=0
TOTAL_DELETED=0
TOTAL_DELETED_BYTES=0
echo "$LOG_PREFIX starting with REGISTRIES=$REGISTRIES KEEP_DAYS=$KEEP_DAYS DRY_RUN=$DRY_RUN"
action_delete_ghcr() {
@@ -92,12 +98,32 @@ action_delete_ghcr() {
echo "$LOG_PREFIX candidate: id=$id tags=$tags created=$created"
# Try to estimate size for GHCR by fetching manifest (best-effort)
candidate_bytes=0
for tag in $(echo "$tags" | sed 's/,/ /g'); do
if [[ -n "$tag" && "$tag" != "null" ]]; then
manifest_url="https://ghcr.io/v2/${OWNER}/${IMAGE_NAME}/manifests/${tag}"
manifest=$(curl -sS -H "Accept: application/vnd.docker.distribution.manifest.v2+json" -H "Authorization: Bearer $GITHUB_TOKEN" "$manifest_url" || true)
if [[ -n "$manifest" ]]; then
bytes=$(echo "$manifest" | jq -r '.layers // [] | map(.size) | add // 0')
if [[ "$bytes" != "null" && "$bytes" -gt 0 2>/dev/null ]]; then
candidate_bytes=$((candidate_bytes + bytes))
fi
fi
fi
done
TOTAL_CANDIDATES=$((TOTAL_CANDIDATES+1))
TOTAL_CANDIDATES_BYTES=$((TOTAL_CANDIDATES_BYTES + candidate_bytes))
if [[ "$DRY_RUN" == "true" ]]; then
echo "$LOG_PREFIX DRY RUN: would delete GHCR version id=$id"
echo "$LOG_PREFIX DRY RUN: would delete GHCR version id=$id (approx ${candidate_bytes} bytes)"
else
echo "$LOG_PREFIX deleting GHCR version id=$id"
echo "$LOG_PREFIX deleting GHCR version id=$id (approx ${candidate_bytes} bytes)"
curl -sS -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/${namespace_type}/${OWNER}/packages/container/${IMAGE_NAME}/versions/$id"
TOTAL_DELETED=$((TOTAL_DELETED+1))
TOTAL_DELETED_BYTES=$((TOTAL_DELETED_BYTES + candidate_bytes))
fi
done
@@ -160,12 +186,25 @@ action_delete_dockerhub() {
echo "$LOG_PREFIX candidate: tag=$tag_name last_updated=$last_updated"
# Estimate size from Docker Hub tag JSON (images[].size or full_size)
bytes=0
bytes=$(echo "$tag" | jq -r '.images | map(.size) | add // empty') || true
if [[ -z "$bytes" || "$bytes" == "null" ]]; then
bytes=$(echo "$tag" | jq -r '.full_size // empty' 2>/dev/null || true)
fi
bytes=${bytes:-0}
TOTAL_CANDIDATES=$((TOTAL_CANDIDATES+1))
TOTAL_CANDIDATES_BYTES=$((TOTAL_CANDIDATES_BYTES + bytes))
if [[ "$DRY_RUN" == "true" ]]; then
echo "$LOG_PREFIX DRY RUN: would delete Docker Hub tag=$tag_name"
echo "$LOG_PREFIX DRY RUN: would delete Docker Hub tag=$tag_name (approx ${bytes} bytes)"
else
echo "$LOG_PREFIX deleting Docker Hub tag=$tag_name"
echo "$LOG_PREFIX deleting Docker Hub tag=$tag_name (approx ${bytes} bytes)"
curl -sS -X DELETE -H "Authorization: JWT $hub_token" \
"https://hub.docker.com/v2/repositories/${DOCKERHUB_USERNAME}/${IMAGE_NAME}/tags/${tag_name}/"
TOTAL_DELETED=$((TOTAL_DELETED+1))
TOTAL_DELETED_BYTES=$((TOTAL_DELETED_BYTES + bytes))
fi
done
@@ -190,4 +229,30 @@ for r in "${regs[@]}"; do
esac
done
# Summary
human_readable() {
local bytes=$1
if (( 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]}"
}
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})"
# Export summary for workflow parsing
echo "TOTAL_CANDIDATES=${TOTAL_CANDIDATES}" >> prune-summary.env
echo "TOTAL_CANDIDATES_BYTES=${TOTAL_CANDIDATES_BYTES}" >> prune-summary.env
echo "TOTAL_DELETED=${TOTAL_DELETED}" >> prune-summary.env
echo "TOTAL_DELETED_BYTES=${TOTAL_DELETED_BYTES}" >> prune-summary.env
echo "$LOG_PREFIX done"