const fs = require("fs"); const path = require("path"); const REPO = "praktimarc/kst4contest"; const API = `https://api.github.com/repos/${REPO}`; const LABEL_MAP = { enhancement: "added", bug: "fixed" }; const GITHUB_API_ATTEMPTS = 3; function wait(milliseconds) { return new Promise( (resolve) => setTimeout(resolve, milliseconds) ); } async function githubGet(urlPath) { const headers = { Accept: "application/vnd.github+json" }; if (process.env.GITHUB_TOKEN) { headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; } let lastError = null; for ( let attempt = 1; attempt <= GITHUB_API_ATTEMPTS; attempt++ ) { try { const res = await fetch( `${API}${urlPath}`, { headers, signal: AbortSignal.timeout(15000) } ); if (res.ok) { return res.json(); } const rateLimitRemaining = res.headers.get("x-ratelimit-remaining"); const rateLimitReset = res.headers.get("x-ratelimit-reset"); const rateLimitInfo = rateLimitRemaining === null ? "" : `, rate limit remaining ` + rateLimitRemaining + ( rateLimitReset ? `, reset ${rateLimitReset}` : "" ); lastError = new Error( `GitHub API ${urlPath} failed with ` + `HTTP ${res.status}${rateLimitInfo}` ); // Authentication and permission errors do not // become valid by retrying. if ( ![408, 429].includes(res.status) && res.status < 500 ) { throw lastError; } } catch (err) { lastError = err; // Do not hide an invalid or expired token behind // repeated requests. if ( /HTTP (401|403|404)/.test(err.message) ) { throw err; } } if (attempt < GITHUB_API_ATTEMPTS) { await wait(attempt * 500); } } throw lastError || new Error(`GitHub API ${urlPath} failed`); } // UpdateChecker.java parses with Double.parseDouble() and // compares it against ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER, // which encodes patch releases by appending the patch digit(s) instead of a // third dot (e.g. tag v1.41.1 -> app version 1.411, v1.41.0 -> 1.41). See // commit 38ef50c ("...set to 1.41.1 for upcoming hotfix" -> 1.411). function toAppVersionNumber(tag) { const cleaned = tag.replace(/^v/, ""); const [major, minor, ...rest] = cleaned.split("."); if (rest.length === 0) return cleaned; const patch = rest.join(""); return /^0*$/.test(patch) ? `${major}.${minor}` : `${major}.${minor}${patch}`; } function toSemanticVersion(tag) { return tag .replace(/^v/, "") .split("-")[0] .split("+")[0]; } function escapeXml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">"); } // Strips GitHub's auto-generated release-notes markdown noise (headings, // "Full Changelog" links, PR author attributions) down to plain text. function cleanReleaseBody(body) { const lines = []; for (let line of (body || "").split("\n")) { if (/^#{1,3}\s/.test(line)) continue; if (line.trim().startsWith("**Full Changelog**")) continue; line = line.replace(/\s+by @\S+ in https:\/\/\S+/, ""); line = line.replace(/^\* /, "- "); if (line.trim()) lines.push(line); } return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); } // Groups closed issues (excluding PRs) in the (since, until] window by // label into "added" (enhancement) / "fixed" (bug) bullet lists. async function getIssuesForRelease(since, until) { const buckets = { added: [], fixed: [] }; let page = 1; for (;;) { const items = await githubGet(`/issues?state=closed&per_page=100&page=${page}`); if (!items.length) break; for (const issue of items) { if (issue.pull_request) continue; const closed = issue.closed_at || ""; if (!(closed > since && closed <= until)) continue; const labels = issue.labels.map((l) => l.name); const bucket = Object.keys(LABEL_MAP).find((l) => labels.includes(l)); if (bucket) buckets[LABEL_MAP[bucket]].push(`- ${issue.title}`); } if (items.length < 100) break; page++; } return { added: buckets.added.join("\n"), fixed: buckets.fixed.join("\n") }; } // version-history.xml holds hand-written entries for releases // published before GitHub Releases existed; re-emitted verbatim here. function loadHistoryEntries() { const historyPath = path.join(__dirname, "version-history.xml"); const xml = fs.readFileSync(historyPath, "utf-8"); return [...xml.matchAll(/[\s\S]*?<\/changeLog>/g)].map((m) => m[0]); } function historyEntryVersion(entryXml) { const m = entryXml.match(/([\s\S]*?)<\/changedVersionNumber>/); return m ? m[1].trim() : null; } // Static / sections carried over from the previous // hand-maintained XML on do5amf.funkerportal.de. UpdateChecker.java doesn't // parse these, kept only so the migration doesn't drop existing content. function loadLegacySections() { const legacyPath = path.join(__dirname, "version-legacy-sections.xml"); const xml = fs.readFileSync(legacyPath, "utf-8"); const roadmap = xml.match(/[\s\S]*?<\/roadmap>/); const bugsReported = xml.match(/[\s\S]*?<\/bugsReported>/); return [roadmap ? roadmap[0] : null, bugsReported ? bugsReported[0] : null].filter(Boolean); } // Builds kst4ContestVersionInfo.xml (the update-checker feed read by // UpdateChecker.java) from GitHub releases + closed issues, falling back to // version-history.xml for releases that predate GitHub Releases. module.exports = async function () { try { const historyEntries = loadHistoryEntries(); const rawReleases = await githubGet("/releases?per_page=100"); if (!Array.isArray(rawReleases)) { throw new Error( "GitHub releases response was not an array" ); } const releases = rawReleases .map((release) => ({ tagName: release.tag_name, publishedAt: release.published_at || "", name: release.name || release.tag_name, body: release.body || "", isPrerelease: release.prerelease, isDraft: release.draft })) .sort( (first, second) => first.publishedAt < second.publishedAt ? 1 : -1 ); const stableReleases = releases.filter( (release) => !release.isPrerelease && !release.isDraft ); const stable = stableReleases[0]; if ( !stable || !stable.tagName || !stable.publishedAt ) { throw new Error( "GitHub did not return " + "a published Stable release" ); } const ghVersions = new Set( stableReleases.map( (release) => toAppVersionNumber( release.tagName ) ) ); const parts = []; parts.push(''); parts.push(""); const latestTag = stable ? stable.tagName : null; parts.push(" "); if (stable) { const latestIssues = await getIssuesForRelease( "1970-01-01T00:00:00Z", stable.publishedAt ); // Kept for application versions that still expect the old numeric format. parts.push( ` ${escapeXml(toAppVersionNumber(latestTag))}` ); // Used by current application versions for correct semantic comparison // and display, for example 1.41.1 instead of 1.411. parts.push( ` ${escapeXml(toSemanticVersion(latestTag))}` ); parts.push(" "); parts.push( ` ${escapeXml(latestIssues.added.slice(0, 300))}` ); // The release page is deliberately platform-neutral. The application // cannot know whether the user needs Windows, Linux or macOS packages. parts.push( ` https://github.com/${REPO}/releases/tag/${latestTag}` ); } parts.push(" "); // UpdateChecker.parseUpdateXMLFile() never actually reads this field // (setNeedUpdateResourcesSinceLastVersion is never called); the // previous server always hardcoded "nothing" here, so we match that. parts.push(" "); parts.push(" nothing"); parts.push(" "); for (const section of loadLegacySections()) { parts.push(section); } for (let i = 0; i < stableReleases.length; i++) { const rel = stableReleases[i]; const until = rel.publishedAt; const since = stableReleases[i + 1] ? stableReleases[i + 1].publishedAt : "1970-01-01T00:00:00Z"; const issues = await getIssuesForRelease(since, until); parts.push(" "); parts.push(` ${escapeXml(toSemanticVersion(rel.tagName))}`); parts.push(` ${escapeXml(until.slice(0, 10))}`); parts.push(` ${escapeXml(rel.name)}`); parts.push(` ${escapeXml(issues.added)}`); parts.push(` ${escapeXml(cleanReleaseBody(rel.body))}`); parts.push(` ${escapeXml(issues.fixed)}`); parts.push(" "); parts.push(" "); } for (const entry of historyEntries) { const version = historyEntryVersion(entry); if (version && ghVersions.has(version)) continue; parts.push(entry); } parts.push(""); return parts.join("\n"); } catch (err) { throw new Error( "[versionInfo] Could not generate " + "a complete update feed. " + "The website build has been aborted " + "so that the existing production feed " + "remains untouched: " + err.message, { cause: err } ); } };