website: prevent incomplete version-info deployments and regenerate validated update feeds after releases

This commit is contained in:
Marc Froehlich
2026-08-12 01:11:34 +02:00
parent 15f585c938
commit 42d4b72dd6
4 changed files with 519 additions and 21 deletions
+3 -1
View File
@@ -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"
+280
View File
@@ -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(/<!--[\s\S]*?-->/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("</")) {
const openTag = stack.pop();
if (openTag !== tagName) {
fail(
`closing tag </${tagName}> 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 (/</.test(remainingMarkup)) {
fail("the XML contains malformed markup");
}
}
function assertContainsTag(block, tagName) {
const pattern =
new RegExp(`<${tagName}>[\\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*<praktiKST>[\s\S]*<\/praktiKST>\s*$/;
if (!completeDocumentPattern.test(xml)) {
fail(
"the document does not contain one complete "
+ "<praktiKST> root element"
);
}
const latestVersionBlock = extractTag(xml, "latestVersion");
if (latestVersionBlock === null) {
fail("<latestVersion> 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(
"<versionNumber> is not a valid legacy numeric version"
);
}
if (
!semanticVersion
|| !/^\d+\.\d+(?:\.\d+)?$/.test(semanticVersion)
) {
fail("<semanticVersion> is not a valid Stable version");
}
if (
!releaseUrl
|| !releaseUrl.startsWith(
"https://github.com/praktimarc/"
+ "kst4contest/releases/tag/"
)
) {
fail(
"<latestVersionPathOnWebserver> is not "
+ "a KST4Contest release URL"
);
}
const changeLogs = [
...xml.matchAll(
/<changeLog>([\s\S]*?)<\/changeLog>/g
)
].map((match) => match[1]);
if (changeLogs.length === 0) {
fail(
"the document does not contain any "
+ "<changeLog> 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
};
+143 -20
View File
@@ -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 <versionNumber> 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 = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<praktiKST></praktiKST>';
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('<?xml version="1.0" encoding="UTF-8" standalone="no"?>');
@@ -203,7 +319,14 @@ module.exports = async function () {
parts.push("</praktiKST>");
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 }
);
}
};
+93
View File
@@ -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 =
`<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<praktiKST>
<latestVersion>
<versionNumber>1.411</versionNumber>
<semanticVersion>1.41.1</semanticVersion>
<adminMessage></adminMessage>
<majorChanges>Hotfix</majorChanges>
<latestVersionPathOnWebserver>https://github.com/praktimarc/kst4contest/releases/tag/v1.41.1</latestVersionPathOnWebserver>
</latestVersion>
<needUpdateSinceLastVersion>
<filename>nothing</filename>
</needUpdateSinceLastVersion>
<changeLog>
<changedVersionNumber>1.41.1</changedVersionNumber>
<date>2026-07-08</date>
<description>Hotfix</description>
<added></added>
<changed></changed>
<fixed>Text input handling</fixed>
<removed></removed>
</changeLog>
</praktiKST>`;
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(
'<?xml version="1.0" '
+ 'encoding="UTF-8"?>'
+ "<praktiKST></praktiKST>"
),
/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;
}
}
);