Skip to content
Latchkey

Cleanup GHCR Images workflow (TryGhost/Ghost)

The Cleanup GHCR Images workflow from TryGhost/Ghost, explained and optimized by Latchkey.

A

CI health: A - excellent

Point runs-on at Latchkey and get job timeouts, self-healing for flaky steps, and up to 58% lower cost, applied automatically.

Grade your own workflow free or run it on Latchkey →
Source: TryGhost/Ghost.github/workflows/cleanup-ghcr.ymlLicense MITView source

What it does

This is the Cleanup GHCR Images workflow from the TryGhost/Ghost repository, a real project running GitHub Actions. It is shown here with attribution under its MIT license.

Below, Latchkey shows a faster, safer version produced by its optimization engine.

The workflow

workflow (.yml)
name: Cleanup GHCR Images

on:
  schedule:
    - cron: "30 4 * * *" # Daily at 04:30 UTC
  workflow_dispatch:
    inputs:
      dry_run:
        description: "Log what would be deleted without making changes"
        required: false
        default: true
        type: boolean
      retention_days:
        description: "Delete versions older than this many days"
        required: false
        default: 14
        type: number
      min_keep:
        description: "Always keep at least this many versions per package"
        required: false
        default: 10
        type: number

permissions:
  packages: write

env:
  ORG: TryGhost
  RETENTION_DAYS: ${{ inputs.retention_days || 14 }}
  MIN_KEEP: ${{ inputs.min_keep || 10 }}

jobs:
  cleanup:
    name: Cleanup
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: [ghost, ghost-core, ghost-development]
    steps:
      - name: Delete old non-release versions
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || inputs.dry_run }}
          PACKAGE: ${{ matrix.package }}
        run: |
          set -euo pipefail

          cutoff=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
            || date -u -v-${RETENTION_DAYS}d +%Y-%m-%dT%H:%M:%SZ)

          echo "Package: ${ORG}/${PACKAGE}"
          echo "Cutoff:  ${cutoff} (${RETENTION_DAYS} days ago)"
          echo "Dry run: ${DRY_RUN}"
          echo ""

          # Pagination - collect all versions
          page=1
          all_versions="[]"
          while true; do
            if ! batch=$(gh api \
              "/orgs/${ORG}/packages/container/${PACKAGE}/versions?per_page=100&page=${page}" \
              --jq '.' 2>&1); then
              if [ "$page" = "1" ]; then
                echo "::error::API request failed: ${batch}"
                exit 1
              fi
              echo "::warning::API request failed (page ${page}): ${batch}"
              break
            fi

            count=$(echo "$batch" | jq 'length')
            if [ "$count" = "0" ]; then
              break
            fi

            all_versions=$(echo "$all_versions $batch" | jq -s 'add')
            page=$((page + 1))
          done

          total=$(echo "$all_versions" | jq 'length')
          echo "Total versions: ${total}"

          # Classify versions
          keep=0
          delete=0
          delete_ids=""

          for row in $(echo "$all_versions" | jq -r '.[] | @base64'); do
            _jq() { echo "$row" | base64 -d | jq -r "$1"; }

            id=$(_jq '.id')
            updated=$(_jq '.updated_at')
            tags=$(_jq '[.metadata.container.tags[]] | join(",")')

            # Keep versions with semver tags (v1.2.3, 1.2.3, 1.2)
            if echo "$tags" | grep -qE '(^|,)v?[0-9]+\.[0-9]+\.[0-9]+(,|$)' || \
               echo "$tags" | grep -qE '(^|,)[0-9]+\.[0-9]+(,|$)'; then
              keep=$((keep + 1))
              continue
            fi

            # Keep versions with 'latest' or 'main' or cache-main tags
            if echo "$tags" | grep -qE '(^|,)(latest|main|cache-main)(,|$)'; then
              keep=$((keep + 1))
              continue
            fi

            # Keep versions newer than cutoff
            if [[ "$updated" > "$cutoff" ]]; then
              keep=$((keep + 1))
              continue
            fi

            # This version is eligible for deletion
            delete=$((delete + 1))
            delete_ids="${delete_ids} ${id}"

            tag_display="${tags:-<untagged>}"
            if [ "$DRY_RUN" = "true" ]; then
              echo "[dry-run] Would delete version ${id} (tags: ${tag_display}, updated: ${updated})"
            fi
          done

          echo ""
          echo "Summary: ${keep} kept, ${delete} to delete (of ${total} total)"

          if [ "$delete" = "0" ]; then
            echo "Nothing to delete."
            exit 0
          fi

          # Safety check - run before dry-run exit so users see the warning
          if [ "$keep" -lt "$MIN_KEEP" ]; then
            echo "::error::Safety check failed - only ${keep} versions would remain (minimum: ${MIN_KEEP}). Aborting."
            exit 1
          fi

          if [ "$DRY_RUN" = "true" ]; then
            echo ""
            echo "Dry run - no versions deleted."
            exit 0
          fi

          # Delete eligible versions
          deleted=0
          failed=0
          for id in $delete_ids; do
            if gh api --method DELETE \
              "/orgs/${ORG}/packages/container/${PACKAGE}/versions/${id}" 2>/dev/null; then
              deleted=$((deleted + 1))
            else
              echo "::warning::Failed to delete version ${id}"
              failed=$((failed + 1))
            fi
          done

          echo ""
          echo "Deleted ${deleted} versions (${failed} failed)"

