Skip to content

Commit 9e55a01

Browse files
authored
feat(actions): add workflow to clean up untagged Docker images (#2657)
* feat(actions): add workflow to clean up untagged Docker images Signed-off-by: elysia <a.elysia@proton.me> * fix(actions): suggestions from codex Signed-off-by: elysia <a.elysia@proton.me> * chore: remove cron task --------- Signed-off-by: elysia <a.elysia@proton.me>
1 parent f869afc commit 9e55a01

1 file changed

Lines changed: 214 additions & 0 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
name: Container Registry Cleanup
2+
3+
permissions:
4+
contents: read
5+
packages: write
6+
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
package:
11+
description: 'The name of the package to clean up'
12+
required: true
13+
default: 'openlist-git'
14+
owner:
15+
description: 'The owner of the package (user or organization)'
16+
required: false
17+
default: 'openlistteam'
18+
older_than:
19+
description: 'Delete untagged images older than this many days'
20+
required: false
21+
default: '-1'
22+
untagged_timestamp_tolerance:
23+
description: 'Tolerance in milliseconds for untagged images close to tagged ones'
24+
required: false
25+
# We do this because multi-arch docker containers will be pushed as separate untagged images
26+
# to the container registry and GitHub cannot recognize them as part of a tagged image.
27+
# Setting this option large enough prevents accidental deletion of one of these images.
28+
# Here we set it to 10 seconds
29+
default: '10000'
30+
token:
31+
description: 'GitHub token with permissions to delete packages (leave empty to use default GITHUB_TOKEN)'
32+
required: false
33+
default: ''
34+
35+
jobs:
36+
cleanup:
37+
runs-on: ubuntu-slim
38+
39+
steps:
40+
- name: Check inputs and tools
41+
id: setup
42+
shell: bash
43+
run: |
44+
PACKAGE="${{ github.event.inputs.package || 'openlist-git' }}"
45+
OLDER="${{ github.event.inputs.older_than || '-1' }}"
46+
TOLERANCE_VAL="${{ github.event.inputs.untagged_timestamp_tolerance || '10000' }}"
47+
TOKEN_INPUT="${{ github.event.inputs.token || '' }}"
48+
OWNER="${{ github.event.inputs.owner || 'openlistteam' }}"
49+
50+
echo "package_name=$PACKAGE" >> "$GITHUB_OUTPUT"
51+
echo "older_than=$OLDER" >> "$GITHUB_OUTPUT"
52+
echo "tolerance=$TOLERANCE_VAL" >> "$GITHUB_OUTPUT"
53+
echo "owner=$OWNER" >> "$GITHUB_OUTPUT"
54+
55+
if [[ -z "$TOKEN_INPUT" ]]; then
56+
echo "::notice:: No token provided, using GITHUB_TOKEN instead."
57+
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
58+
else
59+
echo "::add-mask::$TOKEN_INPUT"
60+
echo "token=$TOKEN_INPUT" >> "$GITHUB_OUTPUT"
61+
fi
62+
63+
if [[ -z "$PACKAGE" || -z "$OWNER" || ( -z "$TOKEN_INPUT" && -z "${{ secrets.GITHUB_TOKEN }}" ) ]]; then
64+
echo "::error:: Missing required inputs (package, token, or owner context)."
65+
exit 1
66+
fi
67+
68+
command -v curl >/dev/null 2>&1 || { echo "::error::curl is required but not installed."; exit 1; }
69+
command -v jq >/dev/null 2>&1 || { echo "::error::jq is required but not installed."; exit 1; }
70+
71+
- name: Determine owner type (user or organization)
72+
id: owner_type
73+
shell: bash
74+
run: |
75+
OWNER="${{ steps.setup.outputs.owner }}"
76+
TOKEN="${{ steps.setup.outputs.token }}"
77+
78+
TYPE=$(curl -s -H "Authorization: token $TOKEN" \
79+
-H "Accept: application/vnd.github.v3+json" \
80+
"https://api.github.com/users/${OWNER}" | jq -r '.type')
81+
82+
if [[ "$TYPE" == "Organization" ]]; then
83+
OWNER_PATH="orgs"
84+
else
85+
OWNER_PATH="users"
86+
fi
87+
88+
echo "owner_path=$OWNER_PATH" >> "$GITHUB_OUTPUT"
89+
echo "Detected owner type: $TYPE (using /${OWNER_PATH}/)"
90+
91+
- name: Access all versions from GitHub
92+
id: fetch
93+
shell: bash
94+
run: |
95+
PACKAGE_NAME="${{ steps.setup.outputs.package_name }}"
96+
OWNER="${{ steps.setup.outputs.owner }}"
97+
TOKEN="${{ steps.setup.outputs.token }}"
98+
OWNER_PATH="${{ steps.owner_type.outputs.owner_path }}"
99+
100+
BASE_URL="https://api.github.com/${OWNER_PATH}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
101+
URL="${BASE_URL}?per_page=100"
102+
TEMP_FILE="/tmp/all_versions.ndjson"
103+
104+
> "$TEMP_FILE"
105+
106+
while [[ -n "$URL" ]]; do
107+
echo "Fetching: $URL"
108+
109+
curl -s -H "Authorization: token $TOKEN" \
110+
-H "Accept: application/vnd.github.v3+json" \
111+
-D headers.txt \
112+
"$URL" > page.json
113+
114+
jq -c '.[]' page.json >> "$TEMP_FILE"
115+
116+
URL=$(grep -i '^link:' headers.txt | grep -o '<[^>]*>;\s*rel="next"' | sed 's/<\(.*\)>.*/\1/' || true)
117+
done
118+
rm -f headers.txt page.json
119+
120+
echo "temp_file=$TEMP_FILE" >> "$GITHUB_OUTPUT"
121+
122+
- name: Filter untagged versions
123+
id: filter
124+
shell: bash
125+
run: |
126+
OLDER_THAN="${{ steps.setup.outputs.older_than }}"
127+
TOLERANCE="${{ steps.setup.outputs.tolerance }}"
128+
TEMP_FILE="${{ steps.fetch.outputs.temp_file }}"
129+
130+
TO_DELETE=$(jq -c -s \
131+
--arg older_than "$OLDER_THAN" \
132+
--arg tolerance "$TOLERANCE" \
133+
'
134+
def abs: if . < 0 then -. else . end;
135+
def to_ms: sub("\\.[0-9]+Z$"; "Z") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime * 1000;
136+
137+
. as $all |
138+
($older_than | tonumber) as $older_than_val |
139+
($tolerance | tonumber) as $tolerance_val |
140+
now as $now |
141+
(if $older_than_val > 0 then ($now - ($older_than_val * 86400)) * 1000 else 0 end) as $cutoff_ms |
142+
143+
[ $all[] | select((.metadata.container.tags // []) | length > 0) | .created_at | to_ms ] as $tagged_times |
144+
145+
$all[] |
146+
select((.metadata.container.tags // []) | length == 0) |
147+
. as $item |
148+
(.created_at | to_ms) as $created_ms |
149+
150+
(if $older_than_val > 0 then $created_ms < $cutoff_ms else true end) as $pass_age |
151+
152+
(if $tolerance_val <= 0 then true
153+
else [ $tagged_times[] | select(($created_ms - .) | abs < $tolerance_val) ] | length == 0
154+
end) as $pass_tolerance |
155+
156+
select($pass_age and $pass_tolerance) |
157+
{id, name}
158+
' "$TEMP_FILE")
159+
160+
rm -f "$TEMP_FILE"
161+
162+
if [[ -z "$TO_DELETE" ]]; then
163+
DELETE_COUNT=0
164+
else
165+
DELETE_COUNT=$(echo "$TO_DELETE" | wc -l | tr -d ' ')
166+
fi
167+
168+
echo "Found $DELETE_COUNT untagged images no longer necessary"
169+
170+
{
171+
echo 'to_delete<<EOF'
172+
echo "$TO_DELETE"
173+
echo 'EOF'
174+
} >> "$GITHUB_OUTPUT"
175+
176+
echo "delete_count=$DELETE_COUNT" >> "$GITHUB_OUTPUT"
177+
178+
- name: Delete untagged versions
179+
id: delete
180+
shell: bash
181+
run: |
182+
PACKAGE_NAME="${{ steps.setup.outputs.package_name }}"
183+
OWNER="${{ steps.setup.outputs.owner }}"
184+
TOKEN="${{ steps.setup.outputs.token }}"
185+
OWNER_PATH="${{ steps.owner_type.outputs.owner_path }}"
186+
DELETE_COUNT="${{ steps.filter.outputs.delete_count }}"
187+
TO_DELETE='${{ steps.filter.outputs.to_delete }}'
188+
189+
if [[ "$DELETE_COUNT" -gt 0 && -n "$TO_DELETE" ]]; then
190+
while IFS= read -r item; do
191+
[[ -z "$item" ]] && continue
192+
193+
# $item 是完整的单行 JSON,如 {"id":123,"name":"sha256:..."}
194+
ID=$(echo "$item" | jq -r '.id')
195+
NAME=$(echo "$item" | jq -r '.name')
196+
197+
DELETE_URL="https://api.github.com/${OWNER_PATH}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/${ID}"
198+
199+
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
200+
-H "Authorization: token $TOKEN" \
201+
-H "Accept: application/vnd.github.v3+json" \
202+
"$DELETE_URL")
203+
204+
if [[ "$HTTP_CODE" == "204" ]]; then
205+
echo "Deleted untagged container image '${NAME}'"
206+
else
207+
echo "::warning:: Failed to delete ${NAME} (ID: ${ID}). HTTP Status: ${HTTP_CODE}"
208+
fi
209+
done <<< "$TO_DELETE"
210+
else
211+
echo "No images to delete."
212+
fi
213+
214+
echo "Cleanup completed."

0 commit comments

Comments
 (0)