Skip to content

Commit 9b5d0ed

Browse files
committed
ci: 🏗️ add configurable retention options for image cleanup
Add new workflow inputs for fine-grained control over image retention: - images_to_keep: configure number of newest non-SHA-only versions to keep - delete_sha_only_tags: toggle deletion of SHA-only tagged versions Improved cleanup logic to properly distinguish between SHA-only and regular tagged versions, allowing more flexible retention policies.
1 parent 8a9d07f commit 9b5d0ed

File tree

1 file changed

+54
-20
lines changed

1 file changed

+54
-20
lines changed

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

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ on:
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

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

2637
jobs:
@@ -32,6 +43,7 @@ jobs:
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

Comments
 (0)