The same workflow, on Latchkey

Removes redundant runs and caps runaway jobs. Added and changed lines are highlighted.

name: Cleanup GHCR Images
 
on:
  schedule:
    - cron: "30 4 * * *" # Daily at 04:30 UTC
  workflow_dispatch:
    inputs:
      dry_run:
        description: "Log what would be deleted without making changes"
        required: false
        default: true
        type: boolean
      retention_days:
        description: "Delete versions older than this many days"
        required: false
        default: 14
        type: number
      min_keep:
        description: "Always keep at least this many versions per package"
        required: false
        default: 10
        type: number
 
permissions:
  packages: write
 
env:
  ORG: TryGhost
  RETENTION_DAYS: ${{ inputs.retention_days || 14 }}
  MIN_KEEP: ${{ inputs.min_keep || 10 }}
 
jobs:
  cleanup:
    timeout-minutes: 30
    name: Cleanup
    runs-on: latchkey-small
    strategy:
      matrix:
        package: [ghost, ghost-core, ghost-development]
    steps:
      - name: Delete old non-release versions
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || inputs.dry_run }}
          PACKAGE: ${{ matrix.package }}
        run: |
          set -euo pipefail
 
          cutoff=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
            || date -u -v-${RETENTION_DAYS}d +%Y-%m-%dT%H:%M:%SZ)
 
          echo "Package: ${ORG}/${PACKAGE}"
          echo "Cutoff:  ${cutoff} (${RETENTION_DAYS} days ago)"
          echo "Dry run: ${DRY_RUN}"
          echo ""
 
          # Pagination - collect all versions
          page=1
          all_versions="[]"
          while true; do
            if ! batch=$(gh api \
              "/orgs/${ORG}/packages/container/${PACKAGE}/versions?per_page=100&page=${page}" \
              --jq '.' 2>&1); then
              if [ "$page" = "1" ]; then
                echo "::error::API request failed: ${batch}"
                exit 1
              fi
              echo "::warning::API request failed (page ${page}): ${batch}"
              break
            fi
 
            count=$(echo "$batch" | jq 'length')
            if [ "$count" = "0" ]; then
              break
            fi
 
            all_versions=$(echo "$all_versions $batch" | jq -s 'add')
            page=$((page + 1))
          done
 
          total=$(echo "$all_versions" | jq 'length')
          echo "Total versions: ${total}"
 
          # Classify versions
          keep=0
          delete=0
          delete_ids=""
 
          for row in $(echo "$all_versions" | jq -r '.[] | @base64'); do
            _jq() { echo "$row" | base64 -d | jq -r "$1"; }
 
            id=$(_jq '.id')
            updated=$(_jq '.updated_at')
            tags=$(_jq '[.metadata.container.tags[]] | join(",")')
 
            # Keep versions with semver tags (v1.2.3, 1.2.3, 1.2)
            if echo "$tags" | grep -qE '(^|,)v?[0-9]+\.[0-9]+\.[0-9]+(,|$)' || \
               echo "$tags" | grep -qE '(^|,)[0-9]+\.[0-9]+(,|$)'; then
              keep=$((keep + 1))
              continue
            fi
 
            # Keep versions with 'latest' or 'main' or cache-main tags
            if echo "$tags" | grep -qE '(^|,)(latest|main|cache-main)(,|$)'; then
              keep=$((keep + 1))
              continue
            fi
 
            # Keep versions newer than cutoff
            if [[ "$updated" > "$cutoff" ]]; then
              keep=$((keep + 1))
              continue
            fi
 
            # This version is eligible for deletion
            delete=$((delete + 1))
            delete_ids="${delete_ids} ${id}"
 
            tag_display="${tags:-<untagged>}"
            if [ "$DRY_RUN" = "true" ]; then
              echo "[dry-run] Would delete version ${id} (tags: ${tag_display}, updated: ${updated})"
            fi
          done
 
          echo ""
          echo "Summary: ${keep} kept, ${delete} to delete (of ${total} total)"
 
          if [ "$delete" = "0" ]; then
            echo "Nothing to delete."
            exit 0
          fi
 
          # Safety check - run before dry-run exit so users see the warning
          if [ "$keep" -lt "$MIN_KEEP" ]; then
            echo "::error::Safety check failed - only ${keep} versions would remain (minimum: ${MIN_KEEP}). Aborting."
            exit 1
          fi
 
          if [ "$DRY_RUN" = "true" ]; then
            echo ""
            echo "Dry run - no versions deleted."
            exit 0
          fi
 
          # Delete eligible versions
          deleted=0
          failed=0
          for id in $delete_ids; do
            if gh api --method DELETE \
              "/orgs/${ORG}/packages/container/${PACKAGE}/versions/${id}" 2>/dev/null; then
              deleted=$((deleted + 1))
            else
              echo "::warning::Failed to delete version ${id}"
              failed=$((failed + 1))
            fi
          done
 
          echo ""
          echo "Deleted ${deleted} versions (${failed} failed)"
 

What changed

This workflow runs 1 job (3 with the matrix expanded) per trigger. On Latchkey the same minutes cost up to 58% less than GitHub-hosted, with zero queue time.