This commit is contained in:
Michał Zieliński
2025-09-18 12:35:25 +02:00
parent 504a331f9a
commit 6feb20c85e

View File

@@ -1,24 +1,18 @@
// .gitea/scripts/getLatestRunWithArtifacts.js // .gitea/scripts/getLatestRunWithArtifacts.js
// Finds the latest successful *workflow run* that exposes all required artifacts. // Purpose: Find latest successful run that exposes all REQUIRED_ARTIFACTS via GUI URLs.
// Uses API if available; otherwise falls back to HTML scraping for run listing and artifacts. // Strategy:
// 1. Try to list runs via API (/actions/runs).
/* ENVIRONMENT: // 2. If not available (404), fallback to scraping HTML /actions page.
- GITEA_BASE_URL e.g. https://code.example.com // 3. For each run (newest first, only "success"), check artifacts by probing GUI URLs.
- OWNER repo owner, e.g. mz // Outputs: sets `run_id` to GITHUB_OUTPUT and writes .gitea/.cache/run_id file.
- 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 fs = require("fs");
const path = require("path"); const path = require("path");
const BASE = process.env.GITEA_BASE_URL; const BASE = process.env.GITEA_BASE_URL;
const OWNER = process.env.OWNER; const OWNER = process.env.OWNER;
const REPO = process.env.REPO; const REPO = process.env.REPO;
const TOKEN = process.env.GITEA_PAT; const TOKEN = process.env.GITEA_PAT;
const SCAN_LIMIT = Number(process.env.SCAN_LIMIT || "100"); const SCAN_LIMIT = Number(process.env.SCAN_LIMIT || "100");
const REQUIRED_ARTIFACTS = (process.env.REQUIRED_ARTIFACTS || "frontend,webapi") const REQUIRED_ARTIFACTS = (process.env.REQUIRED_ARTIFACTS || "frontend,webapi")
.split(",").map(s => s.trim()).filter(Boolean); .split(",").map(s => s.trim()).filter(Boolean);
@@ -27,67 +21,52 @@ if (!BASE || !OWNER || !REPO) {
console.error("Missing one of: GITEA_BASE_URL, OWNER, REPO"); console.error("Missing one of: GITEA_BASE_URL, OWNER, REPO");
process.exit(1); process.exit(1);
} }
if (!TOKEN) { console.error("Missing GITEA_PAT"); process.exit(1); } if (!TOKEN) {
console.error("Missing GITEA_PAT");
process.exit(1);
}
const cacheDir = path.join(".gitea", ".cache"); const cacheDir = path.join(".gitea", ".cache");
fs.mkdirSync(cacheDir, { recursive: true }); fs.mkdirSync(cacheDir, { recursive: true });
async function http(url, accept = "application/json") { async function http(url, opts = {}) {
const res = await fetch(url, { return fetch(url, {
headers: { Authorization: `token ${TOKEN}`, Accept: accept }, ...opts,
headers: { Authorization: `token ${TOKEN}`, ...(opts.headers || {}) },
}); });
return res;
} }
async function apiJSON(url) { async function apiJSON(url) {
const res = await http(url, "application/json"); const res = await http(url);
if (!res.ok) { if (!res.ok) {
const t = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
const err = new Error(`API ${res.status} for ${url}\n${t}`); const err = new Error(`API ${res.status} ${res.statusText} for ${url}\n${text}`);
err.status = res.status; err.status = res.status;
throw err; throw err;
} }
return res.json(); return res.json();
} }
// ---- Run listing ----
function normalizeRunList(resp) { function normalizeRunList(resp) {
if (Array.isArray(resp)) return resp; if (Array.isArray(resp)) return resp;
return resp?.runs || resp?.workflow_runs || resp?.data || resp?.items || []; 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() { async function tryApiListRuns() {
const pageSize = Math.min(50, SCAN_LIMIT); const url = `${BASE}/api/v1/repos/${OWNER}/${REPO}/actions/runs?limit=${SCAN_LIMIT}`;
const out = []; try {
let page = 1; const resp = await apiJSON(url);
return normalizeRunList(resp);
while (out.length < SCAN_LIMIT) { } catch (e) {
const url = `${BASE}/api/v1/repos/${OWNER}/${REPO}/actions/runs?limit=${pageSize}&page=${page}`; if (e.status === 404) return null;
let resp; throw e;
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() { async function listRunsFromHtml() {
const url = `${BASE}/${OWNER}/${REPO}/actions`; const url = `${BASE}/${OWNER}/${REPO}/actions`;
const res = await http(url, "text/html"); const res = await http(url, { headers: { Accept: "text/html" } });
if (!res.ok) { if (!res.ok) {
const t = await res.text().catch(() => ""); const t = await res.text().catch(() => "");
throw new Error(`HTML ${res.status} for ${url}\n${t}`); throw new Error(`HTML ${res.status} for ${url}\n${t}`);
@@ -97,30 +76,17 @@ async function listRunsFromHtml() {
.map(m => Number(m[1])) .map(m => Number(m[1]))
.filter(n => Number.isFinite(n)); .filter(n => Number.isFinite(n));
const unique = [...new Set(runIds)].sort((a, b) => b - a); const unique = [...new Set(runIds)].sort((a, b) => b - a);
return unique.slice(0, SCAN_LIMIT).map(id => ({ id })); return unique.slice(0, SCAN_LIMIT).map(id => ({ id, status: "success" })); // HTML doesnt give status, assume ok
} }
/* -------- Artifact listing -------- */ // ---- Artifact check via GUI URL ----
async function readArtifactNames(runId) { async function headOk(url) {
const url = `${BASE}/api/v1/repos/${OWNER}/${REPO}/actions/runs/${runId}/artifacts`; let res = await http(url, { method: "HEAD", redirect: "follow" });
try { if (res.ok) return true;
const json = await apiJSON(url); res = await http(url, { method: "GET", redirect: "manual" });
const list = Array.isArray(json?.artifacts) return res.status >= 200 && res.status < 400;
? 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 () => { (async () => {
let runs = await tryApiListRuns(); let runs = await tryApiListRuns();
if (!runs) { if (!runs) {
@@ -133,40 +99,56 @@ async function readArtifactNames(runId) {
process.exit(1); process.exit(1);
} }
// newest first
const candidates = runs const candidates = runs
.filter(r => r && r.id != null) .filter(r => r && r.id != null)
.sort((a, b) => (b.id ?? 0) - (a.id ?? 0)); .sort((a, b) => (b.id ?? 0) - (a.id ?? 0))
.filter(r => (r.status || "").toLowerCase() === "success" || !r.status); // HTML case: no status info
if (!candidates.length) {
console.error("No successful runs found.");
process.exit(1);
}
console.log(`Scanning ${candidates.length} runs for artifacts: ${REQUIRED_ARTIFACTS.join(", ")}`); console.log(`Scanning ${candidates.length} runs for artifacts: ${REQUIRED_ARTIFACTS.join(", ")}`);
let pickedId = null; let picked = null;
for (const r of candidates) { for (const r of candidates) {
const runId = r.id; const runId = r.id;
if (r.status && r.conclusion && !isSuccessfulRun(r)) { const urls = REQUIRED_ARTIFACTS.map(
console.log(`Run ${runId}: not successful`); name => `${BASE}/${OWNER}/${REPO}/actions/runs/${runId}/artifacts/${encodeURIComponent(name)}`
continue; );
let allPresent = true;
for (const u of urls) {
const ok = await headOk(u).catch(() => false);
if (!ok) {
allPresent = false;
console.log(`Run ${runId}: artifact not accessible -> ${u}`);
break;
}
} }
try {
const names = await readArtifactNames(runId); if (allPresent) {
const ok = REQUIRED_ARTIFACTS.every(req => names.includes(req)); picked = { id: runId };
if (ok) { pickedId = runId; break; } console.log(`Picked run_id=${runId}`);
console.log(`Run ${runId}: lacks required artifacts (has: ${names.join(", ") || "none"})`); break;
} catch (e) {
console.log(`Run ${runId}: cannot read artifacts -> ${String(e.message).split("\n")[0]}`);
} }
} }
if (!pickedId) { if (!picked) {
console.error("No run exposes all required artifacts."); console.error("No run exposes all required artifacts.");
process.exit(1); process.exit(1);
} }
fs.writeFileSync(path.join(cacheDir, "run_id"), String(pickedId), "utf8"); const runIdStr = String(picked.id);
fs.writeFileSync(path.join(cacheDir, "run_id"), runIdStr, "utf8");
if (process.env.GITHUB_OUTPUT) { if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `run_id=${pickedId}\n`); fs.appendFileSync(process.env.GITHUB_OUTPUT, `run_id=${runIdStr}\n`);
} else {
console.log(`::set-output name=run_id::${runIdStr}`);
} }
console.log(`Picked run_id=${pickedId}`);
})().catch(err => { })().catch(err => {
console.error(err.stack || err.message || String(err)); console.error(err.stack || err.message || String(err));
process.exit(1); process.exit(1);