@@ -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
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