Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/cache-gc.yaml
Original file line number Diff line number Diff line change
@@ -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"'
12 changes: 11 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down