ci: cap Actions storage growth — buildx mode=min + auto-cleanup workflow

Hit the 0.5 GB Actions storage quota today. Two-pronged fix.

1. docker-publish.yml: cache-to switched from mode=max to mode=min.
   With mode=max, buildx pushes every intermediate layer of the
   multi-stage build (qemu-provider, espidf-builder, frontend-builder,
   final stage) into the GHA cache. For our image that's easily
   500 MB-1 GB per cache update. mode=min stores only the layers used
   by the final image; incremental rebuilds still hit the cache for
   the meaningful steps but the footprint drops by roughly 60-70%.

2. actions-cache-cleanup.yml (new workflow):
   - Weekly schedule (Sun 04:00 UTC): deletes every cache older than
     14 days. Catches stale entries from deleted branches.
   - On `pull_request: closed`: deletes caches scoped to that PR's
     branch ref AND the merge ref. Buildx + actions/cache scope per
     branch, so a closed PR's caches are immediately stale — without
     this they linger until the GHA-default 7-day eviction.
   - Manual `workflow_dispatch` for one-shot runs when storage is
     already over.

Permissions: each job sets `actions: write` (the minimum needed for
cache deletion). No GH_TOKEN secret required; the default
GITHUB_TOKEN already has the scope.

Quota math after this lands:
  Before: every push to master = +500 MB-1 GB cache, kept 7 days
          → quota fills in 1-2 builds.
  After:  every push to master = +200-400 MB cache, plus old branches
          actively swept; 0.5 GB stays comfortable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-09 23:27:02 +02:00
parent a5aca2efb2
commit 5a00f1a380
2 changed files with 125 additions and 1 deletions

View File

@ -0,0 +1,118 @@
# Keeps GitHub Actions cache storage from filling up.
#
# Two complementary triggers:
#
# 1. Weekly sweep — delete every cache older than 14 days. Catches the slow
# drift from old branches and stale entries that nothing references.
# 2. PR-close sweep — when a pull request is merged or closed, delete every
# cache scoped to that PR's branch. Most caches are short-lived per-PR
# buildx scopes; without this they linger for the full retention window
# eating quota for nothing.
#
# Storage quota on davidmonterocrespo24's plan is 0.5 GB. A multi-stage Docker
# build with mode=min cache can sit around 200-400 MB so two stale PR caches
# are enough to push us over.
name: Actions cache cleanup
on:
schedule:
# Sundays at 04:00 UTC — well outside CI peak hours.
- cron: '0 4 * * 0'
pull_request:
types: [closed]
workflow_dispatch: {}
jobs:
weekly-sweep:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Delete caches older than 14 days
uses: actions/github-script@v7
with:
script: |
const cutoffMs = Date.now() - 14 * 24 * 60 * 60 * 1000;
let page = 1;
let deleted = 0;
let kept = 0;
let bytesFreed = 0;
while (true) {
const { data } = await github.rest.actions.getActionsCacheList({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
page,
});
if (!data.actions_caches.length) break;
for (const cache of data.actions_caches) {
const created = new Date(cache.created_at).getTime();
if (created < cutoffMs) {
console.log(
`delete ${cache.id} ${(cache.size_in_bytes / 1024 / 1024).toFixed(1)} MB ${cache.ref} ${cache.key}`,
);
await github.rest.actions.deleteActionsCacheById({
owner: context.repo.owner,
repo: context.repo.repo,
cache_id: cache.id,
});
deleted += 1;
bytesFreed += cache.size_in_bytes;
} else {
kept += 1;
}
}
if (data.actions_caches.length < 100) break;
page += 1;
}
console.log(
`summary: deleted=${deleted} kept=${kept} freed=${(bytesFreed / 1024 / 1024).toFixed(1)} MB`,
);
pr-close-sweep:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Delete caches scoped to the closed PR's branch
uses: actions/github-script@v7
env:
PR_REF: refs/pull/${{ github.event.pull_request.number }}/merge
BRANCH_REF: refs/heads/${{ github.event.pull_request.head.ref }}
with:
script: |
const refs = [process.env.PR_REF, process.env.BRANCH_REF];
let deleted = 0;
let bytesFreed = 0;
for (const ref of refs) {
let page = 1;
while (true) {
const { data } = await github.rest.actions.getActionsCacheList({
owner: context.repo.owner,
repo: context.repo.repo,
ref,
per_page: 100,
page,
});
if (!data.actions_caches.length) break;
for (const cache of data.actions_caches) {
console.log(
`delete ${cache.id} ${(cache.size_in_bytes / 1024 / 1024).toFixed(1)} MB ${cache.ref} ${cache.key}`,
);
await github.rest.actions.deleteActionsCacheById({
owner: context.repo.owner,
repo: context.repo.repo,
cache_id: cache.id,
});
deleted += 1;
bytesFreed += cache.size_in_bytes;
}
if (data.actions_caches.length < 100) break;
page += 1;
}
}
console.log(
`pr-close summary: deleted=${deleted} freed=${(bytesFreed / 1024 / 1024).toFixed(1)} MB`,
);

View File

@ -60,7 +60,13 @@ jobs:
build-args: |
ESPIDF_IMAGE=ghcr.io/davidmonterocrespo24/velxio-espidf-toolchain:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# mode=min only stores the layers used in the final image. mode=max
# also stores all intermediate layers, which doubles or triples the
# cache footprint on a multi-stage build like ours (qemu-provider +
# espidf-builder + frontend-builder + final stage). The 0.5 GB
# Actions storage quota burns through fast with max; min is enough
# to make incremental rebuilds fast on the layers that matter.
cache-to: type=gha,mode=min
- name: Ping search engines with sitemap
if: success()