// .gitea/scripts/getLatestRunWithArtifacts.js // Robust finder for the latest successful *workflow run* that exposes required artifacts. // Works against Gitea's Actions API only (no scraping of UI URLs). /* ENVIRONMENT: - GITEA_BASE_URL e.g. https://code.example.com - OWNER repository owner/user, e.g. mz - REPO repository name, e.g. DiunaBI - GITEA_PAT personal access token with repo read permissions - SCAN_LIMIT optional; max number of runs to scan across pages (default: 100) - REQUIRED_ARTIFACTS comma-separated list, e.g. "frontend,webapi" (default: as left) - FILTER_BRANCH optional; if set, only consider runs from this branch (e.g. "main") - INCLUDE_TAGS optional; "true" to allow refs/tags (default: true) */ const fs = require("fs"); const path = require("path"); // ---- Config & defaults ------------------------------------------------------ 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); const FILTER_BRANCH = (process.env.FILTER_BRANCH || "").trim(); // e.g. "main" const INCLUDE_TAGS = String(process.env.INCLUDE_TAGS ?? "true").toLowerCase() !== "false"; 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); } // Output dir for passing values between steps const cacheDir = path.join(".gitea", ".cache"); fs.mkdirSync(cacheDir, { recursive: true }); // ---- Small HTTP helper ------------------------------------------------------ async function apiJSON(url) { const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); if (!res.ok) { const t = await res.text().catch(() => ""); throw new Error(`API ${res.status} for ${url}\n${t}`); } return res.json(); } // Normalize different shapes of list responses across Gitea versions function normalizeRunList(resp) { if (Array.isArray(resp)) return resp; return resp?.runs || resp?.workflow_runs || resp?.data || resp?.items || []; } // Success predicate at the RUN level function isSuccessfulRun(r) { const status = String(r.status || "").toLowerCase(); // "completed", "success" (varies) const concl = String(r.conclusion || "").toLowerCase(); // "success" (may be empty) return status === "success" || (status === "completed" && concl === "success"); } // Optional ref filter function passesRefFilter(run) { // Gitea usually provides head_branch plus ref. We accept: // - FILTER_BRANCH (if set) // - tags (refs/tags/*) when INCLUDE_TAGS=true const headBranch = run.head_branch || ""; const ref = run.ref || ""; if (FILTER_BRANCH && headBranch && headBranch !== FILTER_BRANCH) { // If FILTER_BRANCH is set, allow tags too if INCLUDE_TAGS=true if (!(INCLUDE_TAGS && ref.startsWith("refs/tags/"))) return false; } if (!FILTER_BRANCH && !INCLUDE_TAGS) { // No branch required and tags disabled → accept anything on branches only if (ref.startsWith("refs/tags/")) return false; } return true; } // Fetch paginated runs up to SCAN_LIMIT async function fetchRunsPaginated() { const pageSize = Math.min(50, SCAN_LIMIT); // be nice to the API const out = []; let page = 1; while (out.length < SCAN_LIMIT) { const url = `${BASE}/api/v1/repos/${OWNER}/${REPO}/actions/runs?limit=${pageSize}&page=${page}`; const resp = await apiJSON(url); const chunk = normalizeRunList(resp); if (!Array.isArray(chunk) || chunk.length === 0) break; out.push(...chunk); if (chunk.length < pageSize) break; // last page page += 1; } // Trim to SCAN_LIMIT if we over-fetched return out.slice(0, SCAN_LIMIT); } // Read artifact names for a given run id async function readArtifactNames(runId) { const url = `${BASE}/api/v1/repos/${OWNER}/${REPO}/actions/runs/${runId}/artifacts`; const resp = await apiJSON(url); const list = Array.isArray(resp?.artifacts) ? resp.artifacts : (Array.isArray(resp) ? resp : []); return list.map(a => a?.name).filter(Boolean); } // ---- Main ------------------------------------------------------------------- (async () => { const runs = await fetchRunsPaginated(); if (!runs.length) { console.error("No workflow runs returned by API."); process.exit(1); } // Most recent first by id (Gitea ids are monotonic; if needed, also compare created_at) const candidates = runs .filter(r => r && r.id != null) .sort((a, b) => (b.id ?? 0) - (a.id ?? 0)) .filter(isSuccessfulRun) .filter(passesRefFilter); if (!candidates.length) { console.error("No successful workflow runs found (after filtering)."); process.exit(1); } console.log( `Scanning ${candidates.length} successful runs for artifacts: ${REQUIRED_ARTIFACTS.join(", ")}` ); let pickedId = null; for (const r of candidates) { const runId = r.id; 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) { // If artifacts endpoint fails (permissions, transient), keep scanning console.log( `Run ${runId}: cannot read artifacts via API -> ${String(e.message).split("\n")[0]}` ); } } if (!pickedId) { console.error("No run exposes all required artifacts via API."); process.exit(1); } // Write outputs for downstream steps 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); });