|
| 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"' |
0 commit comments