Skip to content
Latchkey

GHCR Cleanup workflow (mattrobinsonsre/terrapod)

The GHCR Cleanup workflow from mattrobinsonsre/terrapod, 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: mattrobinsonsre/terrapod.github/workflows/cleanup.ymlLicense MPL-2.0View source

What it does

This is the GHCR Cleanup workflow from the mattrobinsonsre/terrapod repository, a real project running GitHub Actions. It is shown here with attribution under its MPL-2.0 license.

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

The workflow

workflow (.yml)
name: GHCR Cleanup

on:
  schedule:
    - cron: '0 3 * * 0'   # Weekly, Sunday 03:00 UTC
  workflow_dispatch:

env:
  REGISTRY: ghcr.io/mattrobinsonsre

jobs:
  cleanup:
    name: Clean ${{ matrix.image }}
    runs-on: ubuntu-latest
    permissions:
      packages: write
    strategy:
      matrix:
        # All five published images. Omitting any (previously listener +
        # migrations were missing) leaves its sha-tagged versions to
        # accumulate in GHCR forever, since main pushes all five on every
        # commit but only the listed ones get reaped.
        image: [terrapod-api, terrapod-web, terrapod-runner, terrapod-listener, terrapod-migrations]
    steps:
      - name: Delete old SHA-tagged versions
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # Delete untagged and sha-* tagged versions older than 30 days,
          # keeping all semver-tagged (v*) versions indefinitely.
          cutoff=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)
          echo "Cutoff: $cutoff"
          echo "Image: ${{ matrix.image }}"

          # List all versions
          versions=$(gh api --paginate \
            "users/mattrobinsonsre/packages/container/${{ matrix.image }}/versions" \
            --jq '.[]') || { echo "No versions found"; exit 0; }

          echo "$versions" | jq -c '.' | while read -r version; do
            id=$(echo "$version" | jq -r '.id')
            tags=$(echo "$version" | jq -r '.metadata.container.tags[]?' 2>/dev/null)
            updated=$(echo "$version" | jq -r '.updated_at')

            # Skip versions with any semver tag (v*)
            has_semver=false
            while IFS= read -r tag; do
              if [[ "$tag" =~ ^v[0-9] ]]; then
                has_semver=true
                break
              fi
            done <<< "$tags"

            if [[ "$has_semver" == "true" ]]; then
              echo "KEEP $id (semver tagged: $tags)"
              continue
            fi

            # Skip if newer than cutoff
            if [[ "$updated" > "$cutoff" ]]; then
              echo "KEEP $id (recent: $updated, tags: $tags)"
              continue
            fi

            echo "DELETE $id (old: $updated, tags: $tags)"
            gh api -X DELETE \
              "users/mattrobinsonsre/packages/container/${{ matrix.image }}/versions/$id" \
              2>/dev/null || echo "  Failed to delete $id"
          done

          echo "Cleanup complete for ${{ matrix.image }}"

The same workflow, on Latchkey

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

name: GHCR Cleanup
 
on:
  schedule:
    - cron: '0 3 * * 0'   # Weekly, Sunday 03:00 UTC
  workflow_dispatch:
 
env:
  REGISTRY: ghcr.io/mattrobinsonsre
 
jobs:
  cleanup:
    timeout-minutes: 30
    name: Clean ${{ matrix.image }}
    runs-on: latchkey-small
    permissions:
      packages: write
    strategy:
      matrix:
        # All five published images. Omitting any (previously listener +
        # migrations were missing) leaves its sha-tagged versions to
        # accumulate in GHCR forever, since main pushes all five on every
        # commit but only the listed ones get reaped.
        image: [terrapod-api, terrapod-web, terrapod-runner, terrapod-listener, terrapod-migrations]
    steps:
      - name: Delete old SHA-tagged versions
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # Delete untagged and sha-* tagged versions older than 30 days,
          # keeping all semver-tagged (v*) versions indefinitely.
          cutoff=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)
          echo "Cutoff: $cutoff"
          echo "Image: ${{ matrix.image }}"
 
          # List all versions
          versions=$(gh api --paginate \
            "users/mattrobinsonsre/packages/container/${{ matrix.image }}/versions" \
            --jq '.[]') || { echo "No versions found"; exit 0; }
 
          echo "$versions" | jq -c '.' | while read -r version; do
            id=$(echo "$version" | jq -r '.id')
            tags=$(echo "$version" | jq -r '.metadata.container.tags[]?' 2>/dev/null)
            updated=$(echo "$version" | jq -r '.updated_at')
 
            # Skip versions with any semver tag (v*)
            has_semver=false
            while IFS= read -r tag; do
              if [[ "$tag" =~ ^v[0-9] ]]; then
                has_semver=true
                break
              fi
            done <<< "$tags"
 
            if [[ "$has_semver" == "true" ]]; then
              echo "KEEP $id (semver tagged: $tags)"
              continue
            fi
 
            # Skip if newer than cutoff
            if [[ "$updated" > "$cutoff" ]]; then
              echo "KEEP $id (recent: $updated, tags: $tags)"
              continue
            fi
 
            echo "DELETE $id (old: $updated, tags: $tags)"
            gh api -X DELETE \
              "users/mattrobinsonsre/packages/container/${{ matrix.image }}/versions/$id" \
              2>/dev/null || echo "  Failed to delete $id"
          done
 
          echo "Cleanup complete for ${{ matrix.image }}"
 

What changed

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