-
Notifications
You must be signed in to change notification settings - Fork 10
213 lines (192 loc) · 7.88 KB
/
Copy pathcontainer-cleanup-dev.yml
File metadata and controls
213 lines (192 loc) · 7.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
name: Container Cleanup (Dev Tags)
# Nightly cleanup of dev-tagged container images. Keeps:
# - Floating tags (latest, main, develop, python<X.Y>)
# - Production semver tags (e.g. 1.6.2, 1.6.2-python3.14, v1.6.2)
# - The newest N dev builds per Python version
# Deletes:
# - Older dev builds beyond the keep-window
# - Untagged orphan manifests
#
# Production-release-driven cleanup of pre-release dev builds is handled
# separately in container-cleanup-on-release.yml.
on:
schedule:
- cron: '0 4 * * *'
workflow_dispatch:
inputs:
dry-run:
description: 'List what would be deleted without deleting'
required: false
default: 'true'
type: boolean
keep-per-python:
description: 'Number of recent dev builds to keep per Python version'
required: false
default: '5'
type: string
permissions:
packages: write
concurrency:
# Distinct from container-cleanup-on-release so both can run concurrently without blocking.
group: container-cleanup-dev
cancel-in-progress: false
jobs:
cleanup-dev:
name: Prune Dev Container Tags
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Cleanup dev container versions
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OWNER: ${{ github.repository_owner }}
PACKAGE: ${{ github.event.repository.name }}
DRY_RUN: ${{ inputs.dry-run || 'false' }}
KEEP_PER_PYTHON: ${{ inputs.keep-per-python || '5' }}
run: |
set -euo pipefail
# A tag is "dev" when it carries a .dev<digits> segment - matches the
# version string semantic-release stamps onto every commit on main.
is_dev_tag() {
local tag="$1"
[[ "$tag" =~ \.dev[0-9]+ ]]
}
# Protected tags are never deleted: floating refs (latest, main, develop,
# pythonX.Y) and production semver (no .dev segment).
is_protected_tag() {
local tag="$1"
case "$tag" in
latest|main|develop) return 0 ;;
esac
if [[ "$tag" =~ ^python[0-9]+\.[0-9]+$ ]]; then return 0; fi
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([-.][a-zA-Z0-9]+)*$ ]] && ! is_dev_tag "$tag"; then
return 0
fi
return 1
}
# Detect owner type: /orgs/ only exists for org accounts; personal accounts
# use /users/. Using the wrong path returns 404 (gh api exits 0 on 404,
# so the script would operate on malformed/empty data and mis-delete).
OWNER_TYPE=$(gh api "/users/${OWNER}" --jq '.type' 2>/dev/null || echo "")
if [[ "$OWNER_TYPE" == "Organization" ]]; then
PACKAGES_API="/orgs/${OWNER}/packages/container/${PACKAGE}/versions"
elif [[ "$OWNER_TYPE" == "User" ]]; then
PACKAGES_API="/users/${OWNER}/packages/container/${PACKAGE}/versions"
else
echo "ERROR: Could not determine owner type for '${OWNER}' (got: '${OWNER_TYPE}')" >&2
exit 1
fi
echo "Owner type: ${OWNER_TYPE} — using API path: ${PACKAGES_API}"
# Paginate every container version on the package.
# --paginate follows Link headers automatically; --slurp merges pages into one array.
: > /tmp/versions.jsonl
if ! gh api --paginate "${PACKAGES_API}" --jq '.[]' > /tmp/versions.jsonl 2>/tmp/api_err.txt; then
echo "ERROR: gh api failed listing package versions:" >&2
cat /tmp/api_err.txt >&2
exit 1
fi
# Convert newline-delimited objects to one-object-per-line (--paginate + --jq '.[]' already does this)
total=$(wc -l < /tmp/versions.jsonl)
echo "Found ${total} container versions"
: > /tmp/delete.txt
: > /tmp/keep_protected.txt
: > /tmp/keep_recent_dev.txt
: > /tmp/untagged.txt
: > /tmp/dev_by_py.tsv
# Classify each manifest: untagged orphan, protected, dev (bucketed by
# python version), or unknown (kept defensively).
while IFS= read -r row; do
id=$(echo "$row" | jq -r '.id')
created_at=$(echo "$row" | jq -r '.created_at')
tags=$(echo "$row" | jq -r '.metadata.container.tags // [] | join(",")')
if [[ -z "$tags" ]]; then
echo "$id ($created_at) [untagged]" >> /tmp/untagged.txt
continue
fi
protected=false
for tag in ${tags//,/ }; do
if is_protected_tag "$tag"; then
protected=true
break
fi
done
if [[ "$protected" == "true" ]]; then
echo "$id ($created_at) [tags: ${tags}]" >> /tmp/keep_protected.txt
continue
fi
dev=false
py_bucket="no-python"
for tag in ${tags//,/ }; do
if is_dev_tag "$tag"; then
dev=true
if [[ "$tag" =~ -python([0-9]+\.[0-9]+) ]]; then
py_bucket="${BASH_REMATCH[1]}"
fi
break
fi
done
if [[ "$dev" == "true" ]]; then
printf '%s\t%s\t%s\t%s\n' "$py_bucket" "$created_at" "$id" "$tags" >> /tmp/dev_by_py.tsv
continue
fi
# Keep unrecognised tags - operator may have set a custom name.
echo "$id ($created_at) [tags: ${tags}] [unknown - keeping]" >> /tmp/keep_protected.txt
done < /tmp/versions.jsonl
# Within each python bucket, keep the most recent KEEP_PER_PYTHON
# builds (by created_at) and mark the rest for deletion.
if [[ -s /tmp/dev_by_py.tsv ]]; then
sort -k1,1 -k2,2r /tmp/dev_by_py.tsv > /tmp/dev_by_py_sorted.tsv
current_py=""
kept_in_bucket=0
while IFS=$'\t' read -r py_bucket created_at id tags; do
if [[ "$py_bucket" != "$current_py" ]]; then
current_py="$py_bucket"
kept_in_bucket=0
fi
if (( kept_in_bucket < KEEP_PER_PYTHON )); then
echo "$id ($created_at) [py: $py_bucket] [tags: ${tags}]" >> /tmp/keep_recent_dev.txt
kept_in_bucket=$((kept_in_bucket + 1))
else
echo "$id ($created_at) [py: $py_bucket] [tags: ${tags}]" >> /tmp/delete.txt
fi
done < /tmp/dev_by_py_sorted.tsv
fi
echo
echo "== Protected (always kept) =="
cat /tmp/keep_protected.txt || true
echo
echo "== Recent dev kept (per-python window of ${KEEP_PER_PYTHON}) =="
cat /tmp/keep_recent_dev.txt || true
echo
echo "== Untagged (will delete) =="
cat /tmp/untagged.txt || true
echo
echo "== Old dev (will delete) =="
cat /tmp/delete.txt || true
echo
if [[ "$DRY_RUN" == "true" ]]; then
echo "Dry run - no deletions performed."
exit 0
fi
deleted=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
id=$(echo "$line" | awk '{print $1}')
echo "Deleting version ${id}"
gh api -X DELETE "${PACKAGES_API}/${id}" || {
echo " failed to delete ${id} (continuing)"
}
deleted=$((deleted + 1))
done < /tmp/untagged.txt
while IFS= read -r line; do
[[ -z "$line" ]] && continue
id=$(echo "$line" | awk '{print $1}')
echo "Deleting version ${id}"
gh api -X DELETE "${PACKAGES_API}/${id}" || {
echo " failed to delete ${id} (continuing)"
}
deleted=$((deleted + 1))
done < /tmp/delete.txt
echo
echo "Deleted ${deleted} version(s)."