/** * Deletes completed Actions runs older than a given age. * * Gitea 1.27.3 has retention for a run's logs (`LOG_RETENTION_DAYS`) and its * artifacts, but none for the run record itself — so the Actions list grows * without limit while the entries on it become empty shells once their logs * expire. This removes the shells. See #324. * * Dry run unless APPLY is exactly "true". Deleting a run cannot be undone and * there is no confirmation step once this starts, so the default has to be the * harmless one. * * Environment: * GITEA_HOST origin of the instance, scheme included, e.g. * https://gitea.example.com or http://gitea:3000 * GITEA_REPO "owner/name" * GITEA_ACCESS_TOKEN token permitted to delete runs * KEEP_DAYS keep runs newer than this many days (default 7) * APPLY "true" to delete; anything else reports only */ const https = require('https'); const http = require('http'); const { URL } = require('url'); const HOST = process.env.GITEA_HOST; const REPO = process.env.GITEA_REPO; const TOKEN = process.env.GITEA_ACCESS_TOKEN; const KEEP_DAYS = Number(process.env.KEEP_DAYS || 7); const APPLY = process.env.APPLY === 'true'; for (const [name, value] of Object.entries({ GITEA_HOST: HOST, GITEA_REPO: REPO, GITEA_ACCESS_TOKEN: TOKEN })) { if (!value) { console.error(`${name} is required`); process.exit(1); } } if (!Number.isFinite(KEEP_DAYS) || KEEP_DAYS < 0) { console.error(`KEEP_DAYS must be a non-negative number, got ${process.env.KEEP_DAYS}`); process.exit(1); } const origin = new URL(HOST); /** * The transport GITEA_HOST actually asks for, rather than the one assumed. * * This was hardcoded to https, which worked when the host was typed by hand and * failed the moment the workflow started taking it from `github.server_url`. * Inside the runner that is the address act_runner reaches Gitea on, which here * is plain HTTP on a container port — and a TLS handshake sent to a plaintext * port does not fail as a connection error. It fails as: * * write EPROTO ... ssl3_get_record:wrong version number * * which reads like a TLS misconfiguration and sends you looking at certificates * and protocol versions. It is neither. The server answered in cleartext and * OpenSSL tried to parse that as a TLS record. * * So the scheme is honoured rather than guessed, and the default port follows * from it. Anything other than the two is refused up front: this script only * speaks HTTP, and a URL naming some other scheme is a mistake worth reporting * as one instead of failing later inside a request. */ if (origin.protocol !== 'https:' && origin.protocol !== 'http:') { console.error(`GITEA_HOST must be an http or https URL, got ${HOST}`); process.exit(1); } const secure = origin.protocol === 'https:'; const transport = secure ? https : http; const PORT = origin.port || (secure ? 443 : 80); const BASE = `/api/v1/repos/${REPO}/actions/runs`; function call(method, path) { return new Promise((resolve, reject) => { const req = transport.request( { hostname: origin.hostname, port: PORT, path, method, headers: { Authorization: `token ${TOKEN}` } }, (res) => { let body = ''; res.on('data', (d) => (body += d)); res.on('end', () => resolve({ status: res.statusCode, body })); } ); req.on('error', reject); req.end(); }); } async function listAllRuns() { const runs = []; // Bounded rather than `while (true)`: a paging bug against an API that keeps // answering would otherwise loop until the job times out. for (let page = 1; page <= 200; page++) { const res = await call('GET', `${BASE}?page=${page}&limit=50`); if (res.status >= 400) throw new Error(`listing runs failed: ${res.status} ${res.body.slice(0, 200)}`); const batch = JSON.parse(res.body).workflow_runs || []; if (batch.length === 0) break; runs.push(...batch); if (batch.length < 50) break; } return runs; } const EPOCH_GUARD = new Date('2000-01-01').getTime(); /** * When a run happened, from whichever timestamp it actually has. * * `started_at` is preferred and is usually right, but a run **cancelled before * it ever started** reports an epoch value there while carrying a real * `completed_at`. Reading only `started_at` therefore made every cancelled run * look undateable, and a first pass at this left 19 of them — from three weeks * earlier — sitting in a list that was supposed to hold seven days. * * Returns null when neither timestamp is usable, which is the case that must * stay conservative: an epoch date treated as "1969, therefore old" would * delete precisely the runs that have not happened yet. */ function runTimeMs(run) { for (const stamp of [run.started_at, run.completed_at]) { const ms = stamp ? new Date(stamp).getTime() : NaN; if (Number.isFinite(ms) && ms >= EPOCH_GUARD) return ms; } return null; } /** * Runs old enough to remove, and the reasons the others were left. * * A run that is not completed is never a candidate — which is also what stops * this deleting the very run it is executing in. */ function selectDoomed(runs, cutoffMs) { const doomed = []; const kept = { recent: 0, unfinished: 0, undated: 0 }; for (const run of runs) { if (run.status !== 'completed') { kept.unfinished++; continue; } const ms = runTimeMs(run); if (ms === null) { kept.undated++; continue; } if (ms >= cutoffMs) { kept.recent++; continue; } doomed.push({ id: run.id, startedAt: run.started_at, when: new Date(ms).toISOString() }); } doomed.sort((a, b) => a.id - b.id); return { doomed, kept }; } (async () => { // Printed before the first request rather than after it succeeds. A transport // failure here says nothing about where it was pointed, and the last one cost // a round trip to find out that the answer was "somewhere plaintext". console.log(`endpoint : ${origin.protocol}//${origin.hostname}:${PORT}`); const runs = await listAllRuns(); const cutoffMs = Date.now() - KEEP_DAYS * 86400000; const { doomed, kept } = selectDoomed(runs, cutoffMs); console.log(`repository : ${REPO}`); console.log(`total runs : ${runs.length}`); console.log(`keeping : ${kept.recent} newer than ${KEEP_DAYS}d, ${kept.unfinished} unfinished, ${kept.undated} undated`); console.log(`to delete : ${doomed.length}`); if (doomed.length > 0) { console.log(` oldest : run ${doomed[0].id} (${doomed[0].when})`); console.log(` newest : run ${doomed[doomed.length - 1].id} (${doomed[doomed.length - 1].when})`); } console.log(''); if (!APPLY) { console.log('DRY RUN — nothing deleted. Re-run with apply set to "true".'); return; } if (doomed.length === 0) { console.log('Nothing to delete.'); return; } let deleted = 0; const failures = []; for (const run of doomed) { const res = await call('DELETE', `${BASE}/${run.id}`); if (res.status >= 200 && res.status < 300) deleted++; else failures.push(`${run.id}:${res.status}`); const done = deleted + failures.length; if (done % 50 === 0) console.log(` ...${done}/${doomed.length}`); } console.log(''); console.log(`deleted : ${deleted}`); console.log(`failed : ${failures.length}`); if (failures.length > 0) { console.log(` ${failures.slice(0, 20).join(' ')}${failures.length > 20 ? ' …' : ''}`); // A partial delete is not a success. Most likely the token cannot delete // runs, and reporting green here would hide that behind a job that // appeared to work. process.exitCode = 1; } })().catch((err) => { console.error(`FAILED: ${err.message}`); process.exit(1); });