Skip to content

Commit 475ee56

Browse files
authored
ci: 🏗️ add scheduled workflow to cleanup old images (#7)
2 parents 2db3f54 + 35b507a commit 475ee56

File tree

1 file changed

+231
-0
lines changed

1 file changed

+231
-0
lines changed
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
name: Cleanup GHCR Images
2+
3+
on:
4+
schedule:
5+
- cron: '0 3 * * *'
6+
workflow_dispatch:
7+
inputs:
8+
retention_days:
9+
description: Delete image versions older than this many days
10+
required: false
11+
default: ''
12+
dry_run:
13+
description: Log deletions without removing image versions
14+
required: false
15+
type: boolean
16+
default: true
17+
18+
permissions:
19+
packages: write
20+
21+
env:
22+
PACKAGE_TYPE: container
23+
PACKAGE_NAME: opencode-cli
24+
RETENTION_DAYS: ${{ inputs.retention_days || vars.GHCR_RETENTION_DAYS || '45' }}
25+
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
26+
27+
jobs:
28+
cleanup-by-age:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- name: Delete old container versions but keep one
32+
uses: actions/github-script@v8
33+
with:
34+
script: |
35+
const owner = context.repo.owner;
36+
const packageType = process.env.PACKAGE_TYPE;
37+
const packageName = process.env.PACKAGE_NAME;
38+
const retentionDays = Number(process.env.RETENTION_DAYS);
39+
const dryRun = process.env.DRY_RUN === 'true';
40+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
41+
42+
const paginateVersions = async () => {
43+
try {
44+
return {
45+
scope: 'org',
46+
versions: await github.paginate(
47+
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
48+
{
49+
package_type: packageType,
50+
package_name: packageName,
51+
org: owner,
52+
per_page: 100,
53+
},
54+
),
55+
};
56+
} catch (error) {
57+
if (error.status !== 404) {
58+
throw error;
59+
}
60+
61+
return {
62+
scope: 'user',
63+
versions: await github.paginate(
64+
github.rest.packages.getAllPackageVersionsForPackageOwnedByUser,
65+
{
66+
package_type: packageType,
67+
package_name: packageName,
68+
username: owner,
69+
per_page: 100,
70+
},
71+
),
72+
};
73+
}
74+
};
75+
76+
const deleteVersion = async (scope, versionId) => {
77+
if (scope === 'org') {
78+
await github.rest.packages.deletePackageVersionForOrg({
79+
package_type: packageType,
80+
package_name: packageName,
81+
org: owner,
82+
package_version_id: versionId,
83+
});
84+
return;
85+
}
86+
87+
await github.rest.packages.deletePackageVersionForUser({
88+
package_type: packageType,
89+
package_name: packageName,
90+
username: owner,
91+
package_version_id: versionId,
92+
});
93+
};
94+
95+
const { scope, versions } = await paginateVersions();
96+
const sortedVersions = [...versions].sort(
97+
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
98+
);
99+
100+
if (sortedVersions.length <= 1) {
101+
core.info('Skipping age-based cleanup because only one package version exists.');
102+
return;
103+
}
104+
105+
let retainedCount = sortedVersions.length;
106+
107+
for (const [index, version] of sortedVersions.entries()) {
108+
const updatedAt = new Date(version.updated_at);
109+
const tags = version.metadata?.container?.tags ?? [];
110+
const isNewest = index === 0;
111+
const isOlderThanRetention = updatedAt < cutoff;
112+
113+
if (!isOlderThanRetention) {
114+
core.info(`Keeping version ${version.id}; updated ${version.updated_at} is within ${retentionDays} days.`);
115+
continue;
116+
}
117+
118+
if (isNewest || retainedCount <= 1) {
119+
core.info(`Keeping version ${version.id} to ensure at least one image remains available.`);
120+
continue;
121+
}
122+
123+
core.info(
124+
`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} updated ${version.updated_at} with tags: ${tags.join(', ') || '(none)'}`,
125+
);
126+
if (!dryRun) {
127+
await deleteVersion(scope, version.id);
128+
}
129+
retainedCount -= 1;
130+
}
131+
132+
cleanup-sha-only:
133+
needs: cleanup-by-age
134+
runs-on: ubuntu-latest
135+
steps:
136+
- name: Delete container versions that only have SHA tags
137+
uses: actions/github-script@v8
138+
with:
139+
script: |
140+
const owner = context.repo.owner;
141+
const packageType = process.env.PACKAGE_TYPE;
142+
const packageName = process.env.PACKAGE_NAME;
143+
const dryRun = process.env.DRY_RUN === 'true';
144+
145+
const paginateVersions = async () => {
146+
try {
147+
return {
148+
scope: 'org',
149+
versions: await github.paginate(
150+
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
151+
{
152+
package_type: packageType,
153+
package_name: packageName,
154+
org: owner,
155+
per_page: 100,
156+
},
157+
),
158+
};
159+
} catch (error) {
160+
if (error.status !== 404) {
161+
throw error;
162+
}
163+
164+
return {
165+
scope: 'user',
166+
versions: await github.paginate(
167+
github.rest.packages.getAllPackageVersionsForPackageOwnedByUser,
168+
{
169+
package_type: packageType,
170+
package_name: packageName,
171+
username: owner,
172+
per_page: 100,
173+
},
174+
),
175+
};
176+
}
177+
};
178+
179+
const deleteVersion = async (scope, versionId) => {
180+
if (scope === 'org') {
181+
await github.rest.packages.deletePackageVersionForOrg({
182+
package_type: packageType,
183+
package_name: packageName,
184+
org: owner,
185+
package_version_id: versionId,
186+
});
187+
return;
188+
}
189+
190+
await github.rest.packages.deletePackageVersionForUser({
191+
package_type: packageType,
192+
package_name: packageName,
193+
username: owner,
194+
package_version_id: versionId,
195+
});
196+
};
197+
198+
const isShaTag = (value) => /^sha-[0-9a-f]{7,}$/i.test(value);
199+
const { scope, versions } = await paginateVersions();
200+
const sortedVersions = [...versions].sort(
201+
(left, right) => new Date(right.updated_at) - new Date(left.updated_at),
202+
);
203+
204+
if (sortedVersions.length <= 1) {
205+
core.info('Skipping SHA-only cleanup because only one package version exists.');
206+
return;
207+
}
208+
209+
let retainedCount = sortedVersions.length;
210+
211+
for (const [index, version] of sortedVersions.entries()) {
212+
const tags = version.metadata?.container?.tags ?? [];
213+
const isShaOnly = tags.length > 0 && tags.every(isShaTag);
214+
const isNewest = index === 0;
215+
216+
if (!isShaOnly) {
217+
core.info(`Keeping version ${version.id} with tags: ${tags.join(', ') || '(none)'}`);
218+
continue;
219+
}
220+
221+
if (isNewest || retainedCount <= 1) {
222+
core.info(`Keeping version ${version.id} to ensure at least one image remains available.`);
223+
continue;
224+
}
225+
226+
core.info(`${dryRun ? 'Would delete' : 'Deleting'} version ${version.id} with SHA-only tags: ${tags.join(', ')}`);
227+
if (!dryRun) {
228+
await deleteVersion(scope, version.id);
229+
}
230+
retainedCount -= 1;
231+
}

0 commit comments

Comments
 (0)