Files
DiunaBI/.gitea/scripts/getLatestRunWithArtifacts.js
Michał Zieliński 504a331f9a release
2025-09-18 12:30:38 +02:00

173 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// .gitea/scripts/getLatestRunWithArtifacts.js
// Finds the latest successful *workflow run* that exposes all required artifacts.
// Uses API if available; otherwise falls back to HTML scraping for run listing and artifacts.
/* ENVIRONMENT:
- GITEA_BASE_URL e.g. https://code.example.com
- OWNER repo owner, e.g. mz
- REPO repo name, e.g. DiunaBI
- GITEA_PAT personal access token with repo read permissions
- SCAN_LIMIT optional; max number of runs to scan (default: 100)
- REQUIRED_ARTIFACTS comma-separated list, default "frontend,webapi"
*/
const fs = require("fs");
const path = require("path");
const BASE = process.env.GITEA_BASE_URL;
const OWNER = process.env.OWNER;
const REPO = process.env.REPO;
const TOKEN = process.env.GITEA_PAT;
const SCAN_LIMIT = Number(process.env.SCAN_LIMIT || "100");
const REQUIRED_ARTIFACTS = (process.env.REQUIRED_ARTIFACTS || "frontend,webapi")
.split(",").map(s => s.trim()).filter(Boolean);
if (!BASE || !OWNER || !REPO) {
console.error("Missing one of: GITEA_BASE_URL, OWNER, REPO");
process.exit(1);
}
if (!TOKEN) { console.error("Missing GITEA_PAT"); process.exit(1); }
const cacheDir = path.join(".gitea", ".cache");
fs.mkdirSync(cacheDir, { recursive: true });
async function http(url, accept = "application/json") {
const res = await fetch(url, {
headers: { Authorization: `token ${TOKEN}`, Accept: accept },
});
return res;
}
async function apiJSON(url) {
const res = await http(url, "application/json");
if (!res.ok) {
const t = await res.text().catch(() => "");
const err = new Error(`API ${res.status} for ${url}\n${t}`);
err.status = res.status;
throw err;
}
return res.json();
}
function normalizeRunList(resp) {
if (Array.isArray(resp)) return resp;
return resp?.runs || resp?.workflow_runs || resp?.data || resp?.items || [];
}
function isSuccessfulRun(r) {
const s = String(r.status || "").toLowerCase();
const c = String(r.conclusion || "").toLowerCase();
return s === "success" || (s === "completed" && c === "success");
}
/* -------- Run listing -------- */
async function tryApiListRuns() {
const pageSize = Math.min(50, SCAN_LIMIT);
const out = [];
let page = 1;
while (out.length < SCAN_LIMIT) {
const url = `${BASE}/api/v1/repos/${OWNER}/${REPO}/actions/runs?limit=${pageSize}&page=${page}`;
let resp;
try {
resp = await apiJSON(url);
} catch (e) {
if (e.status === 404) return null; // endpoint not available
throw e;
}
const chunk = normalizeRunList(resp);
if (!Array.isArray(chunk) || chunk.length === 0) break;
out.push(...chunk);
if (chunk.length < pageSize) break;
page += 1;
}
return out.slice(0, SCAN_LIMIT);
}
async function listRunsFromHtml() {
const url = `${BASE}/${OWNER}/${REPO}/actions`;
const res = await http(url, "text/html");
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`HTML ${res.status} for ${url}\n${t}`);
}
const html = await res.text();
const runIds = Array.from(html.matchAll(/\/actions\/runs\/(\d+)/g))
.map(m => Number(m[1]))
.filter(n => Number.isFinite(n));
const unique = [...new Set(runIds)].sort((a, b) => b - a);
return unique.slice(0, SCAN_LIMIT).map(id => ({ id }));
}
/* -------- Artifact listing -------- */
async function readArtifactNames(runId) {
const url = `${BASE}/api/v1/repos/${OWNER}/${REPO}/actions/runs/${runId}/artifacts`;
try {
const json = await apiJSON(url);
const list = Array.isArray(json?.artifacts)
? json.artifacts
: (Array.isArray(json) ? json : []);
return list.map(a => a?.name).filter(Boolean);
} catch (e) {
// fallback: artifacts tab in HTML
const res = await http(`${BASE}/${OWNER}/${REPO}/actions/runs/${runId}?_tab=artifacts`, "text/html");
if (!res.ok) return [];
const html = await res.text();
return Array.from(html.matchAll(/\/actions\/artifacts\/\d+[^>]*>([^<]+)</g))
.map(m => m[1].trim())
.filter(Boolean);
}
}
/* -------- Main -------- */
(async () => {
let runs = await tryApiListRuns();
if (!runs) {
console.log("Runs API not available on this Gitea falling back to HTML scraping.");
runs = await listRunsFromHtml();
}
if (!runs.length) {
console.error("No workflow runs found.");
process.exit(1);
}
const candidates = runs
.filter(r => r && r.id != null)
.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
console.log(`Scanning ${candidates.length} runs for artifacts: ${REQUIRED_ARTIFACTS.join(", ")}`);
let pickedId = null;
for (const r of candidates) {
const runId = r.id;
if (r.status && r.conclusion && !isSuccessfulRun(r)) {
console.log(`Run ${runId}: not successful`);
continue;
}
try {
const names = await readArtifactNames(runId);
const ok = REQUIRED_ARTIFACTS.every(req => names.includes(req));
if (ok) { pickedId = runId; break; }
console.log(`Run ${runId}: lacks required artifacts (has: ${names.join(", ") || "none"})`);
} catch (e) {
console.log(`Run ${runId}: cannot read artifacts -> ${String(e.message).split("\n")[0]}`);
}
}
if (!pickedId) {
console.error("No run exposes all required artifacts.");
process.exit(1);
}
fs.writeFileSync(path.join(cacheDir, "run_id"), String(pickedId), "utf8");
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `run_id=${pickedId}\n`);
}
console.log(`Picked run_id=${pickedId}`);
})().catch(err => {
console.error(err.stack || err.message || String(err));
process.exit(1);
});