Skip to content

Commit f8c2866

Browse files
committed
- feat: implement cache garbage collection and optimize cache usage in workflows
1 parent ca76bf5 commit f8c2866

3 files changed

Lines changed: 103 additions & 2 deletions

File tree

.github/workflows/cache-gc.yaml

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
name: Cache GC
2+
3+
# The Actions cache is a 10 GB quota for the whole repository, and every CI
4+
# push exports a BuildKit `mode=max` layer set per architecture - gigabytes at a
5+
# time, because the image carries a desktop base and, on ARM64, a from-source
6+
# Rust build. GitHub only evicts an entry after seven days without a read, which
7+
# is far slower than this repository fills the quota, and a full quota does not
8+
# degrade gracefully: `cache-to` fails the whole build with "failed to reserve
9+
# cache" after the image has already been built and exported.
10+
#
11+
# So retire cache entries on this repository's schedule rather than GitHub's.
12+
# The window is measured from the last read, not from creation: a layer that is
13+
# still being restored by builds is worth its space, and one that no build has
14+
# asked for in days is exactly what the quota should not be holding.
15+
#
16+
# CI's `cache-to` also carries `ignore-error=true`, so even if this workflow
17+
# stops running, a full quota costs cache hits rather than green builds.
18+
19+
on:
20+
schedule:
21+
- cron: '17 4 * * *'
22+
workflow_dispatch:
23+
inputs:
24+
max_age_days:
25+
description: Delete cache entries unread for more than this many days
26+
required: false
27+
default: '3'
28+
29+
permissions:
30+
actions: write
31+
32+
concurrency:
33+
group: cache-gc
34+
cancel-in-progress: false
35+
36+
jobs:
37+
trim:
38+
name: Trim the Actions cache
39+
runs-on: ubuntu-latest
40+
41+
steps:
42+
- name: Delete cache entries past the retention window
43+
shell: bash
44+
env:
45+
GH_TOKEN: ${{ github.token }}
46+
MAX_AGE_DAYS: ${{ inputs.max_age_days || '3' }}
47+
run: |
48+
set -euo pipefail
49+
50+
cutoff="$(date -u -d "${MAX_AGE_DAYS} days ago" +%s)"
51+
echo "Retention window: ${MAX_AGE_DAYS} days (unread before $(date -u -d "@${cutoff}" --iso-8601=seconds))"
52+
53+
# The listing is collected in full before anything is deleted:
54+
# deleting while paginating shifts entries between pages and silently
55+
# skips them.
56+
gh api --paginate \
57+
"repos/${GITHUB_REPOSITORY}/actions/caches?per_page=100" \
58+
| jq -r --argjson cutoff "$cutoff" '
59+
.actions_caches[]
60+
| select(
61+
(.last_accessed_at | sub("\\.[0-9]+"; "") | fromdateiso8601)
62+
< $cutoff)
63+
| "\(.id)\t\(.size_in_bytes)\t\(.ref)\t\(.key)"' \
64+
> /tmp/stale.tsv
65+
66+
if [ ! -s /tmp/stale.tsv ]; then
67+
echo "Nothing is past the retention window."
68+
else
69+
freed=0
70+
while IFS=$'\t' read -r id size ref key; do
71+
echo "Deleting ${key} (${ref}, $((size / 1024 / 1024)) MB)"
72+
# A cache can be evicted by GitHub, or by a concurrent run,
73+
# between the listing and the delete. That is the outcome this
74+
# workflow wanted, so it is not a failure.
75+
if gh api --method DELETE \
76+
"repos/${GITHUB_REPOSITORY}/actions/caches/${id}" \
77+
--silent 2>/dev/null; then
78+
freed=$((freed + size))
79+
else
80+
echo " already gone"
81+
fi
82+
done < /tmp/stale.tsv
83+
echo "Freed $((freed / 1024 / 1024)) MB across $(wc -l < /tmp/stale.tsv) entries."
84+
fi
85+
86+
gh api "repos/${GITHUB_REPOSITORY}/actions/cache/usage" \
87+
--jq '"Remaining: \(.active_caches_size_in_bytes / 1024 / 1024 | floor) MB in \(.active_caches_count) entries"'

.github/workflows/ci.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,18 @@ jobs:
9494
platforms: ${{ matrix.platform }}
9595
push: false
9696
tags: pdparchitect/buzznode:ci-${{ matrix.arch }}
97+
# The Actions cache is a 10 GB quota for the whole repository, and a
98+
# mode=max export of this image is gigabytes per architecture. Only a
99+
# push writes it: a pull_request run's cache is scoped to
100+
# refs/pull/N/merge, which no other ref can restore, so exporting
101+
# there fills the quota with entries that are written once and never
102+
# read. Pull requests still restore from the branch scope below.
103+
#
104+
# ignore-error keeps a full quota from failing a build that already
105+
# succeeded - without it, buildx turns "failed to reserve cache" into
106+
# a build failure after the image is finished and exported.
97107
cache-from: type=gha,scope=buzznode-${{ matrix.arch }}
98-
cache-to: type=gha,mode=max,scope=buzznode-${{ matrix.arch }}
108+
cache-to: ${{ github.event_name == 'push' && format('type=gha,mode=max,scope=buzznode-{0},ignore-error=true', matrix.arch) || '' }}
99109

100110
- name: Smoke test
101111
shell: bash

.github/workflows/release.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,12 @@ jobs:
8787
labels: |
8888
org.opencontainers.image.title=Buzznode
8989
org.opencontainers.image.description=One persistent browser-accessible computer for one Buzz agent
90+
# Restore only. A release builds from a tag ref, and a cache entry
91+
# written there is scoped to that tag: no branch, and no later tag,
92+
# can ever restore it. Exporting one would spend gigabytes of the
93+
# repository's 10 GB quota on something nothing reads, and the CI run
94+
# on main has already populated this scope from the same commit.
9095
cache-from: type=gha,scope=buzznode-${{ matrix.arch }}
91-
cache-to: type=gha,mode=max,scope=buzznode-${{ matrix.arch }}
9296
provenance: mode=max
9397
sbom: true
9498

0 commit comments

Comments
 (0)