Skip to content

Commit b07475f

Browse files
committed
ci(cleanup): refactor retention logic and make limits optional
Remove hardcoded defaults for images_to_keep and retention_days inputs, allowing 0 values to disable count-based or age-based cleanup limits. The workflow now ensures a minimum of 1 tagged image is always retained for safety. Also simplifies the cleanup loop with cleaner state tracking and converts the report generation step from shell script to GitHub script.
1 parent 825dd80 commit b07475f

1 file changed

Lines changed: 85 additions & 97 deletions

File tree

.github/workflows/cleanup-ghcr-images.yml

Lines changed: 85 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ on:
66
workflow_dispatch:
77
inputs:
88
images_to_keep:
9-
description: Keep this many newest non-SHA-only image versions
9+
description: Keep this many newest non-SHA-only image versions (0 = no count limit, minimum 1 always kept)
1010
required: false
11-
default: '4'
11+
default: ''
1212
retention_days:
13-
description: Delete non-SHA-only image versions older than this many days
13+
description: Delete non-SHA-only image versions older than this many days (0 = no age limit)
1414
required: false
15-
default: '45'
15+
default: ''
1616
delete_sha_only_tags:
1717
description: Delete image versions that only have SHA tags
1818
required: false
@@ -29,8 +29,8 @@ permissions:
2929

3030
env:
3131
PACKAGE_NAME: opencode-cli
32-
IMAGES_TO_KEEP: ${{ inputs.images_to_keep || vars.GHCR_IMAGES_TO_KEEP || '4' }}
33-
RETENTION_DAYS: ${{ inputs.retention_days || vars.GHCR_RETENTION_DAYS || '45' }}
32+
IMAGES_TO_KEEP: ${{ inputs.images_to_keep || vars.GHCR_IMAGES_TO_KEEP }}
33+
RETENTION_DAYS: ${{ inputs.retention_days || vars.GHCR_RETENTION_DAYS }}
3434
DELETE_SHA_ONLY_TAGS: ${{ github.event_name == 'workflow_dispatch' && inputs.delete_sha_only_tags || vars.GHCR_DELETE_SHA_ONLY_TAGS || 'true' }}
3535
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
3636

