name: Container Registry Prune on: schedule: - cron: '0 3 * * 0' # Weekly: Sundays at 03:00 UTC workflow_dispatch: inputs: keep_days: description: 'Number of days to retain images (unprotected)' required: false default: '30' dry_run: description: 'If true, only logs candidates and does not delete (default: false for active cleanup)' required: false default: 'false' keep_last_n: description: 'Keep last N newest images (global)' required: false default: '30' permissions: packages: write contents: read jobs: prune-ghcr: runs-on: ubuntu-latest env: OWNER: ${{ github.repository_owner }} IMAGE_NAME: charon KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }} KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }} DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }} PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]' PRUNE_UNTAGGED: 'true' PRUNE_SBOM_TAGS: 'true' steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install tools run: | sudo apt-get update && sudo apt-get install -y jq curl - name: Run GHCR prune env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | chmod +x scripts/prune-ghcr.sh ./scripts/prune-ghcr.sh 2>&1 | tee prune-ghcr-${{ github.run_id }}.log - name: Summarize GHCR results if: always() run: | set -euo pipefail SUMMARY_FILE=prune-summary-ghcr.env LOG_FILE=prune-ghcr-${{ 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" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }' } 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 "## GHCR prune summary" echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))" echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))" } >> "$GITHUB_STEP_SUMMARY" 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 "## GHCR prune summary" echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))" } >> "$GITHUB_STEP_SUMMARY" fi - name: Upload GHCR prune artifacts if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: prune-ghcr-log-${{ github.run_id }} path: | prune-ghcr-${{ github.run_id }}.log prune-summary-ghcr.env prune-dockerhub: runs-on: ubuntu-latest env: OWNER: ${{ github.repository_owner }} IMAGE_NAME: charon KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }} KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }} DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }} PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]' steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install tools run: | sudo apt-get update && sudo apt-get install -y jq curl - name: Run Docker Hub prune env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} run: | chmod +x scripts/prune-dockerhub.sh ./scripts/prune-dockerhub.sh 2>&1 | tee prune-dockerhub-${{ github.run_id }}.log - name: Summarize Docker Hub results if: always() run: | set -euo pipefail SUMMARY_FILE=prune-summary-dockerhub.env LOG_FILE=prune-dockerhub-${{ 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" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }' } 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 "## Docker Hub prune summary" echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))" echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))" } >> "$GITHUB_STEP_SUMMARY" 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 "## Docker Hub prune summary" echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))" } >> "$GITHUB_STEP_SUMMARY" fi - name: Upload Docker Hub prune artifacts if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: prune-dockerhub-log-${{ github.run_id }} path: | prune-dockerhub-${{ github.run_id }}.log prune-summary-dockerhub.env summarize: runs-on: ubuntu-latest needs: [prune-ghcr, prune-dockerhub] if: always() steps: - name: Download all artifacts uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 with: pattern: prune-*-log-${{ github.run_id }} merge-multiple: true - name: Combined summary run: | set -euo pipefail human() { local bytes=${1:-0} if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then echo "0 B" return fi awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }' } GHCR_CANDIDATES=0 GHCR_CANDIDATES_BYTES=0 GHCR_DELETED=0 GHCR_DELETED_BYTES=0 if [ -f prune-summary-ghcr.env ]; then GHCR_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0) GHCR_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0) GHCR_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-ghcr.env | cut -d= -f2 || echo 0) GHCR_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0) fi HUB_CANDIDATES=0 HUB_CANDIDATES_BYTES=0 HUB_DELETED=0 HUB_DELETED_BYTES=0 if [ -f prune-summary-dockerhub.env ]; then HUB_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0) HUB_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0) HUB_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0) HUB_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0) fi TOTAL_CANDIDATES=$((GHCR_CANDIDATES + HUB_CANDIDATES)) TOTAL_CANDIDATES_BYTES=$((GHCR_CANDIDATES_BYTES + HUB_CANDIDATES_BYTES)) TOTAL_DELETED=$((GHCR_DELETED + HUB_DELETED)) TOTAL_DELETED_BYTES=$((GHCR_DELETED_BYTES + HUB_DELETED_BYTES)) { echo "## Combined container prune summary" echo "" echo "| Registry | Candidates | Deleted | Space Reclaimed |" echo "|----------|------------|---------|-----------------|" echo "| GHCR | ${GHCR_CANDIDATES} | ${GHCR_DELETED} | $(human "${GHCR_DELETED_BYTES}") |" echo "| Docker Hub | ${HUB_CANDIDATES} | ${HUB_DELETED} | $(human "${HUB_DELETED_BYTES}") |" echo "| **Total** | **${TOTAL_CANDIDATES}** | **${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 "Total space reclaimed: $(human "${TOTAL_DELETED_BYTES}")"