-
Notifications
You must be signed in to change notification settings - Fork 25
269 lines (265 loc) · 12.5 KB
/
Copy pathmaintenance.yml
File metadata and controls
269 lines (265 loc) · 12.5 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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# Generated by velnor-workflow. Regenerate; do not hand-edit.
name: Maintenance
run-name: Maintenance · ${{ github.event_name }}
on:
pull_request:
types: [closed]
schedule:
- cron: '31 3 * * *'
workflow_dispatch:
inputs:
pull_request_number:
description: Optional closed PR number whose merge cache should be removed
required: false
type: string
permissions:
actions: write
contents: read
concurrency:
group: maintenance-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pull_request_number || github.run_id }}
cancel-in-progress: false
jobs:
prune-pr-cache:
name: Prune closed-PR cache
if: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.pull_request_number != '') }}
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Delete merge-ref cache namespace
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }}
run: |
set -euo pipefail
ref="refs/pull/$PR_NUMBER/merge"
encoded="$(printf '%s' "$ref" | jq -sRr @uri)"
cache_ids="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?ref=$encoded" --jq '.actions_caches[].id')"
if [[ -z "$cache_ids" ]]; then
echo "No merge-ref cache entries found for $ref"
exit 0
fi
failed=0
while IFS= read -r id; do
[[ -z "$id" ]] && continue
deleted=false
for delay in 1 2 4 8; do
if gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" >/dev/null 2>&1; then
deleted=true
break
fi
sleep "$delay"
done
if [[ "$deleted" != true ]]; then
failed=$((failed + 1))
echo "::error::failed to delete cache id $id after retries" >&2
fi
done <<< "$cache_ids"
if (( failed > 0 )); then
echo "::error::$failed closed-PR cache entries could not be deleted; rerun maintenance" >&2
exit 1
fi
cache-budget:
name: Cache retention
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
actions: write
pull-requests: read
steps:
- name: Skip while CI producers are running
id: retention-gate
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
for workflow in ci-main.yml nightly.yml; do
if [[ "$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status in_progress --limit 1 --json databaseId --jq 'length')" != "0" ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "$workflow is in_progress; skipping cache retention" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
done
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Set up Velnor workflow runtime
if: ${{ steps.retention-gate.outputs.skip != 'true' && runner.environment == 'github-hosted' }}
uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@b9c3156cdb88e63c11b9e595a3e694b02238c09a
with:
rev: b9c3156cdb88e63c11b9e595a3e694b02238c09a
- name: Set trusted workflow policy revision
if: steps.retention-gate.outputs.skip != 'true'
run: echo "VELNOR_WORKFLOW_POLICY_REVISION=b9c3156cdb88e63c11b9e595a3e694b02238c09a" >> "$GITHUB_ENV"
- name: Sweep closed-PR merge-ref caches
if: steps.retention-gate.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
failed=0
mapfile -t refs < <(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
--jq '.actions_caches[].ref' | grep -E '^refs/pull/[0-9]+/merge$' | sort -u)
if ((${#refs[@]} == 0)); then
echo "No merge-ref cache scopes found"
exit 0
fi
for ref in "${refs[@]}"; do
pr="${ref#refs/pull/}"
pr="${pr%/merge}"
state="$(gh pr view "$pr" --json state --jq .state 2>/dev/null || echo unknown)"
if [[ "$state" != "CLOSED" ]]; then
continue
fi
encoded="$(printf '%s' "$ref" | jq -sRr @uri)"
mapfile -t cache_ids < <(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?ref=$encoded" \
--jq '.actions_caches[].id')
if ((${#cache_ids[@]} == 0)); then
continue
fi
echo "Sweeping $ref ($pr): ${#cache_ids[@]} entries"
for id in "${cache_ids[@]}"; do
deleted=false
for delay in 1 2 4 8; do
if gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" >/dev/null 2>&1; then
deleted=true
break
fi
sleep "$delay"
done
if [[ "$deleted" != true ]]; then
failed=$((failed + 1))
echo "::error::failed to delete cache id $id for $ref after retries" >&2
fi
done
done
if (( failed > 0 )); then
echo "::error::$failed closed-PR merge-ref cache entries could not be deleted; rerun maintenance" >&2
exit 1
fi
- name: Collect Actions cache account
if: steps.retention-gate.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/cache-retention"
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
--jq '.actions_caches[] | {id, key, size_in_bytes, created_at, last_accessed_at}' \
> "$RUNNER_TEMP/cache-retention/entries.jsonl"
jq -s '.' "$RUNNER_TEMP/cache-retention/entries.jsonl" \
> "$RUNNER_TEMP/cache-retention/entries.json"
velnor-workflow cache-plan --mode=budget --entries "$RUNNER_TEMP/cache-retention/entries.json" \
> "$RUNNER_TEMP/cache-retention/budget.json"
# `gh api --paginate --jq` runs the filter once per page and
# concatenates the outputs: summing inside the filter prints one
# number per page, and the total silently understates the account.
# Slurp the page stream first, then take one total over every entry.
total="$(jq '.total_held_bytes' "$RUNNER_TEMP/cache-retention/budget.json")"
count="$(jq 'length' "$RUNNER_TEMP/cache-retention/entries.json")"
headroom="$(jq '.headroom_bytes' "$RUNNER_TEMP/cache-retention/budget.json")"
if (( headroom >= 0 && headroom < 536870912 )); then
echo "::warning::Actions cache headroom below 512 MiB ($headroom bytes remaining)" >&2
fi
jq -n --argjson total "$total" --argjson count "$count" --argjson headroom "$headroom" \
--slurpfile budget "$RUNNER_TEMP/cache-retention/budget.json" \
--arg captured_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{captured_at: $captured_at, cache_count: $count, total_bytes: $total, headroom_bytes: $headroom, classes: $budget[0].classes}' \
> "$RUNNER_TEMP/cache-retention/summary.json"
{
cat "$RUNNER_TEMP/cache-retention/summary.json"
echo "Per-class totals:"
jq -r '.classes[] | " \(.id): \(.entry_count) entries, \(.held_bytes) bytes (budget \(.budget_bytes))"' \
"$RUNNER_TEMP/cache-retention/budget.json"
} >> "$GITHUB_STEP_SUMMARY"
- name: Plan retention evictions
if: steps.retention-gate.outputs.skip != 'true'
run: |
set -euo pipefail
# The plan is the generator's own retention policy, executed - never
# a shell copy of it: per-class budgets, bounded generations per
# variant class, and the protected classes (toolchain seeds, Cargo
# source bundles, the Docker seed baseline) reserved before rolling
# compiler snapshots are touched. An access timestamp is not a
# lease. The newest generation of a variant stays out of reach
# inside the producer window; a superseded generation of the same
# variant is eligible, because the newer save is the producer
# signal that the older entry is no longer being written.
velnor-workflow cache-plan \
--now "$(date -u +%s)" \
--entries "$RUNNER_TEMP/cache-retention/entries.json" \
> "$RUNNER_TEMP/cache-retention/plan.json"
if jq -e 'length > 0' "$RUNNER_TEMP/cache-retention/plan.json" > /dev/null; then
{
echo "Retention plan (evict oldest first: bound, class budget, global budget):"
jq -r 'sort_by(.class, .reason) | group_by(.class, .reason)[] | " \(.[0].class) / \(.[0].reason): \(length) entries, \(map(.size_in_bytes) | add) bytes"' \
"$RUNNER_TEMP/cache-retention/plan.json"
} >> "$GITHUB_STEP_SUMMARY"
else
echo "Retention plan: nothing to evict" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Apply retention evictions
if: steps.retention-gate.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
evicted=0
freed=0
failed=0
# The plan is applied verbatim, in its own order: generations beyond
# their class bound first, then classes over budget, then the global
# sweep - which never touches a protected class.
while IFS=$'\t' read -r class reason id size key; do
if gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" >/dev/null; then
evicted=$((evicted + 1))
freed=$((freed + size))
echo "evicted id=$id class=$class reason=$reason size=$size key=$key"
else
failed=$((failed + 1))
echo "::warning::failed to evict cache id $id (class $class, key $key)" >&2
fi
done < <(jq -r '.[] | [.class, .reason, .id, .size_in_bytes, .key] | @tsv' \
"$RUNNER_TEMP/cache-retention/plan.json")
# Every eviction is recorded under its cache class and its reason,
# so a later cold run can be correlated with the eviction that
# caused it.
{
echo "Evictions by cache class:"
jq -r 'group_by(.class)[] | " \(.[0].class): \(length) evictions, \(map(.size_in_bytes) | add) bytes"' \
"$RUNNER_TEMP/cache-retention/plan.json"
} >> "$GITHUB_STEP_SUMMARY"
jq -n --argjson evicted "$evicted" --argjson freed "$freed" --argjson failed "$failed" \
'{evicted_caches: $evicted, failed_evictions: $failed, freed_bytes: $freed}' \
>> "$GITHUB_STEP_SUMMARY"
# A failed eviction is a loud failure, never a swallowed warning:
# silent DELETE failures leave the account over budget while the
# run reports success.
if (( failed > 0 )); then
echo "::error::$failed retention evictions failed; rerun maintenance" >&2
exit 1
fi
- name: Publish retention evidence
if: steps.retention-gate.outputs.skip != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cache-retention-${{ github.run_id }}
path: ${{ runner.temp }}/cache-retention
if-no-files-found: error
retention-days: 14
- name: Enforce cache budget
if: steps.retention-gate.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Re-query live state: the enforcement decision must reflect what the
# account actually holds after eviction, not the pre-eviction
# snapshot. The same page-streaming rule as collection applies: the
# sum is taken after slurping every page.
total="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
--jq '.actions_caches[].size_in_bytes' | jq -s 'add // 0')"
budget="$(velnor-workflow cache-plan --mode=budget)"
if (( total > budget )); then
echo "::error::Actions cache account exceeds budget: $total > $budget bytes" >&2
exit 1
fi