Skip to content
Merged
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
155 changes: 155 additions & 0 deletions .github/scripts/cache-prune.js
Original file line number Diff line number Diff line change
@@ -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-<job>-...--<run_id>-<attempt>` 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 };
176 changes: 176 additions & 0 deletions .github/scripts/cache-prune.test.js
Original file line number Diff line number Diff line change
@@ -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, []);
});
25 changes: 25 additions & 0 deletions .github/workflows/actions-cache-prune-test.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading