Skip to content

Commit 775f311

Browse files
committed
ci(cleanup): consolidate cleanup steps into single unified pass
Combine age-based and SHA-only cleanup into one step for better efficiency and cleaner workflow structure. Remove the separate cleanup_sha_only step and integrate its logic into the main cleanup process. Add comprehensive logging for each version category and unified result reporting.
1 parent 1b7db33 commit 775f311

File tree

1 file changed

+71
-143
lines changed

1 file changed

+71
-143
lines changed

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

Lines changed: 71 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@ jobs:
3838
cleanup:
3939
runs-on: ubuntu-latest
4040
steps:
41-
- name: Delete old container versions
42-
id: cleanup_by_age
41+
- name: Cleanup container versions
42+
id: cleanup
4343
uses: actions/github-script@v8
4444
env:
4545
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
4646
IMAGES_TO_KEEP: ${{ env.IMAGES_TO_KEEP }}
4747
RETENTION_DAYS: ${{ env.RETENTION_DAYS }}
48+
DELETE_SHA_ONLY_TAGS: ${{ env.DELETE_SHA_ONLY_TAGS }}
4849
DRY_RUN: ${{ env.DRY_RUN }}
4950
with:
5051
result-encoding: string
@@ -54,6 +55,7 @@ jobs:
5455
const packageName = process.env.PACKAGE_NAME;
5556
const imagesToKeep = Number(process.env.IMAGES_TO_KEEP || 0);
5657
const retentionDays = Number(process.env.RETENTION_DAYS || 0);
58+
const deleteShaOnly = process.env.DELETE_SHA_ONLY_TAGS === 'true';
5759
const dryRun = process.env.DRY_RUN === 'true';
5860
const cutoff = retentionDays > 0 ? new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000) : null;
5961
@@ -148,7 +150,6 @@ jobs:
148150
if (a.major !== b.major) return a.major - b.major;
149151
if (a.minor !== b.minor) return a.minor - b.minor;
150152
if (a.patch !== b.patch) return a.patch - b.patch;
151-
// Prerelease versions are lower than release
152153
if (a.prerelease && !b.prerelease) return -1;
153154
if (!a.prerelease && b.prerelease) return 1;
154155
if (a.prerelease && b.prerelease) return a.prerelease.localeCompare(b.prerelease);
@@ -161,11 +162,12 @@ jobs:
161162
.filter(tag => !isShaTag(tag) && !specialTags.has(tag.toLowerCase()))
162163
.map(parseSemver)
163164
.filter(Boolean)
164-
.sort((a, b) => compareSemver(b, a)); // Highest version first
165+
.sort((a, b) => compareSemver(b, a));
165166
return semverTags[0] || null;
166167
};
167168
168169
const { scope, versions } = await paginateVersions();
170+
core.info(`Found ${versions.length} total versions`);
169171
170172
// Sort by semantic version DESC, then by updated_at DESC as tiebreaker
171173
const sortedVersions = [...versions].sort((left, right) => {
@@ -176,30 +178,34 @@ jobs:
176178
const cmp = compareSemver(rightSemver, leftSemver);
177179
if (cmp !== 0) return cmp;
178180
} else if (leftSemver && !rightSemver) {
179-
return -1; // Has version > no version
181+
return -1;
180182
} else if (!leftSemver && rightSemver) {
181183
return 1;
182184
}
183-
// Tiebreaker: newer update time first
184185
return new Date(right.updated_at) - new Date(left.updated_at);
185186
});
186187
188+
// Separate SHA-only from regular versions
189+
const shaOnlyVersions = sortedVersions.filter(isShaOnlyVersion);
187190
const nonShaOnlyVersions = sortedVersions.filter(v => !isShaOnlyVersion(v));
188191
189-
// Step 1: Filter by retention window (if RETENTION_DAYS > 0)
192+
core.info(`${shaOnlyVersions.length} SHA-only versions${deleteShaOnly ? ' (will be deleted)' : ''}`);
193+
core.info(`${nonShaOnlyVersions.length} non-SHA-only versions to evaluate for retention`);
194+
195+
// Apply retention filter to non-SHA-only versions
190196
let candidates = nonShaOnlyVersions;
191197
if (retentionDays > 0) {
192198
candidates = nonShaOnlyVersions.filter(v => new Date(v.updated_at) >= cutoff);
193199
core.info(`Retention filter: ${candidates.length} of ${nonShaOnlyVersions.length} versions within ${retentionDays} days`);
194200
}
195201
196-
// Step 2: Cap by IMAGES_TO_KEEP (high water mark, if > 0)
202+
// Apply count cap
197203
if (imagesToKeep > 0 && candidates.length > imagesToKeep) {
198204
candidates = candidates.slice(0, imagesToKeep);
199205
core.info(`Count cap: limiting to ${imagesToKeep} newest versions`);
200206
}
201207
202-
// Step 3: Safety - ensure at least 1 tagged image
208+
// Safety: ensure at least 1 tagged image
203209
const taggedCandidates = candidates.filter(hasNonShaTag);
204210
if (taggedCandidates.length === 0) {
205211
const newestTagged = nonShaOnlyVersions.find(hasNonShaTag);
@@ -211,156 +217,83 @@ jobs:
211217
212218
// Build set of IDs to keep
213219
const keepIds = new Set(candidates.map(v => v.id));
220+
core.info(`Will keep ${keepIds.size} non-SHA-only versions`);
214221
215-
let deletedCount = 0;
222+
// Process all versions in a single pass
223+
let shaOnlyDeleted = 0;
224+
let shaOnlyRetained = 0;
225+
let ageBasedDeleted = 0;
216226
let retainedCount = 0;
217227
let retainedTaggedCount = 0;
218228
219-
for (const version of sortedVersions) {
220-
const tags = version.metadata?.container?.tags ?? [];
221-
const isShaOnly = isShaOnlyVersion(version);
222-
223-
if (isShaOnly) {
224-
core.info(`Skipping SHA-only version ${version.id} with tags: ${tags.join(', ')}`);
225-
continue;
229+
core.info('');
230+
if (deleteShaOnly) {
231+
core.info('=== SHA-only versions (will be deleted) ===');
232+
for (const version of shaOnlyVersions) {
233+
const tags = version.metadata?.container?.tags ?? [];
234+
core.info(`${dryRun ? '[DRY RUN] Would delete' : 'Deleting'} SHA-only version ${version.id} with tags: ${tags.join(', ')}`);
235+
if (!dryRun) {
236+
await deleteVersion(scope, version.id);
237+
}
238+
shaOnlyDeleted += 1;
239+
}
240+
} else {
241+
core.info('=== SHA-only versions (skipped - DELETE_SHA_ONLY_TAGS is false) ===');
242+
for (const version of shaOnlyVersions) {
243+
const tags = version.metadata?.container?.tags ?? [];
244+
core.info(`Keeping SHA-only version ${version.id} with tags: ${tags.join(', ')}`);
245+
shaOnlyRetained += 1;
226246
}
247+
}
227248
249+
core.info('');
250+
core.info('=== Non-SHA-only versions ===');
251+
for (const version of nonShaOnlyVersions) {
252+
const tags = version.metadata?.container?.tags ?? [];
228253
if (keepIds.has(version.id)) {
229254
core.info(`Keeping version ${version.id} with tags: ${tags.join(', ') || '(none)'}`);
230255
retainedCount += 1;
231256
if (hasNonShaTag(version)) retainedTaggedCount += 1;
232257
} else {
233-
core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} updated ${version.updated_at} with tags: ${tags.join(', ') || '(none)'}`);
258+
core.info(`${dryRun ? '[DRY RUN] Would delete' : 'Deleting'} version ${version.id} with tags: ${tags.join(', ') || '(none)'}`);
234259
if (!dryRun) {
235260
await deleteVersion(scope, version.id);
236261
}
237-
deletedCount += 1;
262+
ageBasedDeleted += 1;
238263
}
239264
}
240265
241-
return JSON.stringify({ deletedCount, retainedCount, retainedTaggedCount });
242-
243-
- name: Delete container versions that only have SHA tags
244-
id: cleanup_sha_only
245-
if: ${{ env.DELETE_SHA_ONLY_TAGS == 'true' }}
246-
uses: actions/github-script@v8
247-
env:
248-
PACKAGE_NAME: ${{ env.PACKAGE_NAME }}
249-
DRY_RUN: ${{ env.DRY_RUN }}
250-
AGE_BASED_RESULT: ${{ steps.cleanup_by_age.outputs.result }}
251-
with:
252-
result-encoding: string
253-
script: |
254-
const owner = context.repo.owner;
255-
const packageType = 'container';
256-
const packageName = process.env.PACKAGE_NAME;
257-
const dryRun = process.env.DRY_RUN === 'true';
258-
const ageBasedResult = JSON.parse(process.env.AGE_BASED_RESULT || '{}');
259-
260-
const paginateVersions = async () => {
261-
try {
262-
return {
263-
scope: 'org',
264-
versions: await github.paginate(
265-
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
266-
{
267-
package_type: packageType,
268-
package_name: packageName,
269-
org: owner,
270-
per_page: 100,
271-
},
272-
),
273-
};
274-
} catch (error) {
275-
if (error.status !== 404) {
276-
throw error;
277-
}
278-
279-
return {
280-
scope: 'user',
281-
versions: await github.paginate(
282-
github.rest.packages.getAllPackageVersionsForPackageOwnedByUser,
283-
{
284-
package_type: packageType,
285-
package_name: packageName,
286-
username: owner,
287-
per_page: 100,
288-
},
289-
),
290-
};
291-
}
292-
};
293-
294-
const deleteVersion = async (scope, versionId) => {
295-
if (scope === 'org') {
296-
await github.rest.packages.deletePackageVersionForOrg({
297-
package_type: packageType,
298-
package_name: packageName,
299-
org: owner,
300-
package_version_id: versionId,
301-
});
302-
return;
303-
}
304-
305-
await github.rest.packages.deletePackageVersionForUser({
306-
package_type: packageType,
307-
package_name: packageName,
308-
username: owner,
309-
package_version_id: versionId,
310-
});
311-
};
312-
313-
const isShaTag = (value) => /^sha-[0-9a-f]{7,}$/i.test(value);
314-
const { scope, versions } = await paginateVersions();
315-
const sortedVersions = [...versions].sort(
316-
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
317-
);
318-
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-
326-
let shaOnlyDeletedCount = 0;
327-
328-
for (const version of shaOnlyVersions) {
329-
const tags = version.metadata?.container?.tags ?? [];
330-
core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} with SHA-only tags: ${tags.join(', ')}`);
331-
if (!dryRun) {
332-
await deleteVersion(scope, version.id);
333-
}
334-
shaOnlyDeletedCount += 1;
335-
}
336-
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');
266+
core.info('');
267+
core.info('=== Summary ===');
268+
core.info(`SHA-only deleted: ${shaOnlyDeleted}`);
269+
if (!deleteShaOnly) {
270+
core.info(`SHA-only retained: ${shaOnlyRetained}`);
341271
}
272+
core.info(`Age/count-based deleted: ${ageBasedDeleted}`);
273+
core.info(`Retained: ${retainedCount} (${retainedTaggedCount} tagged)`);
342274
343275
return JSON.stringify({
344-
totalBefore: sortedVersions.length,
345-
ageBasedDeleted: ageBasedResult.deletedCount || 0,
346-
ageBasedRetained: ageBasedResult.retainedCount || 0,
347-
ageBasedRetainedTagged: ageBasedResult.retainedTaggedCount || 0,
348-
shaOnlyDeleted: shaOnlyDeletedCount,
276+
totalBefore: versions.length,
277+
shaOnlyDeleted,
278+
shaOnlyRetained,
279+
ageBasedDeleted,
280+
retainedCount,
281+
retainedTaggedCount,
349282
});
350283
351284
- name: Generate cleanup report
352285
if: always()
353286
uses: actions/github-script@v8
354287
env:
355-
AGE_BASED_RESULT: ${{ steps.cleanup_by_age.outputs.result }}
356-
SHA_ONLY_RESULT: ${{ steps.cleanup_sha_only.outputs.result }}
288+
CLEANUP_RESULT: ${{ steps.cleanup.outputs.result }}
357289
DELETE_SHA_ONLY_TAGS: ${{ env.DELETE_SHA_ONLY_TAGS }}
358290
DRY_RUN: ${{ env.DRY_RUN }}
359291
IMAGES_TO_KEEP: ${{ env.IMAGES_TO_KEEP }}
360292
RETENTION_DAYS: ${{ env.RETENTION_DAYS }}
361293
with:
362294
script: |
363295
const dryRun = process.env.DRY_RUN === 'true';
296+
const deleteShaOnly = process.env.DELETE_SHA_ONLY_TAGS === 'true';
364297
const imagesToKeep = process.env.IMAGES_TO_KEEP || '0';
365298
const retentionDays = process.env.RETENTION_DAYS || '0';
366299
@@ -375,27 +308,22 @@ jobs:
375308
summary += '|---------|-------|\n';
376309
summary += `| Images to keep | ${imagesToKeep} |\n`;
377310
summary += `| Retention days | ${retentionDays} |\n`;
378-
summary += `| Delete SHA-only tags | ${process.env.DELETE_SHA_ONLY_TAGS} |\n\n`;
311+
summary += `| Delete SHA-only tags | ${deleteShaOnly} |\n\n`;
379312
380-
const shaOnlyResult = process.env.SHA_ONLY_RESULT;
381-
const ageBasedResult = process.env.AGE_BASED_RESULT;
382-
383-
if (shaOnlyResult && shaOnlyResult !== 'null') {
384-
const result = JSON.parse(shaOnlyResult);
313+
const result = process.env.CLEANUP_RESULT;
314+
if (result && result !== 'null') {
315+
const data = JSON.parse(result);
385316
summary += '### Summary\n';
386317
summary += '| Metric | Count |\n';
387318
summary += '|--------|-------|\n';
388-
summary += `| Total versions before | ${result.totalBefore || 0} |\n`;
389-
summary += `| Age-based deletions | ${result.ageBasedDeleted || 0} |\n`;
390-
summary += `| SHA-only deletions | ${result.shaOnlyDeleted || 0} |\n`;
391-
summary += `| **Retained tagged images** | **${result.ageBasedRetainedTagged || 0}** |\n`;
392-
} else if (ageBasedResult && ageBasedResult !== 'null') {
393-
const result = JSON.parse(ageBasedResult);
394-
summary += '### Summary\n';
395-
summary += '| Metric | Count |\n';
396-
summary += '|--------|-------|\n';
397-
summary += `| Age-based deletions | ${result.deletedCount || 0} |\n`;
398-
summary += `| **Retained tagged images** | **${result.retainedTaggedCount || 0}** |\n`;
319+
summary += `| Total versions before | ${data.totalBefore || 0} |\n`;
320+
summary += `| SHA-only deleted | ${data.shaOnlyDeleted || 0} |\n`;
321+
if (data.shaOnlyRetained > 0) {
322+
summary += `| SHA-only retained | ${data.shaOnlyRetained || 0} |\n`;
323+
}
324+
summary += `| Age/count-based deleted | ${data.ageBasedDeleted || 0} |\n`;
325+
summary += `| **Retained images** | **${data.retainedCount || 0}** |\n`;
326+
summary += `| **Retained tagged images** | **${data.retainedTaggedCount || 0}** |\n`;
399327
}
400328
401329
core.summary.addRaw(summary).write();

0 commit comments

Comments
 (0)