From 42d4b72dd657c7d38547cb533dfa66b772a3ab55 Mon Sep 17 00:00:00 2001 From: Marc Froehlich Date: Wed, 12 Aug 2026 01:11:34 +0200 Subject: [PATCH] website: prevent incomplete version-info deployments and regenerate validated update feeds after releases --- website/package.json | 4 +- website/scripts/validate-version-info.js | 280 +++++++++++++++++++++++ website/src/_data/versionInfo.js | 163 +++++++++++-- website/test/version-info.test.js | 93 ++++++++ 4 files changed, 519 insertions(+), 21 deletions(-) create mode 100644 website/scripts/validate-version-info.js create mode 100644 website/test/version-info.test.js diff --git a/website/package.json b/website/package.json index fdb38b9..e276cd6 100644 --- a/website/package.json +++ b/website/package.json @@ -4,7 +4,9 @@ "private": true, "scripts": { "start": "eleventy --serve", - "build": "eleventy" + "build": "eleventy && npm run validate:version-info", + "validate:version-info": "node scripts/validate-version-info.js", + "test": "node --test test/*.test.js" }, "devDependencies": { "@11ty/eleventy": "^3.0.0" diff --git a/website/scripts/validate-version-info.js b/website/scripts/validate-version-info.js new file mode 100644 index 0000000..73686fa --- /dev/null +++ b/website/scripts/validate-version-info.js @@ -0,0 +1,280 @@ +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_FILE = path.join( + __dirname, + "..", + "_site", + "kst4ContestVersionInfo.xml" +); + +function fail(message) { + throw new Error(`[versionInfo validation] ${message}`); +} + +function normaliseVersion(value) { + return String(value || "") + .trim() + .replace(/^v/, "") + .split("-")[0] + .split("+")[0]; +} + +function extractTag(xml, tagName) { + const match = xml.match( + new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`) + ); + + return match ? match[1].trim() : null; +} + +function assertWellFormedStructure(xml) { + if (/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-f]+;)/i.test(xml)) { + fail("the XML contains an unescaped ampersand"); + } + + const withoutCommentsAndDeclaration = xml + .replace(//g, "") + .replace(/<\?[\s\S]*?\?>/g, ""); + + const tagPattern = + /<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g; + + const stack = []; + let match; + + while ( + (match = tagPattern.exec(withoutCommentsAndDeclaration)) !== null + ) { + const fullTag = match[0]; + const tagName = match[1]; + + if (fullTag.startsWith(" does not match ` + + `<${openTag || "none"}>` + ); + } + } else if (!fullTag.endsWith("/>")) { + stack.push(tagName); + } + } + + if (stack.length > 0) { + fail(`unclosed tag <${stack[stack.length - 1]}>`); + } + + const remainingMarkup = + withoutCommentsAndDeclaration.replace(tagPattern, ""); + + // A plain ">" is legal character data and occurs in historical + // changelog notation such as "->". A remaining "<" cannot be legal + // after all valid tags have been removed. + if (/[\\s\\S]*?<\\/${tagName}>`); + + if (!pattern.test(block)) { + fail(`required element <${tagName}> is missing`); + } +} + +function validateVersionInfo(xml, expectedStableVersion = "") { + if (!xml || Buffer.byteLength(xml, "utf8") < 500) { + fail("the generated file is empty or implausibly small"); + } + + assertWellFormedStructure(xml); + + const completeDocumentPattern = + /^<\?xml[^>]*>\s*[\s\S]*<\/praktiKST>\s*$/; + + if (!completeDocumentPattern.test(xml)) { + fail( + "the document does not contain one complete " + + " root element" + ); + } + + const latestVersionBlock = extractTag(xml, "latestVersion"); + + if (latestVersionBlock === null) { + fail(" is missing"); + } + + for (const tagName of [ + "versionNumber", + "semanticVersion", + "adminMessage", + "majorChanges", + "latestVersionPathOnWebserver" + ]) { + assertContainsTag(latestVersionBlock, tagName); + } + + const legacyVersion = + extractTag(latestVersionBlock, "versionNumber"); + + const semanticVersion = + extractTag(latestVersionBlock, "semanticVersion"); + + const releaseUrl = + extractTag( + latestVersionBlock, + "latestVersionPathOnWebserver" + ); + + if ( + !legacyVersion + || !/^\d+(?:\.\d+)?$/.test(legacyVersion) + ) { + fail( + " is not a valid legacy numeric version" + ); + } + + if ( + !semanticVersion + || !/^\d+\.\d+(?:\.\d+)?$/.test(semanticVersion) + ) { + fail(" is not a valid Stable version"); + } + + if ( + !releaseUrl + || !releaseUrl.startsWith( + "https://github.com/praktimarc/" + + "kst4contest/releases/tag/" + ) + ) { + fail( + " is not " + + "a KST4Contest release URL" + ); + } + + const changeLogs = [ + ...xml.matchAll( + /([\s\S]*?)<\/changeLog>/g + ) + ].map((match) => match[1]); + + if (changeLogs.length === 0) { + fail( + "the document does not contain any " + + " entries" + ); + } + + for (const entry of changeLogs) { + for (const tagName of [ + "changedVersionNumber", + "date", + "description", + "added", + "changed", + "fixed", + "removed" + ]) { + assertContainsTag(entry, tagName); + } + } + + const expected = normaliseVersion(expectedStableVersion); + + if (expected) { + if (semanticVersion !== expected) { + fail( + `latest Stable version ${semanticVersion} ` + + `does not match expected release ${expected}` + ); + } + + const releaseEntryExists = changeLogs.some( + (entry) => + extractTag( + entry, + "changedVersionNumber" + ) === expected + ); + + if (!releaseEntryExists) { + fail( + "the changelog does not contain the expected " + + `Stable release ${expected}` + ); + } + } + + return { + semanticVersion, + changeLogEntries: changeLogs.length + }; +} + +function parseArguments(argv) { + const result = { + file: DEFAULT_FILE, + expectedStableVersion: + process.env.EXPECTED_STABLE_VERSION || "" + }; + + for (let index = 0; index < argv.length; index++) { + if ( + argv[index] === "--file" + && argv[index + 1] + ) { + result.file = path.resolve(argv[++index]); + } else if ( + argv[index] === "--expected-stable" + && argv[index + 1] + ) { + result.expectedStableVersion = argv[++index]; + } else { + fail( + `unknown or incomplete argument: ${argv[index]}` + ); + } + } + + return result; +} + +if (require.main === module) { + try { + const options = + parseArguments(process.argv.slice(2)); + + const xml = + fs.readFileSync(options.file, "utf8"); + + const result = + validateVersionInfo( + xml, + options.expectedStableVersion + ); + + console.log( + `[versionInfo validation] OK: ` + + `Stable ${result.semanticVersion}, ` + + `${result.changeLogEntries} changelog entries, ` + + options.file + ); + } catch (err) { + console.error(err.message); + process.exitCode = 1; + } +} + +module.exports = { + normaliseVersion, + validateVersionInfo +}; \ No newline at end of file diff --git a/website/src/_data/versionInfo.js b/website/src/_data/versionInfo.js index 6a624fd..c19ca8c 100644 --- a/website/src/_data/versionInfo.js +++ b/website/src/_data/versionInfo.js @@ -5,16 +5,93 @@ 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" }; + const headers = { + Accept: "application/vnd.github+json" + }; + if (process.env.GITHUB_TOKEN) { - headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + headers.Authorization = + `Bearer ${process.env.GITHUB_TOKEN}`; } - const res = await fetch(`${API}${urlPath}`, { headers }); - if (!res.ok) { - throw new Error(`GitHub API ${urlPath} failed: ${res.status}`); + + 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); + } } - return res.json(); + + throw lastError + || new Error(`GitHub API ${urlPath} failed`); } // UpdateChecker.java parses with Double.parseDouble() and @@ -108,25 +185,64 @@ function loadLegacySections() { // UpdateChecker.java) from GitHub releases + closed issues, falling back to // version-history.xml for releases that predate GitHub Releases. module.exports = async function () { - const fallback = '\n'; - try { const historyEntries = loadHistoryEntries(); - const rawReleases = await githubGet("/releases?per_page=100"); + 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((r) => ({ - tagName: r.tag_name, - publishedAt: r.published_at || "", - name: r.name || r.tag_name, - body: r.body || "", - isPrerelease: r.prerelease + .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((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1)); + .sort( + (first, second) => + first.publishedAt + < second.publishedAt + ? 1 + : -1 + ); + + const stableReleases = releases.filter( + (release) => + !release.isPrerelease + && !release.isDraft + ); - const stableReleases = releases.filter((r) => !r.isPrerelease); const stable = stableReleases[0]; - const ghVersions = new Set(stableReleases.map((r) => toAppVersionNumber(r.tagName))); + + 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(''); @@ -203,7 +319,14 @@ module.exports = async function () { parts.push(""); return parts.join("\n"); } catch (err) { - console.warn(`[versionInfo] Could not generate version info XML, using empty fallback: ${err.message}`); - return fallback; + 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 } + ); } }; diff --git a/website/test/version-info.test.js b/website/test/version-info.test.js new file mode 100644 index 0000000..3e09e86 --- /dev/null +++ b/website/test/version-info.test.js @@ -0,0 +1,93 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const generateVersionInfo = + require("../src/_data/versionInfo"); + +const { + validateVersionInfo +} = require("../scripts/validate-version-info"); + +const VALID_XML = + ` + + + 1.411 + 1.41.1 + + Hotfix + https://github.com/praktimarc/kst4contest/releases/tag/v1.41.1 + + + nothing + + + 1.41.1 + 2026-07-08 + Hotfix + + + Text input handling + + +`; + +test("accepts a complete update feed", () => { + const result = + validateVersionInfo(VALID_XML, "v1.41.1"); + + assert.equal(result.semanticVersion, "1.41.1"); + assert.equal(result.changeLogEntries, 1); +}); + +test("rejects the former empty fallback", () => { + assert.throws( + () => + validateVersionInfo( + '' + + "" + ), + /empty or implausibly small/ + ); +}); + +test( + "rejects a feed which does not contain " + + "the expected Stable release", + () => { + assert.throws( + () => + validateVersionInfo( + VALID_XML, + "v1.42.0" + ), + /does not match expected release/ + ); + } +); + +test( + "aborts generation when GitHub rejects " + + "the API request", + async () => { + const originalFetch = global.fetch; + + global.fetch = async () => ({ + ok: false, + status: 401, + headers: { + get: () => null + } + }); + + try { + await assert.rejects( + generateVersionInfo(), + /website build has been aborted.*HTTP 401/i + ); + } finally { + global.fetch = originalFetch; + } + } +); \ No newline at end of file