55 - cron : ' 0 3 * * *'
66 workflow_dispatch :
77 inputs :
8+ images_to_keep :
9+ description : Keep this many newest non-SHA-only image versions
10+ required : false
11+ default : ' 4'
812 retention_days :
9- description : Delete image versions older than this many days
13+ description : Delete non-SHA-only image versions older than this many days
1014 required : false
1115 default : ' '
16+ delete_sha_only_tags :
17+ description : Delete image versions that only have SHA tags
18+ required : false
19+ type : boolean
20+ default : true
1221 dry_run :
1322 description : Log deletions without removing image versions
1423 required : false
@@ -20,7 +29,9 @@ permissions:
2029
2130env :
2231 PACKAGE_NAME : opencode-cli
32+ IMAGES_TO_KEEP : ${{ inputs.images_to_keep || vars.GHCR_IMAGES_TO_KEEP || '4' }}
2333 RETENTION_DAYS : ${{ inputs.retention_days || vars.GHCR_RETENTION_DAYS || '45' }}
34+ DELETE_SHA_ONLY_TAGS : ${{ github.event_name == 'workflow_dispatch' && inputs.delete_sha_only_tags || vars.GHCR_DELETE_SHA_ONLY_TAGS || 'true' }}
2435 DRY_RUN : ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
2536
2637jobs :
3243 uses : actions/github-script@v8
3344 env :
3445 PACKAGE_NAME : ${{ env.PACKAGE_NAME }}
46+ IMAGES_TO_KEEP : ${{ env.IMAGES_TO_KEEP }}
3547 RETENTION_DAYS : ${{ env.RETENTION_DAYS }}
3648 DRY_RUN : ${{ env.DRY_RUN }}
3749 with :
@@ -40,11 +52,20 @@ jobs:
4052 const owner = context.repo.owner;
4153 const packageType = 'container';
4254 const packageName = process.env.PACKAGE_NAME;
55+ const imagesToKeep = Math.max(1, Number(process.env.IMAGES_TO_KEEP || '4'));
4356 const retentionDays = Number(process.env.RETENTION_DAYS);
4457 const dryRun = process.env.DRY_RUN === 'true';
4558 const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
4659 const deletedVersionIds = [];
4760
61+ if (!Number.isFinite(imagesToKeep)) {
62+ throw new Error(`IMAGES_TO_KEEP must be a number, received: ${process.env.IMAGES_TO_KEEP}`);
63+ }
64+
65+ if (!Number.isFinite(retentionDays)) {
66+ throw new Error(`RETENTION_DAYS must be a number, received: ${process.env.RETENTION_DAYS}`);
67+ }
68+
4869 const paginateVersions = async () => {
4970 try {
5071 return {
@@ -98,31 +119,46 @@ jobs:
98119 });
99120 };
100121
122+ const isShaTag = (value) => /^sha-[0-9a-f]{7,}$/i.test(value);
123+ const isShaOnlyVersion = (version) => {
124+ const tags = version.metadata?.container?.tags ?? [];
125+ return tags.length > 0 && tags.every(isShaTag);
126+ };
127+
101128 const { scope, versions } = await paginateVersions();
102129 const sortedVersions = [...versions].sort(
103130 (left, right) => new Date(right.updated_at) - new Date(left.updated_at),
104131 );
132+ const nonShaOnlyVersions = sortedVersions.filter((version) => !isShaOnlyVersion(version));
105133
106- if (sortedVersions .length <= 1 ) {
107- core.info(' Skipping age-based cleanup because only one package version exists.' );
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}.` );
108136 return JSON.stringify(deletedVersionIds);
109137 }
110138
111- let retainedCount = sortedVersions.length;
139+ let retainedNonShaOnlyCount = nonShaOnlyVersions.length;
140+ let retainedNewestNonShaOnly = 0;
112141
113- for (const [index, version] of sortedVersions.entries() ) {
142+ for (const version of sortedVersions) {
114143 const updatedAt = new Date(version.updated_at);
115144 const tags = version.metadata?.container?.tags ?? [];
116- const isNewest = index === 0 ;
145+ const isShaOnly = isShaOnlyVersion(version) ;
117146 const isOlderThanRetention = updatedAt < cutoff;
118147
148+ if (isShaOnly) {
149+ core.info(`Skipping age-based keep-count evaluation for SHA-only version ${version.id} with tags: ${tags.join(', ')}`);
150+ continue;
151+ }
152+
119153 if (!isOlderThanRetention) {
120154 core.info(`Keeping version ${version.id}; updated ${version.updated_at} is within ${retentionDays} days.`);
155+ retainedNewestNonShaOnly += 1;
121156 continue;
122157 }
123158
124- if (isNewest || retainedCount <= 1) {
125- core.info(`Keeping version ${version.id} to ensure at least one image remains available.`);
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;
126162 continue;
127163 }
128164
@@ -133,24 +169,31 @@ jobs:
133169 await deleteVersion(scope, version.id);
134170 }
135171 deletedVersionIds.push(version.id);
136- retainedCount -= 1;
172+ retainedNonShaOnlyCount -= 1;
137173 }
138174
139175 return JSON.stringify(deletedVersionIds);
140176
141177 - name : Delete container versions that only have SHA tags
178+ if : ${{ env.DELETE_SHA_ONLY_TAGS == 'true' }}
142179 uses : actions/github-script@v8
143180 env :
144181 PACKAGE_NAME : ${{ env.PACKAGE_NAME }}
182+ IMAGES_TO_KEEP : ${{ env.IMAGES_TO_KEEP }}
145183 DRY_RUN : ${{ env.DRY_RUN }}
146184 DELETED_VERSION_IDS : ${{ steps.cleanup_by_age.outputs.result }}
147185 with :
148186 script : |
149187 const owner = context.repo.owner;
150188 const packageType = 'container';
151189 const packageName = process.env.PACKAGE_NAME;
190+ const imagesToKeep = Math.max(1, Number(process.env.IMAGES_TO_KEEP || '4'));
152191 const dryRun = process.env.DRY_RUN === 'true';
153192
193+ if (!Number.isFinite(imagesToKeep)) {
194+ throw new Error(`IMAGES_TO_KEEP must be a number, received: ${process.env.IMAGES_TO_KEEP}`);
195+ }
196+
154197 const paginateVersions = async () => {
155198 try {
156199 return {
@@ -219,28 +262,19 @@ jobs:
219262 remainingVersions = sortedVersions.filter((version) => !deletedVersionIds.has(String(version.id)));
220263 }
221264
222- if (remainingVersions.length <= 1 ) {
223- core.info(' Skipping SHA-only cleanup because only one package version would remain after age-based cleanup.' );
265+ if (remainingVersions.length === 0 ) {
266+ core.info(` Skipping SHA-only cleanup because there are no remaining package versions to evaluate after the age-based cleanup step.` );
224267 return;
225268 }
226269
227- let retainedCount = remainingVersions.length;
228-
229270 for (const [index, version] of remainingVersions.entries()) {
230271 const tags = version.metadata?.container?.tags ?? [];
231272 const isShaOnly = tags.length > 0 && tags.every(isShaTag);
232- const isNewest = index === 0;
233-
234273 if (!isShaOnly) {
235274 core.info(`Keeping version ${version.id} with tags: ${tags.join(', ') || '(none)'}`);
236275 continue;
237276 }
238277
239- if (isNewest || retainedCount <= 1) {
240- core.info(`Keeping version ${version.id} to ensure at least one image remains available.`);
241- continue;
242- }
243-
244278 core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} with SHA-only tags: ${tags.join(', ')}`);
245279 if (!dryRun) {
246280 await deleteVersion(scope, version.id);
0 commit comments