Skip to content
Closed
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
136 changes: 136 additions & 0 deletions .github/scripts/__tests__/cleanup-rust-caches.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { cleanupRustCaches, supersededRustCaches } from '../cleanup-rust-caches.mjs';

const now = Date.parse('2026-10-21T00:00:00Z');
const day = 24 * 60 * 60 * 1000;

function cache(id, overrides = {}) {
return {
id,
ref: 'refs/heads/main',
key: `v1-rust-test-Linux-x64-12345678-${id.toString(16).padStart(8, '0')}`,
version: 'archive-version',
created_at: new Date(now - (6 - id) * day).toISOString(),
last_accessed_at: new Date(now - (6 - id) * day).toISOString(),
size_in_bytes: 500 * 1024 ** 2,
...overrides,
};
}

test('keeps the newest generation and expires unused older generations', () => {
assert.deepEqual(supersededRustCaches([cache(2), cache(1), cache(3)], now), [cache(2), cache(1)]);
});

test('preserves recently used or created entries, including the 48-hour boundary', () => {
for (const field of ['last_accessed_at', 'created_at']) {
for (const age of [0, 1, 2]) {
const recent = cache(1, { [field]: new Date(now - age * day).toISOString() });
assert.deepEqual(supersededRustCaches([recent, cache(5)], now), []);
}
}
});

test('never treats different environments, jobs, architectures, or archive formats as replacements', () => {
const first = cache(1);
for (const overrides of [
{ key: cache(2).key.replace('12345678', '87654321') },
{ key: cache(2).key.replace('-test-', '-lint-') },
{ key: cache(2).key.replace('Linux-x64', 'Darwin-arm64') },
{ version: 'different-archive-version' },
]) {
assert.deepEqual(supersededRustCaches([first, cache(2, overrides)], now), []);
}
});

test('ignores PR caches, other prefixes, invalid dates, and equal creation times', () => {
for (const overrides of [
{ ref: 'refs/pull/12/merge' },
{ key: 'v1-rust-unrecognized-key' },
{ key: cache(1).key.replace('v1-rust', 'v2-rust') },
{ created_at: 'invalid' },
{ last_accessed_at: 'invalid' },
{ version: '' },
{ created_at: cache(3).created_at },
]) {
assert.deepEqual(supersededRustCaches([cache(1, overrides), cache(3)], now), []);
}
});

function fixture(snapshots, status) {
const calls = [];
let reads = 0;
return {
calls,
context: { ref: 'refs/heads/main', repo: { owner: 'voidzero-dev', repo: 'vite-plus' } },
core: { info() {} },
github: {
async paginate(method, params) {
calls.push({ type: 'list', params });
return snapshots[Math.min(reads++, snapshots.length - 1)];
},
rest: {
actions: {
getActionsCacheList() {},
async deleteActionsCacheById(params) {
calls.push({ type: 'delete', params });
if (status) {
throw Object.assign(new Error('API error'), { status });
}
},
},
},
},
};
}

test('defaults to a dry run with no writes and reports eligible bytes', async () => {
const f = fixture([[cache(1), cache(3)]]);
const result = await cleanupRustCaches(f);
assert.deepEqual(result, { count: 1, bytes: cache(1).size_in_bytes });
assert.equal(f.calls.length, 1);
assert.equal(f.calls[0].params.ref, 'refs/heads/main');
assert.equal(f.calls[0].params.per_page, 100);
});

test('rechecks candidates and deletes by ID', async () => {
const f = fixture([[cache(1), cache(3)]]);
await cleanupRustCaches(f, { dryRun: false });
assert.deepEqual(
f.calls.map((call) => call.type),
['list', 'list', 'delete'],
);
assert.equal(f.calls[1].params.key, 'v1-rust-test-Linux-x64-12345678-');
assert.equal(f.calls[2].params.cache_id, 1);
});

test('skips entries used since the snapshot or whose replacement disappeared', async () => {
for (const second of [
[cache(1, { last_accessed_at: new Date().toISOString() }), cache(3)],
[cache(1)],
[cache(3)],
]) {
const f = fixture([[cache(1), cache(3)], second]);
assert.deepEqual(await cleanupRustCaches(f, { dryRun: false }), { count: 0, bytes: 0 });
assert.ok(f.calls.every((call) => call.type === 'list'));
}
});

test('tolerates concurrent eviction but reports other API failures', async () => {
assert.deepEqual(
await cleanupRustCaches(fixture([[cache(1), cache(3)]], 404), { dryRun: false }),
{ count: 0, bytes: 0 },
);
await assert.rejects(
cleanupRustCaches(fixture([[cache(1), cache(3)]], 403), { dryRun: false }),
/API error/,
);
});

