Skip to content

Commit 1b7db33

Browse files
committed
ci(cleanup): add semantic version sorting for image retention
Add semantic version parsing and comparison logic to sort container images by version number rather than just update time. This ensures newer semantic versions are retained preferentially during cleanup operations, with updated_at as a tiebreaker for non-versioned images.
1 parent c516534 commit 1b7db33

File tree

1 file changed

+68
-7
lines changed

1 file changed

+68
-7
lines changed

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

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,61 @@ jobs:
128128
return tags.length > 0 && tags.some(tag => !isShaTag(tag));
129129
};
130130
131+
// Semantic version handling
132+
const specialTags = new Set(['latest', 'stable', 'next', 'canary', 'edge', 'dev', 'alpha', 'beta', 'rc', 'release']);
133+
const semverPattern = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-._]?(.+))?$/;
134+
135+
const parseSemver = (tag) => {
136+
const match = tag.match(semverPattern);
137+
if (!match) return null;
138+
return {
139+
major: parseInt(match[1], 10),
140+
minor: match[2] !== undefined ? parseInt(match[2], 10) : 0,
141+
patch: match[3] !== undefined ? parseInt(match[3], 10) : 0,
142+
prerelease: match[4] || null,
143+
original: tag
144+
};
145+
};
146+
147+
const compareSemver = (a, b) => {
148+
if (a.major !== b.major) return a.major - b.major;
149+
if (a.minor !== b.minor) return a.minor - b.minor;
150+
if (a.patch !== b.patch) return a.patch - b.patch;
151+
// Prerelease versions are lower than release
152+
if (a.prerelease && !b.prerelease) return -1;
153+
if (!a.prerelease && b.prerelease) return 1;
154+
if (a.prerelease && b.prerelease) return a.prerelease.localeCompare(b.prerelease);
155+
return 0;
156+
};
157+
158+
const extractSemver = (version) => {
159+
const tags = version.metadata?.container?.tags ?? [];
160+
const semverTags = tags
161+
.filter(tag => !isShaTag(tag) && !specialTags.has(tag.toLowerCase()))
162+
.map(parseSemver)
163+
.filter(Boolean)
164+
.sort((a, b) => compareSemver(b, a)); // Highest version first
165+
return semverTags[0] || null;
166+
};
167+
131168
const { scope, versions } = await paginateVersions();
132-
const sortedVersions = [...versions].sort(
133-
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
134-
);
169+
170+
// Sort by semantic version DESC, then by updated_at DESC as tiebreaker
171+
const sortedVersions = [...versions].sort((left, right) => {
172+
const leftSemver = extractSemver(left);
173+
const rightSemver = extractSemver(right);
174+
175+
if (leftSemver && rightSemver) {
176+
const cmp = compareSemver(rightSemver, leftSemver);
177+
if (cmp !== 0) return cmp;
178+
} else if (leftSemver && !rightSemver) {
179+
return -1; // Has version > no version
180+
} else if (!leftSemver && rightSemver) {
181+
return 1;
182+
}
183+
// Tiebreaker: newer update time first
184+
return new Date(right.updated_at) - new Date(left.updated_at);
185+
});
135186
136187
const nonShaOnlyVersions = sortedVersions.filter(v => !isShaOnlyVersion(v));
137188
@@ -265,20 +316,30 @@ jobs:
265316
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
266317
);
267318
319+
const shaOnlyVersions = sortedVersions.filter(v => {
320+
const tags = v.metadata?.container?.tags ?? [];
321+
return tags.length > 0 && tags.every(isShaTag);
322+
});
323+
324+
core.info(`Found ${shaOnlyVersions.length} SHA-only versions to evaluate`);
325+
268326
let shaOnlyDeletedCount = 0;
269327
270-
for (const version of sortedVersions) {
328+
for (const version of shaOnlyVersions) {
271329
const tags = version.metadata?.container?.tags ?? [];
272-
const isShaOnly = tags.length > 0 && tags.every(isShaTag);
273-
if (!isShaOnly) continue;
274-
275330
core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} with SHA-only tags: ${tags.join(', ')}`);
276331
if (!dryRun) {
277332
await deleteVersion(scope, version.id);
278333
}
279334
shaOnlyDeletedCount += 1;
280335
}
281336
337+
if (shaOnlyDeletedCount > 0) {
338+
core.info(`${dryRun ? 'Would delete' : 'Deleted'} ${shaOnlyDeletedCount} SHA-only version(s)`);
339+
} else {
340+
core.info('No SHA-only versions to delete');
341+
}
342+
282343
return JSON.stringify({
283344
totalBefore: sortedVersions.length,
284345
ageBasedDeleted: ageBasedResult.deletedCount || 0,

0 commit comments

Comments
 (0)