Skip to content

Commit 19a95a3

Browse files
committed
#369 Add housekeeping for timestamped images
We only need the timestamped Docker images for some time to enable testing of certain builds.
1 parent ce446c3 commit 19a95a3

File tree

1 file changed

+102
-0
lines changed

1 file changed

+102
-0
lines changed

.github/workflows/cleanup-ghcr.yml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: Clean up old GHCR images
2+
3+
on:
4+
schedule:
5+
- cron: '0 2 * * *' # every night at 02:00 UTC
6+
workflow_dispatch:
7+
inputs:
8+
retention_days:
9+
description: 'Delete images older than this many days'
10+
required: false
11+
default: '14'
12+
13+
permissions:
14+
contents: read
15+
packages: write
16+
17+
jobs:
18+
cleanup-ghcr:
19+
name: Remove timestamped images older than 14 days
20+
runs-on: ubuntu-latest
21+
if: ${{ github.event_name != 'schedule' || github.ref == 'refs/heads/main' }}
22+
steps:
23+
- name: Clean up old container versions
24+
uses: actions/github-script@v7
25+
with:
26+
github-token: ${{ secrets.GITHUB_TOKEN }}
27+
script: |
28+
const org = 'aim42';
29+
const package_type = 'container';
30+
const package_name = 'hsc';
31+
const per_page = 100;
32+
const tsTagRegex = /^\d{14}$/; // yyyyMMddHHmmss
33+
const daysInput = (context.payload && context.payload.inputs && context.payload.inputs.retention_days) || '14';
34+
const daysParsed = parseInt(daysInput, 10);
35+
const retentionDays = Number.isFinite(daysParsed) && daysParsed > 0 ? daysParsed : 14;
36+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); // retentionDays days ago
37+
core.info(`Using retention period: ${retentionDays} day(s).`);
38+
39+
function parseTimestampTag(tag) {
40+
// tag format: yyyyMMddHHmmss, interpreted as UTC
41+
const y = parseInt(tag.slice(0, 4), 10);
42+
const m = parseInt(tag.slice(4, 6), 10) - 1;
43+
const d = parseInt(tag.slice(6, 8), 10);
44+
const hh = parseInt(tag.slice(8, 10), 10);
45+
const mm = parseInt(tag.slice(10, 12), 10);
46+
const ss = parseInt(tag.slice(12, 14), 10);
47+
return new Date(Date.UTC(y, m, d, hh, mm, ss));
48+
}
49+
50+
let page = 1;
51+
let totalDeleted = 0;
52+
let scanned = 0;
53+
54+
while (true) {
55+
const { data: versions } = await github.request(
56+
'GET /orgs/{org}/packages/{package_type}/{package_name}/versions',
57+
{ org, package_type, package_name, per_page, page }
58+
);
59+
60+
if (!versions || versions.length === 0) break;
61+
62+
for (const v of versions) {
63+
scanned++;
64+
const tags = (v.metadata && v.metadata.container && v.metadata.container.tags) || [];
65+
if (!tags || tags.length === 0) continue;
66+
67+
// Skip protected tags to avoid removing latest or release tags that share the same version
68+
const hasProtected = tags.some(t => t === 'latest' || /^v\d+/.test(t));
69+
if (hasProtected) {
70+
core.info(`Skipping protected version ${v.id} with tags: ${tags.join(', ')}`);
71+
continue;
72+
}
73+
74+
const tsTags = tags.filter(t => tsTagRegex.test(t));
75+
if (tsTags.length === 0) continue;
76+
77+
// If any timestamp tag is older than cutoff, delete the entire version
78+
let shouldDelete = tsTags.some(t => parseTimestampTag(t) < cutoff);
79+
80+
if (shouldDelete) {
81+
try {
82+
await github.request(
83+
'DELETE /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}',
84+
{
85+
org,
86+
package_type,
87+
package_name,
88+
package_version_id: v.id,
89+
}
90+
);
91+
totalDeleted++;
92+
core.info(`Deleted version ${v.id} with tags: ${tags.join(', ')}`);
93+
} catch (err) {
94+
core.warning(`Failed to delete version ${v.id}: ${err.message}`);
95+
}
96+
}
97+
}
98+
99+
page++;
100+
}
101+
102+
core.info(`Scanned versions: ${scanned}. Deleted versions: ${totalDeleted}.`);

0 commit comments

Comments
 (0)