test('refuses a non-main run before making API requests', async () => {
const f = fixture([]);
f.context.ref = 'refs/heads/feature';
await assert.rejects(cleanupRustCaches(f, { dryRun: false }), /must run on main/);
assert.deepEqual(f.calls, []);
});
84 changes: 84 additions & 0 deletions .github/scripts/cleanup-rust-caches.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const ref = 'refs/heads/main';
const unusedMilliseconds = 48 * 60 * 60 * 1000;

function family(cache) {
// Keep the job, OS, architecture, Rust/environment hash, and archive version
// separate. Only the final manifest hash can differ within one family.
const match =
/^v1-rust-.+-(?:Linux|Darwin|Windows_NT)-(?:x64|arm64)-[a-f0-9]{8}-[a-f0-9]{8}$/.exec(
cache.key,
);
if (cache.ref !== ref || !match || !cache.version) {
return undefined;
}
return `${cache.key.slice(0, -9)}:${cache.version}`;
}

export function supersededRustCaches(caches, now = Date.now()) {
const newest = new Map();
for (const cache of caches) {
const group = family(cache);
const created = Date.parse(cache.created_at);
if (group && created > (newest.get(group) ?? -Infinity)) {
newest.set(group, created);
}
}
const cutoff = now - unusedMilliseconds;
return caches.filter(
(cache) =>
Date.parse(cache.created_at) < newest.get(family(cache)) &&
Date.parse(cache.created_at) < cutoff &&
Date.parse(cache.last_accessed_at) < cutoff,
);
}

export async function cleanupRustCaches({ github, context, core }, { dryRun = true } = {}) {
// This workflow runs trusted main code with actions:write. PR caches are
// handled separately by the existing closed-PR job.
if (context.ref !== ref) {
throw new Error('Rust cache cleanup must run on main');
}
const list = async (key) =>
github.paginate(github.rest.actions.getActionsCacheList, {
...context.repo,
ref,
key,
per_page: 100,
sort: 'created_at',
direction: 'desc',
});
const now = Date.now();
const candidates = supersededRustCaches(await list('v1-rust-'), now);
let count = 0;
let bytes = 0;
for (const candidate of candidates) {
if (!dryRun) {
// Recheck recent use and the replacement before deleting. The initial
// snapshot may be stale after pagination or another cleanup.
const current = supersededRustCaches(await list(candidate.key.slice(0, -8)), now);
if (!current.some((cache) => cache.id === candidate.id)) {
continue;
}
try {
await github.rest.actions.deleteActionsCacheById({
...context.repo,
cache_id: candidate.id,
});
} catch (error) {
if (error.status === 404) {
continue;
}
throw error;
}
}
core.info(
`${dryRun ? 'Would delete' : 'Deleted'} ${candidate.key} (${candidate.size_in_bytes} B)`,
);
count++;
bytes += candidate.size_in_bytes;
}
core.info(
`${dryRun ? 'Eligible' : 'Deleted'}: ${count} caches, ${(bytes / 1024 ** 2).toFixed(1)} MiB`,
);
return { count, bytes };
}
36 changes: 33 additions & 3 deletions .github/workflows/cleanup-cache.yml
Original file line number Diff line number Diff line change
@@ -1,29 +1,59 @@
name: Cleanup github runner caches on closed pull requests
name: Cleanup GitHub Actions caches
on:
pull_request:
types:
- closed
schedule:
- cron: '23 */6 * * *'
workflow_dispatch:
inputs:
dry-run:
description: Report unused main Rust caches without deleting them.
type: boolean
default: true

jobs:
cleanup:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Cleanup
run: |
echo "Fetching list of cache keys"
cacheKeysForPR=$(gh cache list --ref $BRANCH --limit 100 --json id --jq '.[].id')
cacheKeysForPR=$(gh cache list --ref "$BRANCH" --limit 100 --json id --jq '.[].id')

## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches…"
for cacheKey in $cacheKeysForPR
do
gh cache delete $cacheKey
gh cache delete "$cacheKey"
done
echo "Done"
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge

cleanup-rust:
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
group: cleanup-main-rust-caches
cancel-in-progress: false
permissions:
contents: read
actions: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run }}
with:
script: |
const { cleanupRustCaches } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/cleanup-rust-caches.mjs`);
await cleanupRustCaches({ github, context, core }, { dryRun: process.env.DRY_RUN === 'true' });
Loading