155 lines
5.0 KiB
JavaScript
155 lines
5.0 KiB
JavaScript
// .gitea/scripts/getLatestRunWithArtifacts.js
|
||
// Purpose: Find latest successful run that exposes all REQUIRED_ARTIFACTS via GUI URLs.
|
||
// Strategy:
|
||
// 1. Try to list runs via API (/actions/runs).
|
||
// 2. If not available (404), fallback to scraping HTML /actions page.
|
||
// 3. For each run (newest first, only "success"), check artifacts by probing GUI URLs.
|
||
// Outputs: sets `run_id` to GITHUB_OUTPUT and writes .gitea/.cache/run_id file.
|
||
|
||
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, opts = {}) {
|
||
return fetch(url, {
|
||
...opts,
|
||
headers: { Authorization: `token ${TOKEN}`, ...(opts.headers || {}) },
|
||
});
|
||
}
|
||
|
||
async function apiJSON(url) {
|
||
const res = await http(url);
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => "");
|
||
const err = new Error(`API ${res.status} ${res.statusText} for ${url}\n${text}`);
|
||
err.status = res.status;
|
||
throw err;
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
// ---- Run listing ----
|
||
function normalizeRunList(resp) {
|
||
if (Array.isArray(resp)) return resp;
|
||
return resp?.runs || resp?.workflow_runs || resp?.data || resp?.items || [];
|
||
}
|
||
|
||
async function tryApiListRuns() {
|
||
const url = `${BASE}/api/v1/repos/${OWNER}/${REPO}/actions/runs?limit=${SCAN_LIMIT}`;
|
||
try {
|
||
const resp = await apiJSON(url);
|
||
return normalizeRunList(resp);
|
||
} catch (e) {
|
||
if (e.status === 404) return null;
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
async function listRunsFromHtml() {
|
||
const url = `${BASE}/${OWNER}/${REPO}/actions`;
|
||
const res = await http(url, { headers: { Accept: "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, status: "success" })); // HTML doesn’t give status, assume ok
|
||
}
|
||
|
||
// ---- Artifact check via GUI URL ----
|
||
async function headOk(url) {
|
||
let res = await http(url, { method: "HEAD", redirect: "follow" });
|
||
if (res.ok) return true;
|
||
res = await http(url, { method: "GET", redirect: "manual" });
|
||
return res.status >= 200 && res.status < 400;
|
||
}
|
||
|
||
(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);
|
||
}
|
||
|
||
// newest first
|
||
const candidates = runs
|
||
.filter(r => r && r.id != null)
|
||
.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(", ")}`);
|
||
|
||
let picked = null;
|
||
for (const r of candidates) {
|
||
const runId = r.id;
|
||
const urls = REQUIRED_ARTIFACTS.map(
|
||
name => `${BASE}/${OWNER}/${REPO}/actions/runs/${runId}/artifacts/${encodeURIComponent(name)}`
|
||
);
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
if (allPresent) {
|
||
picked = { id: runId };
|
||
console.log(`Picked run_id=${runId}`);
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!picked) {
|
||
console.error("No run exposes all required artifacts.");
|
||
process.exit(1);
|
||
}
|
||
|
||
const runIdStr = String(picked.id);
|
||
fs.writeFileSync(path.join(cacheDir, "run_id"), runIdStr, "utf8");
|
||
|
||
if (process.env.GITHUB_OUTPUT) {
|
||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `run_id=${runIdStr}\n`);
|
||
} else {
|
||
console.log(`::set-output name=run_id::${runIdStr}`);
|
||
}
|
||
})().catch(err => {
|
||
console.error(err.stack || err.message || String(err));
|
||
process.exit(1);
|
||
}); |