diff --git a/.github/scripts/__tests__/cleanup-rust-caches.mjs b/.github/scripts/__tests__/cleanup-rust-caches.mjs new file mode 100644 index 0000000000..59cc7f48db --- /dev/null +++ b/.github/scripts/__tests__/cleanup-rust-caches.mjs @@ -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, []); +}); diff --git a/.github/scripts/cleanup-rust-caches.mjs b/.github/scripts/cleanup-rust-caches.mjs new file mode 100644 index 0000000000..29de4180f1 --- /dev/null +++ b/.github/scripts/cleanup-rust-caches.mjs @@ -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 }; +} diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml index 72f5b067f8..6499a85241 100644 --- a/.github/workflows/cleanup-cache.yml +++ b/.github/workflows/cleanup-cache.yml @@ -1,11 +1,20 @@ -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 @@ -13,17 +22,38 @@ jobs: - 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' });