From 4d7d74a80dfc5f93d613fd5854d8f3910371b958 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 4 Oct 2026 17:58:12 +0700 Subject: [PATCH] ci(cache): prune Actions cache entries nothing can restore The repository's Actions cache sat at its 10 GB limit with Zig caches that can never be read again, and evicted every chipdb (#843). Two kinds of entry are dead weight: - entries saved on a closed pull request's merge ref (or a deleted branch): only that ref could restore them; - older entries of a key that ends in the run id. mlugg/setup-zig saves one per run and restores by prefix, which takes the newest, so the older ones in the same series on the same ref are never read. actions-cache-prune runs hourly, when a pull request closes, and on demand (dry run by default). The rules are a plain function in .github/scripts/cache-prune.js; the job runs its 14 node tests before it deletes anything, and actions-cache-prune-test runs them on any pull request that changes them. 12 of 12 mutants of the rules are killed by the tests. Dry run against the live repository on 2026-10-04: 161 entries, 10.73 GB; it would drop 139 (9.00 GB) and keep 22 (1.73 GB). 100 of the dropped belong to the closed pull requests #828, #839, #840 and #841; 39 are superseded setup-zig entries on main. Part of #851 and #844. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/cache-prune.js | 155 +++++++++++++++ .github/scripts/cache-prune.test.js | 176 ++++++++++++++++++ .../workflows/actions-cache-prune-test.yml | 25 +++ .github/workflows/actions-cache-prune.yml | 60 ++++++ 4 files changed, 416 insertions(+) create mode 100644 .github/scripts/cache-prune.js create mode 100644 .github/scripts/cache-prune.test.js create mode 100644 .github/workflows/actions-cache-prune-test.yml create mode 100644 .github/workflows/actions-cache-prune.yml diff --git a/.github/scripts/cache-prune.js b/.github/scripts/cache-prune.js new file mode 100644 index 000000000..3751d4389 --- /dev/null +++ b/.github/scripts/cache-prune.js @@ -0,0 +1,155 @@ +// Prune the Actions cache so the 10 GB budget holds entries that can still be +// restored. gHashTag/trinity-fpga#844. +// +// Two kinds of entry are dead weight: +// +// - Entries saved on a pull request's merge ref. Only that pull request can +// restore them, so once it is closed nothing reads them again. The same holds +// for a branch that has been deleted. +// - Older entries of a key that ends in the run id. mlugg/setup-zig saves +// `setup-zig-cache-v2--...---` on every run and restores +// by prefix, which takes the newest match, so every older entry of the same +// series on the same ref is never read. +// +// Everything else is kept: fixed keys (setup-zig-tarball-*, the chipdb keyed on +// part + image) and the newest entry of every series on every live ref. + +'use strict'; + +// A run id (8 or more digits) at the end of a key, with an optional attempt. +const RUN_SUFFIX = /-\d{8,}(?:-\d+)?$/; + +// The key with its run id removed, or null when the key is fixed. +function seriesOf(key) { + const perRun = RUN_SUFFIX.test(key); + if (!perRun) return null; + return key.replace(RUN_SUFFIX, ''); +} + +function prNumberOf(ref) { + const m = /^refs\/pull\/(\d+)\/merge$/.exec(ref); + const isPr = m !== null; + if (!isPr) return null; + return Number(m[1]); +} + +function newestFirst(a, b) { + const sameTime = a.created_at === b.created_at; + if (sameTime) return b.id - a.id; + return a.created_at < b.created_at ? 1 : -1; +} + +// caches: [{id, key, ref, size_in_bytes, created_at}] as the REST API returns them +// closed: Set of pull request numbers that are closed +// goneRefs: Set of branch refs (refs/heads/...) that no longer exist +// Returns {drop: [{entry, reason}], keep: [entry]}. +function plan(caches, closed, goneRefs) { + const drop = []; + const keep = []; + const series = new Map(); + for (const c of caches) { + const pr = prNumberOf(c.ref); + const prClosed = pr !== null && closed.has(pr); + if (prClosed) { + drop.push({ entry: c, reason: `pull request #${pr} is closed` }); + continue; + } + const refGone = goneRefs.has(c.ref); + if (refGone) { + drop.push({ entry: c, reason: `${c.ref} no longer exists` }); + continue; + } + const s = seriesOf(c.key); + const fixedKey = s === null; + if (fixedKey) { + keep.push(c); + continue; + } + const slot = `${c.ref}\n${s}`; + const known = series.has(slot); + if (!known) series.set(slot, []); + series.get(slot).push(c); + } + for (const [slot, entries] of series) { + entries.sort(newestFirst); + keep.push(entries[0]); + const newest = entries[0].key; + for (const c of entries.slice(1)) { + drop.push({ entry: c, reason: `superseded by ${newest} on ${slot.split('\n')[0]}` }); + } + } + return { drop, keep }; +} + +function bytes(list) { + return list.reduce((n, c) => n + c.size_in_bytes, 0); +} + +function gb(n) { + return (n / 1e9).toFixed(2); +} + +async function run({ github, context, core, dryRun }) { + const { owner, repo } = context.repo; + const caches = await github.paginate(github.rest.actions.getActionsCacheList, { + owner, repo, per_page: 100, + }); + + const closed = new Set(); + const prs = new Set(caches.map((c) => prNumberOf(c.ref)).filter((n) => n !== null)); + for (const pull_number of prs) { + const { data } = await github.rest.pulls.get({ owner, repo, pull_number }); + const isClosed = data.state === 'closed'; + if (isClosed) closed.add(pull_number); + } + + const goneRefs = new Set(); + const branchRefs = new Set(caches.map((c) => c.ref).filter((r) => r.startsWith('refs/heads/'))); + for (const ref of branchRefs) { + const branch = ref.slice('refs/heads/'.length); + try { + await github.rest.repos.getBranch({ owner, repo, branch }); + } catch (e) { + const missing = e.status === 404; + if (!missing) throw e; + goneRefs.add(ref); + } + } + + const { drop, keep } = plan(caches, closed, goneRefs); + core.info(`${caches.length} entries, ${gb(bytes(caches))} GB; ` + + `drop ${drop.length} (${gb(bytes(drop.map((d) => d.entry)))} GB), keep ${keep.length} (${gb(bytes(keep))} GB)` + + (dryRun ? ' [dry run]' : '')); + + let deleted = 0; + let deletedBytes = 0; + let failed = 0; + for (const { entry, reason } of drop) { + core.info(`${dryRun ? 'would drop' : 'drop'} ${entry.id} ${entry.ref} ${entry.key}: ${reason}`); + if (dryRun) continue; + try { + await github.rest.actions.deleteActionsCacheById({ owner, repo, cache_id: entry.id }); + deleted += 1; + deletedBytes += entry.size_in_bytes; + } catch (e) { + // A concurrent prune may have taken it first. + const alreadyGone = e.status === 404; + if (alreadyGone) continue; + failed += 1; + core.warning(`could not delete ${entry.id} ${entry.key}: ${e.message}`); + } + } + + await core.summary + .addHeading('Actions cache prune', 3) + .addTable([ + [{ data: '', header: true }, { data: 'entries', header: true }, { data: 'GB', header: true }], + ['before', `${caches.length}`, gb(bytes(caches))], + [dryRun ? 'would drop' : 'dropped', `${dryRun ? drop.length : deleted}`, gb(dryRun ? bytes(drop.map((d) => d.entry)) : deletedBytes)], + ['kept', `${keep.length}`, gb(bytes(keep))], + ]) + .write(); + if (failed > 0) core.setFailed(`${failed} cache entries could not be deleted`); +} + +module.exports = { plan, run, seriesOf, prNumberOf }; diff --git a/.github/scripts/cache-prune.test.js b/.github/scripts/cache-prune.test.js new file mode 100644 index 000000000..cf8ac1294 --- /dev/null +++ b/.github/scripts/cache-prune.test.js @@ -0,0 +1,176 @@ +// node --test .github/scripts/cache-prune.test.js +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { plan, run, seriesOf, prNumberOf } = require('./cache-prune.js'); + +const ZIG = 'setup-zig-cache-v2-brain_unit-zig-x86_64-linux-0.16.0-'; +let nextId = 1; +function entry(ref, key, created, size = 100) { + return { id: nextId++, ref, key, created_at: created, size_in_bytes: size }; +} +const ids = (list) => list.map((x) => (x.entry || x).id).sort((a, b) => a - b); + +test('series: a run id at the end makes a series, a fixed key does not', () => { + assert.equal(seriesOf(`${ZIG}-37193965908-1`), ZIG); + assert.equal(seriesOf(`${ZIG}-37193965908`), ZIG); + assert.equal(seriesOf('setup-zig-tarball-zig-x86_64-linux-0.16.0'), null); + assert.equal(seriesOf('chipdb-xc7a200tfbg484-2-0123456789abcdef0123456789abcdef-bbasm-le-v1'), null); + // The part number ends in a short digit run; that is not a run id. + assert.equal(seriesOf('chipdb-xc7a200tfbg484-2'), null); +}); + +test('pull request number comes only from a merge ref', () => { + assert.equal(prNumberOf('refs/pull/839/merge'), 839); + assert.equal(prNumberOf('refs/heads/main'), null); + assert.equal(prNumberOf('refs/heads/pull/839/merge'), null); +}); + +test('every entry of a closed pull request is dropped, even the newest', () => { + const a = entry('refs/pull/839/merge', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'); + const b = entry('refs/pull/839/merge', 'setup-zig-tarball-zig-x86_64-linux-0.16.0', '2026-10-01T00:00:00Z'); + const { drop, keep } = plan([a, b], new Set([839]), new Set()); + assert.deepEqual(ids(drop), ids([a, b])); + assert.equal(keep.length, 0); +}); + +test('an open pull request keeps the newest entry of each series', () => { + const old = entry('refs/pull/852/merge', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'); + const neu = entry('refs/pull/852/merge', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z'); + const { drop, keep } = plan([neu, old], new Set([839]), new Set()); + assert.deepEqual(ids(drop), ids([old])); + assert.deepEqual(ids(keep), ids([neu])); +}); + +test('the same series on two refs is two series', () => { + const m1 = entry('refs/heads/main', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'); + const m2 = entry('refs/heads/main', `${ZIG}-37000000003-1`, '2026-10-03T00:00:00Z'); + const p1 = entry('refs/pull/852/merge', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z'); + const { drop, keep } = plan([m1, m2, p1], new Set(), new Set()); + assert.deepEqual(ids(drop), ids([m1])); + assert.deepEqual(ids(keep), ids([m2, p1])); +}); + +test('fixed keys are kept on a live ref', () => { + const t = entry('refs/heads/main', 'setup-zig-tarball-zig-x86_64-linux-0.16.0', '2026-09-07T00:00:00Z'); + const c = entry('refs/heads/main', 'chipdb-xc7a200tfbg484-2-0123abc-bbasm-le-v1', '2026-10-04T00:00:00Z'); + const { drop, keep } = plan([t, c], new Set(), new Set()); + assert.equal(drop.length, 0); + assert.deepEqual(ids(keep), ids([t, c])); +}); + +test('a deleted branch loses its entries', () => { + const g = entry('refs/heads/feat/gone', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'); + const l = entry('refs/heads/feat/live', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'); + const { drop, keep } = plan([g, l], new Set(), new Set(['refs/heads/feat/gone'])); + assert.deepEqual(ids(drop), ids([g])); + assert.deepEqual(ids(keep), ids([l])); +}); + +test('equal creation times keep the higher id', () => { + const a = entry('refs/heads/main', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'); + const b = entry('refs/heads/main', `${ZIG}-37000000001-2`, '2026-10-01T00:00:00Z'); + const { keep } = plan([b, a], new Set(), new Set()); + assert.deepEqual(ids(keep), ids([b])); +}); + +test('plan accounts for every entry exactly once', () => { + const all = [ + entry('refs/heads/main', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'), + entry('refs/heads/main', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z'), + entry('refs/pull/1/merge', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z'), + entry('refs/heads/main', 'setup-zig-tarball-x', '2026-10-02T00:00:00Z'), + ]; + const { drop, keep } = plan(all, new Set([1]), new Set()); + assert.deepEqual([...ids(drop), ...ids(keep)].sort((a, b) => a - b), ids(all)); +}); + +// A stand-in for the github-script client: enough of octokit for run(). +function fakeGithub(caches, { closed = [], gone = [], deleteStatus = {} } = {}) { + const deleted = []; + const err = (status) => Object.assign(new Error(`HTTP ${status}`), { status }); + return { + deleted, + paginate: async () => caches, + rest: { + actions: { + getActionsCacheList: () => {}, + deleteActionsCacheById: async ({ cache_id }) => { + const status = deleteStatus[cache_id]; + if (status) throw err(status); + deleted.push(cache_id); + }, + }, + pulls: { get: async ({ pull_number }) => ({ data: { state: closed.includes(pull_number) ? 'closed' : 'open' } }) }, + repos: { + getBranch: async ({ branch }) => { + if (gone.includes(branch)) throw err(404); + return { data: {} }; + }, + }, + }, + }; +} + +function fakeCore() { + const core = { failed: null, info() {}, warning() {}, setFailed(m) { core.failed = m; } }; + const summary = { addHeading: () => summary, addTable: () => summary, write: async () => summary }; + core.summary = summary; + return core; +} + +const context = { repo: { owner: 'o', repo: 'r' } }; + +function fixture() { + return [ + entry('refs/pull/839/merge', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'), + entry('refs/heads/main', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'), + entry('refs/heads/main', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z'), + entry('refs/heads/feat/gone', 'setup-zig-tarball-x', '2026-10-02T00:00:00Z'), + entry('refs/heads/main', 'setup-zig-tarball-x', '2026-10-02T00:00:00Z'), + ]; +} + +test('a dry run deletes nothing', async () => { + const caches = fixture(); + const github = fakeGithub(caches, { closed: [839], gone: ['feat/gone'] }); + const core = fakeCore(); + await run({ github, context, core, dryRun: true }); + assert.deepEqual(github.deleted, []); + assert.equal(core.failed, null); +}); + +test('a real run deletes exactly what plan drops', async () => { + const caches = fixture(); + const github = fakeGithub(caches, { closed: [839], gone: ['feat/gone'] }); + const core = fakeCore(); + await run({ github, context, core, dryRun: false }); + const [pr, mainOld, , gone] = caches; + assert.deepEqual(github.deleted.sort((a, b) => a - b), ids([pr, mainOld, gone])); + assert.equal(core.failed, null); +}); + +test('an entry already deleted by someone else is not a failure', async () => { + const caches = fixture(); + const github = fakeGithub(caches, { closed: [839], deleteStatus: { [caches[0].id]: 404 } }); + const core = fakeCore(); + await run({ github, context, core, dryRun: false }); + assert.equal(core.failed, null); +}); + +test('a refused delete fails the job', async () => { + const caches = fixture(); + const github = fakeGithub(caches, { closed: [839], deleteStatus: { [caches[0].id]: 403 } }); + const core = fakeCore(); + await run({ github, context, core, dryRun: false }); + assert.match(core.failed, /1 cache entries could not be deleted/); +}); + +test('a branch lookup error other than 404 stops the run before any delete', async () => { + const caches = fixture(); + const github = fakeGithub(caches, { closed: [839] }); + github.rest.repos.getBranch = async () => { throw Object.assign(new Error('HTTP 500'), { status: 500 }); }; + await assert.rejects(run({ github, context, core: fakeCore(), dryRun: false }), /HTTP 500/); + assert.deepEqual(github.deleted, []); +}); diff --git a/.github/workflows/actions-cache-prune-test.yml b/.github/workflows/actions-cache-prune-test.yml new file mode 100644 index 000000000..e38638d75 --- /dev/null +++ b/.github/workflows/actions-cache-prune-test.yml @@ -0,0 +1,25 @@ +name: actions-cache-prune-test + +# actions-cache-prune.yml runs the copy of its rules on the default branch, so a +# change to those rules is tested here, on the pull request that makes it. + +on: + pull_request: + paths: + - .github/scripts/cache-prune.js + - .github/scripts/cache-prune.test.js + - .github/workflows/actions-cache-prune.yml + - .github/workflows/actions-cache-prune-test.yml + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + - run: node --test .github/scripts/cache-prune.test.js diff --git a/.github/workflows/actions-cache-prune.yml b/.github/workflows/actions-cache-prune.yml new file mode 100644 index 000000000..2d8d15e1f --- /dev/null +++ b/.github/workflows/actions-cache-prune.yml @@ -0,0 +1,60 @@ +name: actions-cache-prune + +# Keep the 10 GB Actions cache for entries that can still be restored. +# gHashTag/trinity-fpga#844. +# +# setup-zig saves a new entry on every run of every Zig job, and an entry saved +# on a pull request can only be restored by that pull request. Left alone they +# filled the budget (161 entries, 10.73 GB on 2026-10-04) and evicted every +# chipdb, so each FPGA job rebuilt it from nothing (#843). +# +# The rules live in .github/scripts/cache-prune.js and are tested by +# .github/scripts/cache-prune.test.js, which this job runs before it deletes +# anything. + +on: + pull_request: + types: [closed] + schedule: + - cron: '41 * * * *' + workflow_dispatch: + inputs: + dry_run: + description: List what would be deleted, delete nothing + type: boolean + default: true + +permissions: + actions: write + contents: read + pull-requests: read + +concurrency: + group: actions-cache-prune + cancel-in-progress: false + +jobs: + prune: + # A fork's pull request gets a read-only token and could not delete anything. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # The default branch, not the closed pull request's merge ref, which may + # already be gone and must not decide what gets deleted. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + + - name: Test the prune rules + run: node --test .github/scripts/cache-prune.test.js + + - name: Prune + uses: actions/github-script@v7 + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + with: + script: | + const { run } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/cache-prune.js`); + await run({ github, context, core, dryRun: process.env.DRY_RUN === 'true' });