Merge pull request 'chore(ci): a manually-run workflow to delete old Actions runs (#324)' (#325) from chore/324-cleanup-actions-workflow into main
Reviewed-on: #325
This commit was merged in pull request #325.
This commit is contained in:
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user