Skip to content

Commit 5bf370b

Browse files
committed
Added nightly GHCR image cleanup workflow
ref https://linear.app/tryghost/issue/BER-3423/ Scheduled nightly cleanup for ghost, ghost-core, and ghost-development container packages. Deletes versions older than 14 days while preserving semver-tagged releases, latest/main/cache-main tags, and a minimum of 10 versions per package. Uses GITHUB_TOKEN with packages:write — no PAT needed since the packages are published from this repo.
1 parent 338e1bd commit 5bf370b

File tree

1 file changed

+158
-0
lines changed

1 file changed

+158
-0
lines changed

.github/workflows/cleanup-ghcr.yml

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
name: Cleanup GHCR Images
2+
3+
on:
4+
schedule:
5+
- cron: "30 4 * * *" # Daily at 04:30 UTC
6+
workflow_dispatch:
7+
inputs:
8+
dry_run:
9+
description: "Log what would be deleted without making changes"
10+
required: false
11+
default: true
12+
type: boolean
13+
retention_days:
14+
description: "Delete versions older than this many days"
15+
required: false
16+
default: 14
17+
type: number
18+
min_keep:
19+
description: "Always keep at least this many versions per package"
20+
required: false
21+
default: 10
22+
type: number
23+
24+
permissions:
25+
packages: write
26+
27+
env:
28+
ORG: TryGhost
29+
RETENTION_DAYS: ${{ inputs.retention_days || 14 }}
30+
MIN_KEEP: ${{ inputs.min_keep || 10 }}
31+
32+
jobs:
33+
cleanup:
34+
name: Cleanup
35+
runs-on: ubuntu-latest
36+
strategy:
37+
matrix:
38+
package: [ghost, ghost-core, ghost-development]
39+
steps:
40+
- name: Delete old non-release versions
41+
env:
42+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43+
DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || inputs.dry_run }}
44+
PACKAGE: ${{ matrix.package }}
45+
run: |
46+
set -euo pipefail
47+
48+
cutoff=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
49+
|| date -u -v-${RETENTION_DAYS}d +%Y-%m-%dT%H:%M:%SZ)
50+
51+
echo "Package: ${ORG}/${PACKAGE}"
52+
echo "Cutoff: ${cutoff} (${RETENTION_DAYS} days ago)"
53+
echo "Dry run: ${DRY_RUN}"
54+
echo ""
55+
56+
# Pagination — collect all versions
57+
page=1
58+
all_versions="[]"
59+
while true; do
60+
if ! batch=$(gh api \
61+
"/orgs/${ORG}/packages/container/${PACKAGE}/versions?per_page=100&page=${page}" \
62+
--jq '.' 2>&1); then
63+
if [ "$page" = "1" ]; then
64+
echo "::error::API request failed: ${batch}"
65+
exit 1
66+
fi
67+
echo "::warning::API request failed (page ${page}): ${batch}"
68+
break
69+
fi
70+
71+
count=$(echo "$batch" | jq 'length')
72+
if [ "$count" = "0" ]; then
73+
break
74+
fi
75+
76+
all_versions=$(echo "$all_versions $batch" | jq -s 'add')
77+
page=$((page + 1))
78+
done
79+
80+
total=$(echo "$all_versions" | jq 'length')
81+
echo "Total versions: ${total}"
82+
83+
# Classify versions
84+
keep=0
85+
delete=0
86+
delete_ids=""
87+
88+
for row in $(echo "$all_versions" | jq -r '.[] | @base64'); do
89+
_jq() { echo "$row" | base64 -d | jq -r "$1"; }
90+
91+
id=$(_jq '.id')
92+
updated=$(_jq '.updated_at')
93+
tags=$(_jq '[.metadata.container.tags[]] | join(",")')
94+
95+
# Keep versions with semver tags (v1.2.3, 1.2.3, 1.2)
96+
if echo "$tags" | grep -qE '(^|,)v?[0-9]+\.[0-9]+\.[0-9]+(,|$)' || \
97+
echo "$tags" | grep -qE '(^|,)[0-9]+\.[0-9]+(,|$)'; then
98+
keep=$((keep + 1))
99+
continue
100+
fi
101+
102+
# Keep versions with 'latest' or 'main' or cache-main tags
103+
if echo "$tags" | grep -qE '(^|,)(latest|main|cache-main)(,|$)'; then
104+
keep=$((keep + 1))
105+
continue
106+
fi
107+
108+
# Keep versions newer than cutoff
109+
if [[ "$updated" > "$cutoff" ]]; then
110+
keep=$((keep + 1))
111+
continue
112+
fi
113+
114+
# This version is eligible for deletion
115+
delete=$((delete + 1))
116+
delete_ids="${delete_ids} ${id}"
117+
118+
tag_display="${tags:-<untagged>}"
119+
if [ "$DRY_RUN" = "true" ]; then
120+
echo "[dry-run] Would delete version ${id} (tags: ${tag_display}, updated: ${updated})"
121+
fi
122+
done
123+
124+
echo ""
125+
echo "Summary: ${keep} kept, ${delete} to delete (of ${total} total)"
126+
127+
if [ "$delete" = "0" ]; then
128+
echo "Nothing to delete."
129+
exit 0
130+
fi
131+
132+
# Safety check — run before dry-run exit so users see the warning
133+
if [ "$keep" -lt "$MIN_KEEP" ]; then
134+
echo "::error::Safety check failed — only ${keep} versions would remain (minimum: ${MIN_KEEP}). Aborting."
135+
exit 1
136+
fi
137+
138+
if [ "$DRY_RUN" = "true" ]; then
139+
echo ""
140+
echo "Dry run — no versions deleted."
141+
exit 0
142+
fi
143+
144+
# Delete eligible versions
145+
deleted=0
146+
failed=0
147+
for id in $delete_ids; do
148+
if gh api --method DELETE \
149+
"/orgs/${ORG}/packages/container/${PACKAGE}/versions/${id}" 2>/dev/null; then
150+
deleted=$((deleted + 1))
151+
else
152+
echo "::warning::Failed to delete version ${id}"
153+
failed=$((failed + 1))
154+
fi
155+
done
156+
157+
echo ""
158+
echo "Deleted ${deleted} versions (${failed} failed)"

0 commit comments

Comments
 (0)