This commit is contained in:
Michał Zieliński
2025-09-18 12:23:55 +02:00
parent dcbbdc287a
commit 9981b1c21b

View File

@@ -1,265 +1,145 @@
// .gitea/scripts/getLatestRunWithArtifacts.js
// Finds the latest successful *workflow run* that exposes all required artifacts.
// Works against Gitea. Uses API if available; otherwise falls back to HTML scraping
// of the Actions pages (because many Gitea versions dont expose runs/artifacts via API).
/* ENVIRONMENT:
- GITEA_BASE_URL e.g. https://code.example.com
- OWNER repo owner, e.g. mz
- REPO repo name, e.g. DiunaBI
- GITEA_PAT PAT with repo read permissions (used as "Authorization: token <PAT>")
- SCAN_LIMIT optional; max number of runs to scan (default: 100)
- REQUIRED_ARTIFACTS comma-separated list, default "frontend,webapi"
- FILTER_BRANCH optional; if set, only consider runs from this branch (e.g. "main")
- INCLUDE_TAGS optional; "true" (default) to allow tag refs
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const fs = require("fs");
const path = require("path");
// Konfiguracja z zmiennych środowiskowych
const config = {
baseUrl: process.env.GITEA_BASE_URL,
owner: process.env.OWNER,
repo: process.env.REPO,
token: process.env.GITEA_PAT,
requiredArtifacts: process.env.REQUIRED_ARTIFACTS?.split(',') || [],
scanLimit: parseInt(process.env.SCAN_LIMIT) || 50
};
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();
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); }
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;
// Walidacja konfiguracji
if (!config.baseUrl || !config.owner || !config.repo || !config.token) {
console.error('Błąd: Brak wymaganych zmiennych środowiskowych (GITEA_BASE_URL, OWNER, REPO, GITEA_PAT)');
process.exit(1);
}
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();
}
console.log(`Szukam ostatniego udanego buildu dla ${config.owner}/${config.repo}`);
console.log(`Wymagane artefakty: ${config.requiredArtifacts.join(', ')}`);
function normalizeRunList(resp) {
if (Array.isArray(resp)) return resp;
return resp?.runs || resp?.workflow_runs || resp?.data || resp?.items || [];
}
// Funkcja do wykonywania zapytań HTTPS
function makeRequest(url, options = {}) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const requestOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || 443,
path: parsedUrl.pathname + parsedUrl.search,
method: 'GET',
headers: {
'Authorization': `token ${config.token}`,
'Accept': 'application/json',
'User-Agent': 'Gitea-Workflow-Script'
},
...options
};
function isSuccessfulRun(r) {
const s = String(r.status || "").toLowerCase(); // "completed", "success" (varies)
const c = String(r.conclusion || "").toLowerCase(); // "success" (sometimes empty)
return s === "success" || (s === "completed" && c === "success");
}
function passesRefFilter(run) {
const headBranch = run.head_branch || "";
const ref = run.ref || "";
if (FILTER_BRANCH && headBranch && headBranch !== FILTER_BRANCH) {
if (!(INCLUDE_TAGS && ref.startsWith("refs/tags/"))) return false;
}
if (!FILTER_BRANCH && !INCLUDE_TAGS && ref.startsWith("refs/tags/")) return false;
return true;
}
/* ---------------------------- API STRATEGY ---------------------------- */
async function tryApiListRuns() {
// If the endpoint exists on this Gitea version, this will succeed,
// otherwise well get a 404 and fall back to HTML.
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; // real error
}
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 apiArtifactNames(runId) {
const url = `${BASE}/api/v1/repos/${OWNER}/${REPO}/actions/runs/${runId}/artifacts`;
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;
}
const json = await res.json();
const list = Array.isArray(json?.artifacts) ? json.artifacts : (Array.isArray(json) ? json : []);
return list.map(a => a?.name).filter(Boolean);
}
/* --------------------------- HTML STRATEGY ---------------------------- */
// Very light HTML parsing using regexes; robust enough for Giteas Actions pages.
async function listRunsFromHtml() {
// Actions overview page often lists recent runs and links like: /OWNER/REPO/actions/runs/123
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();
// Find run links
const runIds = Array.from(html.matchAll(/\/actions\/runs\/(\d+)/g))
.map(m => Number(m[1]))
.filter(n => Number.isFinite(n));
// De-duplicate and keep most recent first
const unique = [...new Set(runIds)].sort((a, b) => b - a);
return unique.slice(0, SCAN_LIMIT).map(id => ({ id }));
}
async function htmlArtifactNames(runId) {
const url = `${BASE}/${OWNER}/${REPO}/actions/runs/${runId}/artifacts`;
const res = await http(url, "text/html");
if (!res.ok) {
// Some Gitea versions show artifacts inline on the run page; try that as fallback.
const runUrl = `${BASE}/${OWNER}/${REPO}/actions/runs/${runId}`;
const res2 = await http(runUrl, "text/html");
if (!res2.ok) {
const t = await res.text().catch(() => "");
throw new Error(`HTML ${res.status} for ${url}\n${t}`);
}
const html2 = await res2.text();
return extractArtifactNamesFromHtml(html2);
}
const html = await res.text();
return extractArtifactNamesFromHtml(html);
}
function extractArtifactNamesFromHtml(html) {
// Look for common patterns showing artifact names in tables/links/spans.
// This is intentionally permissive: any text near "/actions/artifacts/" or "download" buttons.
const names = new Set();
// 1) <a href="/.../actions/artifacts/NNN">NAME</a>
for (const m of html.matchAll(/actions\/artifacts\/\d+[^>]*>([^<]+)</g)) {
const name = m[1].trim();
if (name) names.add(name);
}
// 2) data-name="NAME"
for (const m of html.matchAll(/data-(?:artifact-)?name="([^"]+)"/g)) {
const name = m[1].trim();
if (name) names.add(name);
}
// 3) Loose: lines that include “artifact”, capture preceding label-ish word
for (const m of html.matchAll(/artifact[^<>\n]*<\/[^>]*>\s*([^<>\n]{2,80})</gi)) {
const guess = m[1].trim();
if (guess) names.add(guess);
}
// Return as array of distinct names
return Array.from(names);
}
/* ------------------------------ MAIN --------------------------------- */
(async () => {
// Strategy A: official API (if present)
let runs = await tryApiListRuns();
// Strategy B: HTML scraping fallback
if (!runs) {
console.log("Runs API not available on this Gitea falling back to HTML scraping.");
runs = await listRunsFromHtml(); // {id} only
}
if (!runs.length) {
console.error("No workflow runs found.");
process.exit(1);
}
// Order newest first by id; API runs may include extra fields, HTML gives only id
const candidates = runs
.filter(r => r && r.id != null)
.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
console.log(`Scanning ${Math.min(candidates.length, SCAN_LIMIT)} runs for artifacts: ${REQUIRED_ARTIFACTS.join(", ")}`);
let pickedId = null;
for (const r of candidates.slice(0, SCAN_LIMIT)) {
const runId = r.id;
// Optional API-only filtering (status/branch). If runs came from HTML, we cant pre-filter reliably,
// so we just check artifacts presence (which is what we ultimately need).
if (r.status && r.conclusion) {
if (!isSuccessfulRun(r)) {
console.log(`Run ${runId}: not successful (status=${r.status}, conclusion=${r.conclusion})`);
continue;
}
if (!passesRefFilter(r)) {
console.log(`Run ${runId}: skipped by ref filter (branch=${r.head_branch || ""}, ref=${r.ref || ""})`);
continue;
}
}
let names = [];
try {
// Prefer API artifacts; if 404, use HTML extraction.
try {
names = await apiArtifactNames(runId);
} catch (e) {
if (e.status === 404) {
names = await htmlArtifactNames(runId);
const req = https.request(requestOptions, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(JSON.parse(data));
} catch (e) {
resolve(data);
}
} else {
throw e;
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
}
});
});
req.on('error', reject);
req.end();
});
}
// Funkcja do pobierania listy workflow runs
async function getWorkflowRuns() {
const url = `${config.baseUrl}/api/v1/repos/${config.owner}/${config.repo}/actions/runs?limit=${config.scanLimit}`;
console.log(`Pobieranie workflow runs z: ${url}`);
try {
const response = await makeRequest(url);
return response.workflow_runs || [];
} catch (error) {
console.error('Błąd podczas pobierania workflow runs:', error.message);
throw error;
}
}
// Funkcja do pobierania artefaktów dla danego run
async function getRunArtifacts(runId) {
const url = `${config.baseUrl}/api/v1/repos/${config.owner}/${config.repo}/actions/runs/${runId}/artifacts`;
try {
const response = await makeRequest(url);
return response.artifacts || [];
} catch (error) {
console.warn(`Nie można pobrać artefaktów dla run ${runId}:`, error.message);
return [];
}
}
// Główna funkcja
async function findLatestSuccessfulRunWithArtifacts() {
try {
const runs = await getWorkflowRuns();
console.log(`Znaleziono ${runs.length} workflow runs`);
for (const run of runs) {
console.log(`Sprawdzam run ${run.id} (${run.name}): status=${run.status}, conclusion=${run.conclusion}`);
// Sprawdzaj tylko udane buildy
if (run.status === 'completed' && run.conclusion === 'success') {
console.log(`Run ${run.id} zakończony sukcesem, sprawdzam artefakty...`);
const artifacts = await getRunArtifacts(run.id);
const artifactNames = artifacts.map(a => a.name);
console.log(`Artefakty w run ${run.id}:`, artifactNames);
// Sprawdź czy wszystkie wymagane artefakty są dostępne
const hasAllRequiredArtifacts = config.requiredArtifacts.every(required =>
artifactNames.includes(required)
);
if (hasAllRequiredArtifacts) {
console.log(`✅ Znaleziono odpowiedni run: ${run.id}`);
return run.id;
} else {
const missing = config.requiredArtifacts.filter(req => !artifactNames.includes(req));
console.log(`❌ Run ${run.id} nie ma wszystkich wymaganych artefaktów. Brakuje: ${missing.join(', ')}`);
}
}
// Normalize artifact names (trim & case-sensitive match as in UI/API)
names = names.map(n => n.trim()).filter(Boolean);
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]}`);
continue;
}
}
if (!pickedId) {
console.error("No run exposes all required artifacts.");
throw new Error('Nie znaleziono żadnego udanego buildu z wymaganymi artefaktami');
} catch (error) {
console.error('Błąd:', error.message);
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`);
// Wykonanie skryptu
findLatestSuccessfulRunWithArtifacts().then(runId => {
console.log(`Ostatni udany build z artefaktami: ${runId}`);
// Utworzenie katalogu cache i zapisanie ID
const cacheDir = path.join('.gitea', '.cache');
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
console.log(`Picked run_id=${pickedId}`);
})().catch(err => {
console.error(err.stack || err.message || String(err));
fs.writeFileSync(path.join(cacheDir, 'run_id'), runId.toString());
console.log(`ID zapisane do .gitea/.cache/run_id`);
}).catch(error => {
console.error('Niepowodzenie:', error.message);
process.exit(1);
});