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
3030env :
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