diff --git a/.github/workflows/cache-gc.yaml b/.github/workflows/cache-gc.yaml new file mode 100644 index 0000000..2f242cd --- /dev/null +++ b/.github/workflows/cache-gc.yaml @@ -0,0 +1,87 @@ +name: Cache GC + +# The Actions cache is a 10 GB quota for the whole repository, and every CI +# push exports a BuildKit `mode=max` layer set per architecture - gigabytes at a +# time, because the image carries a desktop base and, on ARM64, a from-source +# Rust build. GitHub only evicts an entry after seven days without a read, which +# is far slower than this repository fills the quota, and a full quota does not +# degrade gracefully: `cache-to` fails the whole build with "failed to reserve +# cache" after the image has already been built and exported. +# +# So retire cache entries on this repository's schedule rather than GitHub's. +# The window is measured from the last read, not from creation: a layer that is +# still being restored by builds is worth its space, and one that no build has +# asked for in days is exactly what the quota should not be holding. +# +# CI's `cache-to` also carries `ignore-error=true`, so even if this workflow +# stops running, a full quota costs cache hits rather than green builds. + +on: + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + inputs: + max_age_days: + description: Delete cache entries unread for more than this many days + required: false + default: '3' + +permissions: + actions: write + +concurrency: + group: cache-gc + cancel-in-progress: false + +jobs: + trim: + name: Trim the Actions cache + runs-on: ubuntu-latest + + steps: + - name: Delete cache entries past the retention window + shell: bash + env: + GH_TOKEN: ${{ github.token }} + MAX_AGE_DAYS: ${{ inputs.max_age_days || '3' }} + run: | + set -euo pipefail + + cutoff="$(date -u -d "${MAX_AGE_DAYS} days ago" +%s)" + echo "Retention window: ${MAX_AGE_DAYS} days (unread before $(date -u -d "@${cutoff}" --iso-8601=seconds))" + + # The listing is collected in full before anything is deleted: + # deleting while paginating shifts entries between pages and silently + # skips them. + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/caches?per_page=100" \ + | jq -r --argjson cutoff "$cutoff" ' + .actions_caches[] + | select( + (.last_accessed_at | sub("\\.[0-9]+"; "") | fromdateiso8601) + < $cutoff) + | "\(.id)\t\(.size_in_bytes)\t\(.ref)\t\(.key)"' \ + > /tmp/stale.tsv + + if [ ! -s /tmp/stale.tsv ]; then + echo "Nothing is past the retention window." + else + freed=0 + while IFS=$'\t' read -r id size ref key; do + echo "Deleting ${key} (${ref}, $((size / 1024 / 1024)) MB)" + # A cache can be evicted by GitHub, or by a concurrent run, + # between the listing and the delete. That is the outcome this + # workflow wanted, so it is not a failure. + if gh api --method DELETE \ + "repos/${GITHUB_REPOSITORY}/actions/caches/${id}" \ + --silent 2>/dev/null; then + freed=$((freed + size)) + else + echo " already gone" + fi + done < /tmp/stale.tsv + echo "Freed $((freed / 1024 / 1024)) MB across $(wc -l < /tmp/stale.tsv) entries." + fi + + gh api "repos/${GITHUB_REPOSITORY}/actions/cache/usage" \ + --jq '"Remaining: \(.active_caches_size_in_bytes / 1024 / 1024 | floor) MB in \(.active_caches_count) entries"' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cc89624..c3e0651 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -94,8 +94,18 @@ jobs: platforms: ${{ matrix.platform }} push: false tags: pdparchitect/buzznode:ci-${{ matrix.arch }} + # The Actions cache is a 10 GB quota for the whole repository, and a + # mode=max export of this image is gigabytes per architecture. Only a + # push writes it: a pull_request run's cache is scoped to + # refs/pull/N/merge, which no other ref can restore, so exporting + # there fills the quota with entries that are written once and never + # read. Pull requests still restore from the branch scope below. + # + # ignore-error keeps a full quota from failing a build that already + # succeeded - without it, buildx turns "failed to reserve cache" into + # a build failure after the image is finished and exported. cache-from: type=gha,scope=buzznode-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=buzznode-${{ matrix.arch }} + cache-to: ${{ github.event_name == 'push' && format('type=gha,mode=max,scope=buzznode-{0},ignore-error=true', matrix.arch) || '' }} - name: Smoke test shell: bash diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6e77159..884afa8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -87,8 +87,12 @@ jobs: labels: | org.opencontainers.image.title=Buzznode org.opencontainers.image.description=One persistent browser-accessible computer for one Buzz agent + # Restore only. A release builds from a tag ref, and a cache entry + # written there is scoped to that tag: no branch, and no later tag, + # can ever restore it. Exporting one would spend gigabytes of the + # repository's 10 GB quota on something nothing reads, and the CI run + # on main has already populated this scope from the same commit. cache-from: type=gha,scope=buzznode-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=buzznode-${{ matrix.arch }} provenance: mode=max sbom: true