Files
redefined-designs/scripts/cleanup-workflow-runs.js
T
synAdminandClaude Opus 5 f9c40146d4 fix(ci): the cleanup script forced TLS onto a plaintext endpoint (#324)
The workflow failed on its first real run with:

    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.

The script required node's https module and always used it, defaulting to port 443. That was fine while the host was typed by hand, and it stopped being fine the moment the workflow started supplying it from github.server_url. Inside the runner that is the address act_runner reaches Gitea on, not the public one, and here it is plain HTTP on a container port.

So the scheme in GITEA_HOST is honoured rather than assumed, and the default port follows from it. A URL naming neither http nor https is refused up front, because this script speaks nothing else and reporting that as a bad input beats failing later inside a request.

The endpoint is now printed before the first request rather than after one succeeds. That is the part that made this cost more than it should have: a transport failure said nothing about where it had been pointed, so the message named a symptom in OpenSSL and nothing about the run at all.

Verified against a plaintext HTTP stub end to end: the listing, the age selection and the dry-run report all work over http, and a bad scheme exits 1 with a message naming the value it was given.

Refs #324

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:19:47 -05:00

213 lines
7.6 KiB
JavaScript

/**
* 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);
});