GHCR Cleanup workflow (mattrobinsonsre/terrapod)
The GHCR Cleanup workflow from mattrobinsonsre/terrapod, explained and optimized by Latchkey.
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.
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
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
- Run on Latchkey managed runners with one line (
runs-on), which apply the fixes below automatically and self-heal transient failures. This example useslatchkey-small; pick the runner size that fits the job. - Add a job timeout so a hung step cannot burn hours of runner time.
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.