diff --git a/.gitea/workflows/cleanup-actions.yml b/.gitea/workflows/cleanup-actions.yml new file mode 100644 index 0000000..3f45a9c --- /dev/null +++ b/.gitea/workflows/cleanup-actions.yml @@ -0,0 +1,53 @@ +name: Clean up old workflow runs + +# Manual only, and dry run by default (#324). +# +# Gitea 1.27.3 expires a run's logs and artifacts but never the run record +# itself, so the Actions list grows without limit and fills with entries whose +# logs are already gone. This removes those entries. +# +# Deliberately not on a schedule. Deleting a run cannot be undone, the list is +# an annoyance rather than a problem, and a cron that quietly removes history +# should be a decision taken on its own rather than the default that arrives +# with the tool. + +on: + workflow_dispatch: + inputs: + keep_days: + description: 'Keep runs newer than this many days' + required: false + default: '7' + apply: + description: 'Type true to actually delete. Anything else reports only.' + required: false + default: 'false' + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + # No npm install: the script uses only node's own https module, so there + # is nothing to fetch and nothing that can break when a dependency moves. + - name: Delete old runs + env: + # A dedicated secret rather than the automatic per-job token, because + # deleting a run may be beyond what that token is allowed to do. If it + # turns out to be sufficient, this and the secret can both go. + GITEA_ACCESS_TOKEN: ${{ secrets.ACTIONS_CLEANUP_TOKEN }} + # Taken from the run's own context so this file carries no hostname + # and works unchanged if the instance ever moves — which #313 may yet + # make happen. + GITEA_HOST: ${{ github.server_url }} + GITEA_REPO: ${{ github.repository }} + KEEP_DAYS: ${{ github.event.inputs.keep_days }} + APPLY: ${{ github.event.inputs.apply }} + run: node scripts/cleanup-workflow-runs.js diff --git a/scripts/cleanup-workflow-runs.js b/scripts/cleanup-workflow-runs.js new file mode 100644 index 0000000..b58b228 --- /dev/null +++ b/scripts/cleanup-workflow-runs.js @@ -0,0 +1,176 @@ +/** + * 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, e.g. https://gitea.example.com + * 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 { 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); +const BASE = `/api/v1/repos/${REPO}/actions/runs`; + +function call(method, path) { + return new Promise((resolve, reject) => { + const req = https.request( + { hostname: origin.hostname, port: origin.port || 443, 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 () => { + 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); +});