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
50 changes: 42 additions & 8 deletions .github/scripts/cache-prune.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,28 @@
//
// 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.
//
// Then a budget. The newest entry of every series on every open pull request
// and its branch still came to 7.97 GB (130 entries) on 2026-10-05, and the
// saves between two prunes took the repository past 10 GB, where GitHub evicts
// whatever was read least recently, fixed keys included. The chipdb on a
// branch was gone within a day. So while the kept entries are over
// BUDGET_BYTES, setup-zig's per-run entries on refs other than main are
// dropped, least recently read first. Nothing else is dropped for the budget,
// including a chipdb whose key ends in a run id: a chipdb hit saves about
// three minutes of build, a Zig cache hit tens of seconds at most.

'use strict';

// Under GitHub's 10 GB, with room for the saves of a few runs between prunes.
const BUDGET_BYTES = 8e9;

// A run id (8 or more digits) at the end of a key, with an optional attempt.
const RUN_SUFFIX = /-\d{8,}(?:-\d+)?$/;

// What mlugg/setup-zig saves on every run; the only entries the budget spends.
const ZIG_CACHE = 'setup-zig-cache-';

// The key with its run id removed, or null when the key is fixed.
function seriesOf(key) {
const perRun = RUN_SUFFIX.test(key);
Expand All @@ -39,11 +55,16 @@ function newestFirst(a, b) {
return a.created_at < b.created_at ? 1 : -1;
}

// caches: [{id, key, ref, size_in_bytes, created_at}] as the REST API returns them
function lastRead(c) {
return c.last_accessed_at || c.created_at;
}

// caches: [{id, key, ref, size_in_bytes, created_at, last_accessed_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
// budget: bytes the kept entries may take; see BUDGET_BYTES
// Returns {drop: [{entry, reason}], keep: [entry]}.
function plan(caches, closed, goneRefs) {
function plan(caches, closed, goneRefs, budget = Infinity) {
const drop = [];
const keep = [];
const series = new Map();
Expand Down Expand Up @@ -78,7 +99,20 @@ function plan(caches, closed, goneRefs) {
drop.push({ entry: c, reason: `superseded by ${newest} on ${slot.split('\n')[0]}` });
}
}
return { drop, keep };

let kept = bytes(keep);
const spendable = keep
.filter((c) => c.key.startsWith(ZIG_CACHE) && seriesOf(c.key) !== null && c.ref !== 'refs/heads/main')
.sort((a, b) => (lastRead(a) < lastRead(b) ? -1 : lastRead(a) > lastRead(b) ? 1 : a.id - b.id));
const over = new Set();
for (const c of spendable) {
const fits = kept <= budget;
if (fits) break;
over.add(c);
kept -= c.size_in_bytes;
drop.push({ entry: c, reason: `over the ${gb(budget)} GB budget, last read ${lastRead(c)}` });
}
return { drop, keep: keep.filter((c) => !over.has(c)) };
}

function bytes(list) {
Expand All @@ -89,7 +123,7 @@ function gb(n) {
return (n / 1e9).toFixed(2);
}

async function run({ github, context, core, dryRun }) {
async function run({ github, context, core, dryRun, budget = BUDGET_BYTES }) {
const { owner, repo } = context.repo;
const caches = await github.paginate(github.rest.actions.getActionsCacheList, {
owner, repo, per_page: 100,
Expand All @@ -116,10 +150,10 @@ async function run({ github, context, core, dryRun }) {
}
}

const { drop, keep } = plan(caches, closed, goneRefs);
const { drop, keep } = plan(caches, closed, goneRefs, budget);
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]' : ''));
`drop ${drop.length} (${gb(bytes(drop.map((d) => d.entry)))} GB), keep ${keep.length} (${gb(bytes(keep))} GB), ` +
`budget ${gb(budget)} GB` + (dryRun ? ' [dry run]' : ''));

let deleted = 0;
let deletedBytes = 0;
Expand Down Expand Up @@ -152,4 +186,4 @@ async function run({ github, context, core, dryRun }) {
if (failed > 0) core.setFailed(`${failed} cache entries could not be deleted`);
}

module.exports = { plan, run, seriesOf, prNumberOf };
module.exports = { plan, run, seriesOf, prNumberOf, BUDGET_BYTES };
95 changes: 92 additions & 3 deletions .github/scripts/cache-prune.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@

const test = require('node:test');
const assert = require('node:assert/strict');
const { plan, run, seriesOf, prNumberOf } = require('./cache-prune.js');
const { plan, run, seriesOf, prNumberOf, BUDGET_BYTES } = 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 };
function entry(ref, key, created, size = 100, accessed = undefined) {
const e = { id: nextId++, ref, key, created_at: created, size_in_bytes: size };
const read = accessed !== undefined;
if (read) e.last_accessed_at = accessed;
return e;
}
const ids = (list) => list.map((x) => (x.entry || x).id).sort((a, b) => a - b);

Expand Down Expand Up @@ -86,6 +89,72 @@ test('plan accounts for every entry exactly once', () => {
assert.deepEqual([...ids(drop), ...ids(keep)].sort((a, b) => a - b), ids(all));
});

const CHIPDB = 'chipdb-xc7a200tfbg484-2-0123abc-bbasm-le-v1';

test('over the budget, per-run entries off main go, least recently read first', () => {
const a = entry('refs/pull/852/merge', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', 100, '2026-10-04T03:00:00Z');
const b = entry('refs/heads/fix/x', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z', 100, '2026-10-04T01:00:00Z');
const c = entry('refs/pull/853/merge', `${ZIG}-37000000003-1`, '2026-10-03T00:00:00Z', 100, '2026-10-04T02:00:00Z');
const { drop, keep } = plan([a, b, c], new Set(), new Set(), 150);
assert.deepEqual(ids(drop), ids([b, c]));
assert.deepEqual(ids(keep), ids([a]));
assert.match(drop[0].reason, /budget/);
});

test('the budget never drops a fixed key or an entry on main, even when still over', () => {
const chip = entry('refs/pull/852/merge', CHIPDB, '2026-10-01T00:00:00Z', 70, '2026-10-01T00:00:00Z');
const tar = entry('refs/heads/fix/x', 'setup-zig-tarball-x', '2026-10-01T00:00:00Z', 60, '2026-10-01T00:00:00Z');
const main = entry('refs/heads/main', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', 100, '2026-10-01T00:00:00Z');
const pr = entry('refs/pull/852/merge', `${ZIG}-37000000002-1`, '2026-10-05T00:00:00Z', 100, '2026-10-05T00:00:00Z');
const { drop, keep } = plan([chip, tar, main, pr], new Set(), new Set(), 10);
assert.deepEqual(ids(drop), ids([pr]));
assert.deepEqual(ids(keep), ids([chip, tar, main]));
});

// A chipdb keyed on its run id, as a branch saved on 2026-10-05.
test('the budget never drops a chipdb, even one whose key ends in a run id', () => {
const chip = entry('refs/heads/fix/x', 'chipdb-xc7a200tfbg484-2-regymm-37246227964', '2026-10-01T00:00:00Z', 70, '2026-10-01T00:00:00Z');
const zig = entry('refs/heads/fix/x', `${ZIG}-37000000002-1`, '2026-10-05T00:00:00Z', 100, '2026-10-05T00:00:00Z');
assert.notEqual(seriesOf(chip.key), null);
const { drop, keep } = plan([chip, zig], new Set(), new Set(), 10);
assert.deepEqual(ids(drop), ids([zig]));
assert.deepEqual(ids(keep), ids([chip]));
});

test('the budget never drops a setup-zig cache key that has no run id', () => {
const fixed = entry('refs/heads/fix/x', `${ZIG}-pinned`, '2026-10-01T00:00:00Z', 70, '2026-10-01T00:00:00Z');
const zig = entry('refs/heads/fix/x', `${ZIG}-37000000002-1`, '2026-10-05T00:00:00Z', 100, '2026-10-05T00:00:00Z');
const { drop, keep } = plan([fixed, zig], new Set(), new Set(), 10);
assert.deepEqual(ids(drop), ids([zig]));
assert.deepEqual(ids(keep), ids([fixed]));
});

test('under the budget, the budget drops nothing', () => {
const a = entry('refs/pull/852/merge', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', 100);
const b = entry('refs/heads/fix/x', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z', 100);
const { drop } = plan([a, b], new Set(), new Set(), 200);
assert.equal(drop.length, 0);
});

test('the last read decides, not the creation time', () => {
const oldButRead = entry('refs/pull/1/merge', `${ZIG}-37000000001-1`, '2026-09-01T00:00:00Z', 100, '2026-10-05T00:00:00Z');
const newButIdle = entry('refs/pull/2/merge', `${ZIG}-37000000002-1`, '2026-10-04T00:00:00Z', 100, '2026-10-04T00:00:00Z');
const { drop } = plan([oldButRead, newButIdle], new Set(), new Set(), 100);
assert.deepEqual(ids(drop), ids([newButIdle]));
});

test('with a budget, plan still 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/pull/2/merge', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z'),
entry('refs/pull/2/merge', CHIPDB, '2026-10-02T00:00:00Z'),
];
const { drop, keep } = plan(all, new Set([1]), new Set(), 150);
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 = [];
Expand Down Expand Up @@ -151,6 +220,26 @@ test('a real run deletes exactly what plan drops', async () => {
assert.equal(core.failed, null);
});

test('run passes its budget to plan, and defaults to BUDGET_BYTES', async () => {
const open = (size) => [
entry('refs/pull/852/merge', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', size),
entry('refs/heads/main', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', size),
];
const tight = open(100);
const g1 = fakeGithub(tight);
await run({ github: g1, context, core: fakeCore(), dryRun: false, budget: 0 });
assert.deepEqual(g1.deleted, ids([tight[0]]));
const g2 = fakeGithub(open(100));
await run({ github: g2, context, core: fakeCore(), dryRun: false });
assert.deepEqual(g2.deleted, []);
// 2 x 5 GB is over the default 8 GB, so the default budget drops the pull request's entry.
const big = open(5e9);
const g3 = fakeGithub(big);
await run({ github: g3, context, core: fakeCore(), dryRun: false });
assert.deepEqual(g3.deleted, ids([big[0]]));
assert.equal(BUDGET_BYTES, 8e9);
});

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 } });
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/actions-cache-prune.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@ name: actions-cache-prune
# 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.
#
# The hourly schedule alone was not enough: between this workflow's merge
# (2026-10-04 18:41Z) and 03:46Z it started 2 runs in 9 slots, and the cache
# went from 7.97 GB kept to over 11 GB. So it also runs after the two
# workflows that save the most (brain-ci 8.59 GB and CI 1.85 GB of 10.84 GB on
# 2026-10-05). The concurrency group below holds at most one pending run.

on:
pull_request:
types: [closed]
workflow_run:
workflows: ['S³AI Brain CI — "Functional MRI" Gate', CI]
types: [completed]
schedule:
- cron: '41 * * * *'
workflow_dispatch:
Expand Down
Loading