Skip to content

Commit 50e578a

Browse files
committed
ci(cleanup): refactor retention logic with candidate filtering
Restructure the image retention evaluation to pre-compute candidates before the main deletion loop. The new approach applies filters in a clear three-step process: retention window, count cap, and safety minimum. Update input descriptions for clarity and remove default values to require explicit retention policy configuration.
1 parent cab7d0a commit 50e578a

1 file changed

Lines changed: 43 additions & 30 deletions

File tree

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

Lines changed: 43 additions & 30 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 (0 = no count limit, minimum 1 always kept)
9+
description: Maximum number of tagged images to keep (high water mark, 0 = no limit)
1010
required: false
11-
default: '4'
11+
default: ''
1212
retention_days:
13-
description: Delete non-SHA-only image versions older than this many days (0 = no age limit)
13+
description: Delete images older than this many days (0 = no age limit)
1414
required: false
15-
default: '30'
15+
default: ''
1616
delete_sha_only_tags:
1717
description: Delete image versions that only have SHA tags
1818
required: false
@@ -54,10 +54,8 @@ jobs:
5454
const packageName = process.env.PACKAGE_NAME;
5555
const imagesToKeep = Number(process.env.IMAGES_TO_KEEP || 0);
5656
const retentionDays = Number(process.env.RETENTION_DAYS || 0);
57-
const minimumToKeep = Math.max(1, imagesToKeep);
5857
const dryRun = process.env.DRY_RUN === 'true';
5958
const cutoff = retentionDays > 0 ? new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000) : null;
60-
let deletedCount = 0;
6159
6260
if (!Number.isFinite(imagesToKeep) || imagesToKeep < 0) {
6361
throw new Error(`IMAGES_TO_KEEP must be a non-negative number, received: ${process.env.IMAGES_TO_KEEP}`);
@@ -135,43 +133,58 @@ jobs:
135133
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
136134
);
137135
136+
const nonShaOnlyVersions = sortedVersions.filter(v => !isShaOnlyVersion(v));
137+
138+
// Step 1: Filter by retention window (if RETENTION_DAYS > 0)
139+
let candidates = nonShaOnlyVersions;
140+
if (retentionDays > 0) {
141+
candidates = nonShaOnlyVersions.filter(v => new Date(v.updated_at) >= cutoff);
142+
core.info(`Retention filter: ${candidates.length} of ${nonShaOnlyVersions.length} versions within ${retentionDays} days`);
143+
}
144+
145+
// Step 2: Cap by IMAGES_TO_KEEP (high water mark, if > 0)
146+
if (imagesToKeep > 0 && candidates.length > imagesToKeep) {
147+
candidates = candidates.slice(0, imagesToKeep);
148+
core.info(`Count cap: limiting to ${imagesToKeep} newest versions`);
149+
}
150+
151+
// Step 3: Safety - ensure at least 1 tagged image
152+
const taggedCandidates = candidates.filter(hasNonShaTag);
153+
if (taggedCandidates.length === 0) {
154+
const newestTagged = nonShaOnlyVersions.find(hasNonShaTag);
155+
if (newestTagged) {
156+
candidates.unshift(newestTagged);
157+
core.info(`Safety: added newest tagged version ${newestTagged.id} to ensure minimum of 1 tagged image`);
158+
}
159+
}
160+
161+
// Build set of IDs to keep
162+
const keepIds = new Set(candidates.map(v => v.id));
163+
164+
let deletedCount = 0;
138165
let retainedCount = 0;
139166
let retainedTaggedCount = 0;
140167
141168
for (const version of sortedVersions) {
142-
const updatedAt = new Date(version.updated_at);
143169
const tags = version.metadata?.container?.tags ?? [];
144170
const isShaOnly = isShaOnlyVersion(version);
145-
const isTagged = hasNonShaTag(version);
146-
const withinRetention = retentionDays > 0 && updatedAt >= cutoff;
147-
const needForMinimum = retainedTaggedCount < minimumToKeep;
148171
149172
if (isShaOnly) {
150-
core.info(`Skipping age-based keep-count evaluation for SHA-only version ${version.id} with tags: ${tags.join(', ')}`);
173+
core.info(`Skipping SHA-only version ${version.id} with tags: ${tags.join(', ')}`);
151174
continue;
152175
}
153176
154-
if (withinRetention) {
155-
core.info(`Keeping version ${version.id}; updated ${version.updated_at} is within ${retentionDays} days.`);
177+
if (keepIds.has(version.id)) {
178+
core.info(`Keeping version ${version.id} with tags: ${tags.join(', ') || '(none)'}`);
156179
retainedCount += 1;
157-
if (isTagged) retainedTaggedCount += 1;
158-
continue;
159-
}
160-
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;
165-
continue;
166-
}
167-
168-
core.info(
169-
`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} updated ${version.updated_at} with tags: ${tags.join(', ') || '(none)'}`,
170-
);
171-
if (!dryRun) {
172-
await deleteVersion(scope, version.id);
180+
if (hasNonShaTag(version)) retainedTaggedCount += 1;
181+
} else {
182+
core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} updated ${version.updated_at} with tags: ${tags.join(', ') || '(none)'}`);
183+
if (!dryRun) {
184+
await deleteVersion(scope, version.id);
185+
}
186+
deletedCount += 1;
173187
}
174-
deletedCount += 1;
175188
}
176189
177190
return JSON.stringify({ deletedCount, retainedCount, retainedTaggedCount });

0 commit comments

Comments
 (0)