@@ -52,18 +52,19 @@ jobs:
5252
const owner = context.repo.owner;
5353
const packageType = 'container';
5454
const packageName = process.env.PACKAGE_NAME;
55-
const imagesToKeep = Math.max(1, Number(process.env.IMAGES_TO_KEEP || '4'));
56-
const retentionDays = Number(process.env.RETENTION_DAYS);
55+
const imagesToKeep = Number(process.env.IMAGES_TO_KEEP || 0);
56+
const retentionDays = Number(process.env.RETENTION_DAYS || 0);
57+
const minimumToKeep = Math.max(1, imagesToKeep);
5758
const dryRun = process.env.DRY_RUN === 'true';
58-
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
59-
const deletedVersionIds = [];
59+
const cutoff = retentionDays > 0 ? new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000) : null;
60+
let deletedCount = 0;
6061
61-
if (!Number.isFinite(imagesToKeep)) {
62-
throw new Error(`IMAGES_TO_KEEP must be a number, received: ${process.env.IMAGES_TO_KEEP}`);
62+
if (!Number.isFinite(imagesToKeep) || imagesToKeep < 0) {
63+
throw new Error(`IMAGES_TO_KEEP must be a non-negative number, received: ${process.env.IMAGES_TO_KEEP}`);
6364
}
6465
65-
if (!Number.isFinite(retentionDays)) {
66-
throw new Error(`RETENTION_DAYS must be a number, received: ${process.env.RETENTION_DAYS}`);
66+
if (!Number.isFinite(retentionDays) || retentionDays < 0) {
67+
throw new Error(`RETENTION_DAYS must be a non-negative number, received: ${process.env.RETENTION_DAYS}`);
6768
}
6869
6970
const paginateVersions = async () => {
@@ -124,41 +125,43 @@ jobs:
124125
const tags = version.metadata?.container?.tags ?? [];
125126
return tags.length > 0 && tags.every(isShaTag);
126127
};
128+
const hasNonShaTag = (version) => {
129+
const tags = version.metadata?.container?.tags ?? [];
130+
return tags.length > 0 && tags.some(tag => !isShaTag(tag));
131+
};
127132
128133
const { scope, versions } = await paginateVersions();
129134
const sortedVersions = [...versions].sort(
130135
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
131136
);
132-
const nonShaOnlyVersions = sortedVersions.filter((version) => !isShaOnlyVersion(version));
133-
134-
if (nonShaOnlyVersions.length <= imagesToKeep) {
135-
core.info(`Skipping age-based cleanup because ${nonShaOnlyVersions.length} non-SHA-only package version(s) do not exceed the keep limit of ${imagesToKeep}.`);
136-
return JSON.stringify(deletedVersionIds);
137-
}
138137
139-
let retainedNonShaOnlyCount = nonShaOnlyVersions.length;
140-
let retainedNewestNonShaOnly = 0;
138+
let retainedCount = 0;
139+
let retainedTaggedCount = 0;
141140
142141
for (const version of sortedVersions) {
143142
const updatedAt = new Date(version.updated_at);
144143
const tags = version.metadata?.container?.tags ?? [];
145144
const isShaOnly = isShaOnlyVersion(version);
146-
const isOlderThanRetention = updatedAt < cutoff;
145+
const isTagged = hasNonShaTag(version);
146+
const withinRetention = retentionDays > 0 && updatedAt >= cutoff;
147+
const needForMinimum = retainedTaggedCount < minimumToKeep;
147148
148149
if (isShaOnly) {
149150
core.info(`Skipping age-based keep-count evaluation for SHA-only version ${version.id} with tags: ${tags.join(', ')}`);
150151
continue;
151152
}
152153
153-
if (!isOlderThanRetention) {
154+
if (withinRetention) {
154155
core.info(`Keeping version ${version.id}; updated ${version.updated_at} is within ${retentionDays} days.`);
155-
retainedNewestNonShaOnly += 1;
156+
retainedCount += 1;
157+
if (isTagged) retainedTaggedCount += 1;
156158
continue;
157159
}
158160
159-
if (retainedNewestNonShaOnly < imagesToKeep || retainedNonShaOnlyCount <= imagesToKeep) {
160-
core.info(`Keeping version ${version.id} because it is within the newest ${imagesToKeep} non-SHA-only image version(s).`);
161-
retainedNewestNonShaOnly += 1;
161+
if (isTagged && needForMinimum) {
162+
core.info(`Keeping version ${version.id} to maintain minimum of ${minimumToKeep} tagged image(s).`);
163+
retainedCount += 1;
164+
retainedTaggedCount += 1;
162165
continue;
163166
}
164167
@@ -168,11 +171,10 @@ jobs:
168171
if (!dryRun) {
169172
await deleteVersion(scope, version.id);
170173
}
171-
deletedVersionIds.push(version.id);
172-
retainedNonShaOnlyCount -= 1;
174+
deletedCount += 1;
173175
}
174176
175-
return JSON.stringify(deletedVersionIds);
177+
return JSON.stringify({ deletedCount, retainedCount, retainedTaggedCount });
176178
177179
- name: Delete container versions that only have SHA tags
178180
id: cleanup_sha_only
@@ -181,14 +183,15 @@ jobs:
181183
env:
182184
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
183185
DRY_RUN: ${{ env.DRY_RUN }}
184-
DELETED_VERSION_IDS: ${{ steps.cleanup_by_age.outputs.result }}
186+
AGE_BASED_RESULT: ${{ steps.cleanup_by_age.outputs.result }}
185187
with:
186188
result-encoding: string
187189
script: |
188190
const owner = context.repo.owner;
189191
const packageType = 'container';
190192
const packageName = process.env.PACKAGE_NAME;
191193
const dryRun = process.env.DRY_RUN === 'true';
194+
const ageBasedResult = JSON.parse(process.env.AGE_BASED_RESULT || '{}');
192195
193196
const paginateVersions = async () => {
194197
try {
@@ -249,91 +252,76 @@ jobs:
249252
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
250253
);
251254
252-
let remainingVersions = sortedVersions;
253-
254-
if (dryRun) {
255-
const deletedVersionIds = new Set(JSON.parse(process.env.DELETED_VERSION_IDS || '[]').map(String));
256-
core.info('Dry run enabled; excluding versions marked by the age-based cleanup step from SHA-only evaluation.');
257-
258-
remainingVersions = sortedVersions.filter((version) => !deletedVersionIds.has(String(version.id)));
259-
}
260-
261-
if (remainingVersions.length === 0) {
262-
core.info(`Skipping SHA-only cleanup because there are no remaining package versions to evaluate after the age-based cleanup step.`);
263-
return;
264-
}
255+
let shaOnlyDeletedCount = 0;
265256
266-
for (const version of remainingVersions) {
257+
for (const version of sortedVersions) {
267258
const tags = version.metadata?.container?.tags ?? [];
268259
const isShaOnly = tags.length > 0 && tags.every(isShaTag);
269-
if (!isShaOnly) {
270-
core.info(`Keeping version ${version.id} with tags: ${tags.join(', ') || '(none)'}`);
271-
continue;
272-
}
260+
if (!isShaOnly) continue;
273261
274262
core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} with SHA-only tags: ${tags.join(', ')}`);
275263
if (!dryRun) {
276264
await deleteVersion(scope, version.id);
277265
}
266+
shaOnlyDeletedCount += 1;
278267
}
279268
280-
const shaOnlyDeleted = remainingVersions.filter(v => {
281-
const tags = v.metadata?.container?.tags ?? [];
282-
return tags.length > 0 && tags.every(isShaTag);
283-
});
284-
285269
return JSON.stringify({
286270
totalBefore: sortedVersions.length,
287-
ageBasedDeleted: JSON.parse(process.env.DELETED_VERSION_IDS || '[]').length,
288-
shaOnlyDeleted: shaOnlyDeleted.length,
289-
remaining: sortedVersions.length - (dryRun ? JSON.parse(process.env.DELETED_VERSION_IDS || '[]').length + shaOnlyDeleted.length : 0),
271+
ageBasedDeleted: ageBasedResult.deletedCount || 0,
272+
ageBasedRetained: ageBasedResult.retainedCount || 0,
273+
ageBasedRetainedTagged: ageBasedResult.retainedTaggedCount || 0,
274+
shaOnlyDeleted: shaOnlyDeletedCount,
290275
});
291276
292277
- name: Generate cleanup report
293278
if: always()
279+
uses: actions/github-script@v8
294280
env:
295281
AGE_BASED_RESULT: ${{ steps.cleanup_by_age.outputs.result }}
296282
SHA_ONLY_RESULT: ${{ steps.cleanup_sha_only.outputs.result }}
297283
DELETE_SHA_ONLY_TAGS: ${{ env.DELETE_SHA_ONLY_TAGS }}
298284
DRY_RUN: ${{ env.DRY_RUN }}
299285
IMAGES_TO_KEEP: ${{ env.IMAGES_TO_KEEP }}
300286
RETENTION_DAYS: ${{ env.RETENTION_DAYS }}
301-
run: |
302-
echo "## 🧹 GHCR Cleanup Report" >> $GITHUB_STEP_SUMMARY
303-
echo "" >> $GITHUB_STEP_SUMMARY
304-
305-
if [ "$DRY_RUN" = "true" ]; then
306-
echo "" >> $GITHUB_STEP_SUMMARY
307-
echo "> ⚠️ **Dry run mode** - no images were actually deleted" >> $GITHUB_STEP_SUMMARY
308-
echo "" >> $GITHUB_STEP_SUMMARY
309-
fi
310-
echo "" >> $GITHUB_STEP_SUMMARY
311-
312-
echo "### Configuration" >> $GITHUB_STEP_SUMMARY
313-
echo "| Setting | Value |" >> $GITHUB_STEP_SUMMARY
314-
echo "|---------|-------|" >> $GITHUB_STEP_SUMMARY
315-
echo "| Images to keep | ${IMAGES_TO_KEEP} |" >> $GITHUB_STEP_SUMMARY
316-
echo "| Retention days | ${RETENTION_DAYS} |" >> $GITHUB_STEP_SUMMARY
317-
echo "| Delete SHA-only tags | ${DELETE_SHA_ONLY_TAGS} |" >> $GITHUB_STEP_SUMMARY
318-
echo "" >> $GITHUB_STEP_SUMMARY
319-
320-
if [ -n "$SHA_ONLY_RESULT" ] && [ "$SHA_ONLY_RESULT" != "null" ]; then
321-
AGE_DELETED=$(echo "$AGE_BASED_RESULT" | sed 's/^"//;s/"$//' | jq -r 'if type == "array" then length elif type == "object" then .ageBasedDeleted // 0 else 0 end')
322-
SHA_DELETED=$(echo "$SHA_ONLY_RESULT" | sed 's/^"//;s/"$//' | jq '.shaOnlyDeleted // 0')
323-
TOTAL_BEFORE=$(echo "$SHA_ONLY_RESULT" | sed 's/^"//;s/"$//' | jq '.totalBefore // 0')
324-
REMAINING=$(echo "$SHA_ONLY_RESULT" | sed 's/^"//;s/"$//' | jq '.remaining // 0')
325-
326-
echo "### Summary" >> $GITHUB_STEP_SUMMARY
327-
echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
328-
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
329-
echo "| Total versions before | ${TOTAL_BEFORE} |" >> $GITHUB_STEP_SUMMARY
330-
echo "| Age-based deletions | ${AGE_DELETED} |" >> $GITHUB_STEP_SUMMARY
331-
echo "| SHA-only deletions | ${SHA_DELETED} |" >> $GITHUB_STEP_SUMMARY
332-
echo "| **Remaining versions** | **${REMAINING}** |" >> $GITHUB_STEP_SUMMARY
333-
elif [ -n "$AGE_BASED_RESULT" ] && [ "$AGE_BASED_RESULT" != "null" ]; then
334-
AGE_DELETED=$(echo "$AGE_BASED_RESULT" | sed 's/^"//;s/"$//' | jq -r 'if type == "array" then length else 0 end')
335-
echo "### Summary" >> $GITHUB_STEP_SUMMARY
336-
echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
337-
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
338-
echo "| Age-based deletions | ${AGE_DELETED} |" >> $GITHUB_STEP_SUMMARY
339-
fi
287+
with:
288+
script: |
289+
const dryRun = process.env.DRY_RUN === 'true';
290+
const imagesToKeep = process.env.IMAGES_TO_KEEP || '0';
291+
const retentionDays = process.env.RETENTION_DAYS || '0';
292+
293+
let summary = '## 🧹 GHCR Cleanup Report\n\n';
294+
295+
if (dryRun) {
296+
summary += '> ⚠️ **Dry run mode** - no images were actually deleted\n\n';
297+
}
298+
299+
summary += '### Configuration\n';
300+
summary += '| Setting | Value |\n';
301+
summary += '|---------|-------|\n';
302+
summary += `| Images to keep | ${imagesToKeep} |\n`;
303+
summary += `| Retention days | ${retentionDays} |\n`;
304+
summary += `| Delete SHA-only tags | ${process.env.DELETE_SHA_ONLY_TAGS} |\n\n`;
305+
306+
const shaOnlyResult = process.env.SHA_ONLY_RESULT;
307+
const ageBasedResult = process.env.AGE_BASED_RESULT;
308+
309+
if (shaOnlyResult && shaOnlyResult !== 'null') {
310+
const result = JSON.parse(shaOnlyResult);
311+
summary += '### Summary\n';
312+
summary += '| Metric | Count |\n';
313+
summary += '|--------|-------|\n';
314+
summary += `| Total versions before | ${result.totalBefore || 0} |\n`;
315+
summary += `| Age-based deletions | ${result.ageBasedDeleted || 0} |\n`;
316+
summary += `| SHA-only deletions | ${result.shaOnlyDeleted || 0} |\n`;
317+
summary += `| **Retained tagged images** | **${result.ageBasedRetainedTagged || 0}** |\n`;
318+
} else if (ageBasedResult && ageBasedResult !== 'null') {
319+
const result = JSON.parse(ageBasedResult);
320+
summary += '### Summary\n';
321+
summary += '| Metric | Count |\n';
322+
summary += '|--------|-------|\n';
323+
summary += `| Age-based deletions | ${result.deletedCount || 0} |\n`;
324+
summary += `| **Retained tagged images** | **${result.retainedTaggedCount || 0}** |\n`;
325+
}
326+
327+
core.summary.addRaw(summary).write();

0 commit comments

Comments
 (0)