From 2d76b4e9e7254a89fe613e4c41a859bdca207e8e Mon Sep 17 00:00:00 2001 From: LennarX Date: Mon, 20 Jul 2026 20:37:46 +0200 Subject: [PATCH 01/16] feat: serve the flashtrace JSON Schemas verbatim Copies the tool repo's schemas/ into dist/ byte-for-byte and adds a generated latest. alias per schema directory. The files never touch the markdown pipeline: consumers fetch them by $id, and the docs link rewriter would corrupt the identifiers ($id, $ref, $schema) inside them. latest.json is a real file rather than a redirect - GitHub Pages has no server-side redirects and a meta-refresh means nothing to a JSON fetch. Its $id is not rewritten, so a copy fetched from latest.json still identifies the version it actually is. schemas/ is absent until the release that introduces it, so a missing folder only warns; CI and deploys build from the latest published release and must keep working. Once the folder exists, anything wrong inside it is fatal. Co-Authored-By: Claude Opus 4.8 --- build.mjs | 41 +++++++++++++++++++++++++++ src/schemas.mjs | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/schemas.mjs diff --git a/build.mjs b/build.mjs index ed897f0..e550f0f 100644 --- a/build.mjs +++ b/build.mjs @@ -11,6 +11,7 @@ import { docShell, esc, EXT_ATTRS, GITHUB_URL, highlightTokens, SITE_URL } from import { renderLanding } from './src/landing.mjs'; import { renderImpressum } from './src/impressum.mjs'; import { renderLicense } from './src/license.mjs'; +import { collectSchemas, locateSchemas } from './src/schemas.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); const dist = path.join(root, 'dist'); @@ -42,6 +43,28 @@ if (!existsSync(licensePath)) { } const licenseText = readFileSync(licensePath, 'utf8'); +// --- schemas: published schemas, sitting next to docs/ in the tool repo ------ + +// Absent until the release that introduces schemas/, so a missing folder only +// warns - the rest of the site must keep deploying against older releases. +// Once the folder exists, anything wrong with its contents is fatal. +const schemasDir = locateSchemas(docsDir); +let schemas = []; +if (schemasDir) { + try { + schemas = collectSchemas(schemasDir); + } catch (err) { + console.error(`error: ${err.message}`); + process.exit(1); + } + if (schemas.length === 0) { + console.error(`error: ${schemasDir} holds no v. files - did the layout change?`); + process.exit(1); + } +} else { + console.warn('warn: no schemas/ in the flashtrace checkout - skipping /schemas/.'); +} + // --- version: release tag from env, else the tool repo's package.json ------ function readVersion() { @@ -193,6 +216,24 @@ for (const page of pages) { writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); } +// Schemas are machine-consumed artifacts, not pages: copied byte-for-byte and +// never through the markdown pipeline, whose link rewriting would corrupt the +// identifiers ($id, $ref, $schema) inside them. +if (schemasDir) { + cpSync(schemasDir, path.join(dist, 'schemas'), { recursive: true }); + for (const schema of schemas) { + // A real file, not a redirect - GitHub Pages has no server-side redirects + // and a meta-refresh means nothing to a JSON fetch. The bytes are the + // highest version's verbatim, $id included: a copy fetched from + // latest.json must still say which version it actually is. One alias per + // format, so a v1.xml would get latest.xml alongside latest.json. + writeFileSync( + path.join(dist, 'schemas', schema.name, `latest.${schema.format}`), + schema.latest.bytes, + ); + } +} + const sitePaths = [ '/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), diff --git a/src/schemas.mjs b/src/schemas.mjs new file mode 100644 index 0000000..5b5f9f9 --- /dev/null +++ b/src/schemas.mjs @@ -0,0 +1,75 @@ +// Discovery of the machine-readable JSON Schemas the tool repo publishes under +// schemas/. The files are served verbatim - consumers fetch them by $id, so +// the bytes on the site must match the release exactly. Nothing here rewrites +// a schema; the JSON is parsed only to reject a release that ships a broken +// one, since the URL is a published contract. +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +// schemas//v. - the tool repo mirrors the URL layout it is +// served at, so the on-disk path is also the site path. The format is read off +// the extension rather than assumed, so a future schemas/report/v1.xml needs no +// change here. +const VERSION_FILE = /^v(\d+)\.([A-Za-z0-9]+)$/; + +// Sits next to docs/ in the tool repo, like LICENSE does. Absent until the +// release that introduces it, so the caller decides whether that is fatal. +export function locateSchemas(docsDir) { + const dir = path.join(docsDir, '..', 'schemas'); + return existsSync(dir) ? dir : null; +} + +// One entry per (directory, format) pair, so a directory that ever holds both +// v1.json and v1.xml keeps two independent version lines - and two latest.* +// aliases - rather than one muddled one. Returns +// [{ name, format, versions: [{ version, file, bytes, json }], latest }]. +export function collectSchemas(schemasDir) { + const out = []; + walk(schemasDir, schemasDir, out); + out.sort((a, b) => (a.name === b.name ? a.format.localeCompare(b.format) : a.name.localeCompare(b.name))); + return out; +} + +function walk(dir, rootDir, out) { + const byFormat = new Map(); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(rootDir, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + walk(full, rootDir, out); + continue; + } + // latest. is ours to generate; upstream shipping one would mean + // two sources disagree about which version is current. + if (/^latest\.[A-Za-z0-9]+$/.test(entry.name)) { + throw new Error( + `${rel} exists upstream, but the site generates ${entry.name} itself. ` + + 'Remove it upstream or drop the generation here - do not ship both.', + ); + } + const m = VERSION_FILE.exec(entry.name); + if (!m) continue; // non-version files are still copied verbatim, just not aliased + const [, num, ext] = m; + const format = ext.toLowerCase(); + const bytes = readFileSync(full); + // Only the JSON documents are parsed - another format is served verbatim + // and this build has no opinion on its contents. + let json; + if (format === 'json') { + try { + json = JSON.parse(bytes.toString('utf8')); + } catch (err) { + throw new Error(`${rel} is not valid JSON: ${err.message}`); + } + } + if (!byFormat.has(format)) byFormat.set(format, []); + byFormat.get(format).push({ version: Number(num), file: entry.name, bytes, json }); + } + + const name = path.relative(rootDir, dir).split(path.sep).join('/'); + for (const [format, versions] of byFormat) { + // Numeric, not lexical: v10 must sort above v9. + versions.sort((a, b) => a.version - b.version); + out.push({ name, format, versions, latest: versions.at(-1) }); + } +} From 1dc9aafaa2aa402d0b6d376e69d2cf838c65bbd8 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 00:00:20 +0200 Subject: [PATCH 02/16] refactor: restrict served schemas to the schemas//v.json layout Discovery walked schemas/ to any depth, accepted any extension, and the build then cpSync'd the whole folder into dist/. That published whatever upstream happened to put there - a README meant for repo readers, a symlink, a format nobody had decided to support - and left the site serving paths it had never validated. Discovery now recognises exactly schemas//v., one directory deep, with an explicit format allowlist that holds only json today. The build writes the files discovery accepted instead of copying the folder, so the site publishes the layout it checked and nothing else. Everything found and not served is recorded as a problem, distinguishing files that are plainly local to the tool repo (README, dotfiles, which stay silent) from names that were reaching for the version layout and missed (v1.2.json, report-v0.json, a symlink where a schema belongs). A typo that unpublishes a schema is otherwise indistinguishable from a file that was never meant to be one. Problems remain fatal for now; the next commit makes them survivable. An empty schemas/ no longer fails - the folder can land upstream a release before the first schema inside it does. Co-Authored-By: Claude Opus 4.8 --- build.mjs | 41 ++++++++-------- src/schemas.mjs | 126 ++++++++++++++++++++++++++++++++++++------------ 2 files changed, 117 insertions(+), 50 deletions(-) diff --git a/build.mjs b/build.mjs index e550f0f..3624864 100644 --- a/build.mjs +++ b/build.mjs @@ -50,16 +50,20 @@ const licenseText = readFileSync(licensePath, 'utf8'); // Once the folder exists, anything wrong with its contents is fatal. const schemasDir = locateSchemas(docsDir); let schemas = []; +let schemaProblems = []; if (schemasDir) { try { - schemas = collectSchemas(schemasDir); + ({ schemas, problems: schemaProblems } = collectSchemas(schemasDir)); } catch (err) { - console.error(`error: ${err.message}`); + console.error(`error: could not read ${schemasDir}: ${err.message}`); process.exit(1); } + for (const p of schemaProblems) console.error(`error: schemas/${p.path} ${p.reason}`); + if (schemaProblems.length > 0) process.exit(1); + // An empty folder is not a failure: schemas/ can land upstream a release + // before the first schema inside it does. if (schemas.length === 0) { - console.error(`error: ${schemasDir} holds no v. files - did the layout change?`); - process.exit(1); + console.warn(`warn: ${schemasDir} holds no v.json files - nothing to serve under /schemas/.`); } } else { console.warn('warn: no schemas/ in the flashtrace checkout - skipping /schemas/.'); @@ -216,22 +220,21 @@ for (const page of pages) { writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); } -// Schemas are machine-consumed artifacts, not pages: copied byte-for-byte and +// Schemas are machine-consumed artifacts, not pages: written byte-for-byte and // never through the markdown pipeline, whose link rewriting would corrupt the -// identifiers ($id, $ref, $schema) inside them. -if (schemasDir) { - cpSync(schemasDir, path.join(dist, 'schemas'), { recursive: true }); - for (const schema of schemas) { - // A real file, not a redirect - GitHub Pages has no server-side redirects - // and a meta-refresh means nothing to a JSON fetch. The bytes are the - // highest version's verbatim, $id included: a copy fetched from - // latest.json must still say which version it actually is. One alias per - // format, so a v1.xml would get latest.xml alongside latest.json. - writeFileSync( - path.join(dist, 'schemas', schema.name, `latest.${schema.format}`), - schema.latest.bytes, - ); - } +// identifiers ($id, $ref, $schema) inside them. Only the files discovery +// accepted are published - the site serves the layout it validated, not +// whatever else the folder happens to contain. +for (const schema of schemas) { + const dir = path.join(dist, 'schemas', schema.name); + mkdirSync(dir, { recursive: true }); + for (const version of schema.versions) writeFileSync(path.join(dir, version.file), version.bytes); + // A real file, not a redirect - GitHub Pages has no server-side redirects + // and a meta-refresh means nothing to a JSON fetch. The bytes are the + // highest version's verbatim, $id included: a copy fetched from latest.json + // must still say which version it actually is. One alias per format, so a + // v1.xml would get latest.xml alongside latest.json. + writeFileSync(path.join(dir, `latest.${schema.format}`), schema.latest.bytes); } const sitePaths = [ diff --git a/src/schemas.mjs b/src/schemas.mjs index 5b5f9f9..cb7165f 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -1,17 +1,41 @@ // Discovery of the machine-readable JSON Schemas the tool repo publishes under // schemas/. The files are served verbatim - consumers fetch them by $id, so // the bytes on the site must match the release exactly. Nothing here rewrites -// a schema; the JSON is parsed only to reject a release that ships a broken +// a schema; the JSON is parsed only to catch a release that ships a broken // one, since the URL is a published contract. +// +// Discovery reports rather than throws: a file it cannot serve is recorded as +// a problem and left out of the result, so the caller can decide whether one +// bad file is worth more than the schemas that are fine. import { existsSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; -// schemas//v. - the tool repo mirrors the URL layout it is -// served at, so the on-disk path is also the site path. The format is read off -// the extension rather than assumed, so a future schemas/report/v1.xml needs no -// change here. +// schemas//v., exactly one directory deep. The tool repo +// mirrors the URL layout it is served at, so the on-disk path is also the site +// path - which stays true only while the site refuses to serve anything else. const VERSION_FILE = /^v(\d+)\.([A-Za-z0-9]+)$/; +// latest. is ours to generate from the highest version, so an upstream +// copy is ignored rather than served: two sources disagreeing about which +// version is current is the one thing the alias exists to prevent. +const LATEST_FILE = /^latest\.[A-Za-z0-9]+$/i; + +// Serving a format means having decided how it is validated here and how the +// docs generator renders it, so the list is explicit rather than "whatever +// turns up". Adding 'xml' is a one-word change once a release ships one. +const ALLOWED_FORMATS = new Set(['json']); + +// What a schemas/ folder legitimately holds for the tool repo's own sake: a +// README explaining the folder to someone reading that repo, editor dotfiles, +// a licence note. Not served, and not worth mentioning either. +const LOCAL_FILE = /^(?:\.|LICENSE)|\.md$/i; + +// A name that was reaching for the version layout and missed - v1.2.json, +// V0.json, report-v0.json, v0.json.bak. Worth reporting, because from here a +// typo that quietly unpublishes a schema looks exactly like a file that was +// never meant to be one. +const NEAR_MISS = /v\d/i; + // Sits next to docs/ in the tool repo, like LICENSE does. Absent until the // release that introduces it, so the caller decides whether that is fatal. export function locateSchemas(docsDir) { @@ -19,57 +43,97 @@ export function locateSchemas(docsDir) { return existsSync(dir) ? dir : null; } -// One entry per (directory, format) pair, so a directory that ever holds both -// v1.json and v1.xml keeps two independent version lines - and two latest.* -// aliases - rather than one muddled one. Returns -// [{ name, format, versions: [{ version, file, bytes, json }], latest }]. +// Returns { schemas, problems }. schemas is one entry per (folder, format) +// pair, so a folder that ever holds both v1.json and v1.xml keeps two +// independent version lines - and two latest.* aliases - rather than one +// muddled one. problems is [{ path, reason }] naming every file that was found +// and not served. export function collectSchemas(schemasDir) { - const out = []; - walk(schemasDir, schemasDir, out); - out.sort((a, b) => (a.name === b.name ? a.format.localeCompare(b.format) : a.name.localeCompare(b.name))); - return out; + const schemas = []; + const problems = []; + + for (const entry of readdirSync(schemasDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + collectSchemaDir(path.join(schemasDir, entry.name), entry.name, schemas, problems); + continue; + } + // A loose file at the top level is never served: the layout puts every + // schema under a folder that names it. + if (!LOCAL_FILE.test(entry.name) && NEAR_MISS.test(entry.name)) { + problems.push({ + path: entry.name, + reason: 'sits outside a schemas// folder, so it is not served', + }); + } + } + + schemas.sort((a, b) => + a.name === b.name ? a.format.localeCompare(b.format) : a.name.localeCompare(b.name), + ); + problems.sort((a, b) => a.path.localeCompare(b.path)); + return { schemas, problems }; } -function walk(dir, rootDir, out) { +function collectSchemaDir(dir, name, schemas, problems) { const byFormat = new Map(); + for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - const rel = path.relative(rootDir, full).split(path.sep).join('/'); + const rel = `${name}/${entry.name}`; + const problem = (reason) => problems.push({ path: rel, reason }); + if (entry.isDirectory()) { - walk(full, rootDir, out); + problem('is nested deeper than schemas//, so nothing inside it is served'); continue; } - // latest. is ours to generate; upstream shipping one would mean - // two sources disagree about which version is current. - if (/^latest\.[A-Za-z0-9]+$/.test(entry.name)) { - throw new Error( - `${rel} exists upstream, but the site generates ${entry.name} itself. ` + - 'Remove it upstream or drop the generation here - do not ship both.', - ); + // withFileTypes reports a link as a link, so this never reaches the copy - + // a symlink would survive the build but not the Pages artifact. + if (entry.isSymbolicLink()) { + problem('is a symlink, and schemas are published as real files, so it is not served'); + continue; + } + if (LATEST_FILE.test(entry.name)) { + problem('is generated by the site from the highest version, so this copy is ignored'); + continue; } + const m = VERSION_FILE.exec(entry.name); - if (!m) continue; // non-version files are still copied verbatim, just not aliased + if (!m) { + if (!LOCAL_FILE.test(entry.name) && NEAR_MISS.test(entry.name)) { + problem('does not match v., so it is not served'); + } + continue; + } + const [, num, ext] = m; const format = ext.toLowerCase(); - const bytes = readFileSync(full); - // Only the JSON documents are parsed - another format is served verbatim - // and this build has no opinion on its contents. + if (!ALLOWED_FORMATS.has(format)) { + problem(`is .${format}, and the site only serves ${[...ALLOWED_FORMATS].join(', ')}`); + continue; + } + + const bytes = readFileSync(path.join(dir, entry.name)); + // Only the JSON documents are parsed - another format, once allowed, is + // served verbatim and this build has no opinion on its contents. let json; if (format === 'json') { try { json = JSON.parse(bytes.toString('utf8')); } catch (err) { - throw new Error(`${rel} is not valid JSON: ${err.message}`); + problem(`is not valid JSON: ${err.message}`); + continue; } } + if (!byFormat.has(format)) byFormat.set(format, []); byFormat.get(format).push({ version: Number(num), file: entry.name, bytes, json }); } - const name = path.relative(rootDir, dir).split(path.sep).join('/'); for (const [format, versions] of byFormat) { // Numeric, not lexical: v10 must sort above v9. versions.sort((a, b) => a.version - b.version); - out.push({ name, format, versions, latest: versions.at(-1) }); + // latest follows the highest version that survived validation, so a broken + // v1 leaves the alias on v0 rather than on nothing. Serving the last good + // version is the lesser wrong, and the problem report says why it happened. + schemas.push({ name, format, versions, latest: versions.at(-1) }); } } From 6c836a0546cf575e9334e77c56efef61382dd936 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 00:01:28 +0200 Subject: [PATCH 03/16] feat: keep deploying the schemas that are fine A single unservable file under schemas/ aborted the build. Since both CI and deploy build from the newest flashtrace release, that meant one bad schema in a release stopped the site from deploying at all - docs included - and left it stale until upstream cut a fix. The blast radius was wrong. Refusing to publish a broken schema is right; refusing to publish the docs because a schema is broken is not. Skipped files are now warnings, the build finishes, and everything that validated still ships. Quiet skipping would be its own failure, so the build writes the skips to schema-problems.json (gitignored, outside dist/, not published). Written on every build including a clean one, so the workflow reading it can tell "nothing wrong" from "never got that far". Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + build.mjs | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 06ad4d5..8abcbba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ flashtrace/ +schema-problems.json diff --git a/build.mjs b/build.mjs index 3624864..ed97468 100644 --- a/build.mjs +++ b/build.mjs @@ -15,6 +15,7 @@ import { collectSchemas, locateSchemas } from './src/schemas.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); const dist = path.join(root, 'dist'); +const schemaProblemsPath = path.join(root, 'schema-problems.json'); // --- locate the tool repo's docs (env → CI checkout → local sibling) ------- @@ -47,7 +48,12 @@ const licenseText = readFileSync(licensePath, 'utf8'); // Absent until the release that introduces schemas/, so a missing folder only // warns - the rest of the site must keep deploying against older releases. -// Once the folder exists, anything wrong with its contents is fatal. +// +// A file the site cannot serve is skipped rather than fatal, and for the same +// reason: one broken schema must not cost the deploy of every other schema, +// nor of the docs, which have nothing to do with it. Skipping quietly would be +// the wrong trade though - the skips are written to schemaProblemsPath below, +// and CI turns that into a tracking issue. const schemasDir = locateSchemas(docsDir); let schemas = []; let schemaProblems = []; @@ -58,9 +64,8 @@ if (schemasDir) { console.error(`error: could not read ${schemasDir}: ${err.message}`); process.exit(1); } - for (const p of schemaProblems) console.error(`error: schemas/${p.path} ${p.reason}`); - if (schemaProblems.length > 0) process.exit(1); - // An empty folder is not a failure: schemas/ can land upstream a release + for (const p of schemaProblems) console.warn(`warn: schemas/${p.path} ${p.reason}`); + // An empty folder is not a problem: schemas/ can land upstream a release // before the first schema inside it does. if (schemas.length === 0) { console.warn(`warn: ${schemasDir} holds no v.json files - nothing to serve under /schemas/.`); @@ -237,6 +242,14 @@ for (const schema of schemas) { writeFileSync(path.join(dir, `latest.${schema.format}`), schema.latest.bytes); } +// Outside dist/ - a build artifact for CI to read, not something to publish. +// Written on every build, including a clean one: "no problems" has to be a +// statement the workflow can act on, or it could never close a stale issue. +writeFileSync( + schemaProblemsPath, + `${JSON.stringify({ version, schemas: schemas.length, problems: schemaProblems }, null, 2)}\n`, +); + const sitePaths = [ '/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), From 07a25612fcb7e40953827f0e2dc54e4a199cebc9 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 00:01:45 +0200 Subject: [PATCH 04/16] fix: name the schema file an unreadable read came from A readFileSync failure inside discovery surfaced as a bare `error: EACCES ...` with nothing tying it to schemas/, leaving the reader to work out which file it meant. It is now reported like every other skip, naming the file, and the build carries on - an unreadable schema is indistinguishable from a broken one from a visitor's side, so it deserves the same treatment. Co-Authored-By: Claude Opus 4.8 --- src/schemas.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/schemas.mjs b/src/schemas.mjs index cb7165f..2520db2 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -111,7 +111,17 @@ function collectSchemaDir(dir, name, schemas, problems) { continue; } - const bytes = readFileSync(path.join(dir, entry.name)); + // An unreadable file is one more thing to skip, not a crash: it reads the + // same to a visitor as a broken one, and the report should say which file + // rather than leaving a bare EACCES to be traced back by hand. + let bytes; + try { + bytes = readFileSync(path.join(dir, entry.name)); + } catch (err) { + problem(`could not be read: ${err.message}`); + continue; + } + // Only the JSON documents are parsed - another format, once allowed, is // served verbatim and this build has no opinion on its contents. let json; From 732173b388a4eaeea28a5c3f185d33b33ec7d8c7 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 00:02:31 +0200 Subject: [PATCH 05/16] refactor: drop the parsed JSON from schema entries Every version entry carried the parsed document alongside its bytes, and nothing in this PR read it - the site serves bytes and never a re-serialisation. The parse itself stays: it is what catches a release shipping a schema that will not parse, and that check is the reason the URL can be treated as a contract. Only the result is discarded, so there is no second representation of a schema able to drift from the one being served. Note for #10, which is stacked on this branch: src/schema-doc.mjs reads versions[].json to render its page. It should parse the bytes where it needs the document, keeping the parsed form local to the consumer that wants it. Co-Authored-By: Claude Opus 4.8 --- src/schemas.mjs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/schemas.mjs b/src/schemas.mjs index 2520db2..cf8ca65 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -7,6 +7,10 @@ // Discovery reports rather than throws: a file it cannot serve is recorded as // a problem and left out of the result, so the caller can decide whether one // bad file is worth more than the schemas that are fine. +// +// The parsed JSON is deliberately not kept - a consumer that needs the +// document should parse the bytes it is given, so there is no second +// representation to fall out of step with what is served. import { existsSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; @@ -122,12 +126,13 @@ function collectSchemaDir(dir, name, schemas, problems) { continue; } - // Only the JSON documents are parsed - another format, once allowed, is - // served verbatim and this build has no opinion on its contents. - let json; + // Parsed to find out whether it parses, and for nothing else: the result + // is discarded because the site serves bytes, not a re-serialisation. + // Only the JSON documents - another format, once allowed, is served + // verbatim and this build has no opinion on its contents. if (format === 'json') { try { - json = JSON.parse(bytes.toString('utf8')); + JSON.parse(bytes.toString('utf8')); } catch (err) { problem(`is not valid JSON: ${err.message}`); continue; @@ -135,7 +140,7 @@ function collectSchemaDir(dir, name, schemas, problems) { } if (!byFormat.has(format)) byFormat.set(format, []); - byFormat.get(format).push({ version: Number(num), file: entry.name, bytes, json }); + byFormat.get(format).push({ version: Number(num), file: entry.name, bytes }); } for (const [format, versions] of byFormat) { From 0882cbd7aaec27cc632d6495d9138eddb37bddd6 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 00:03:24 +0200 Subject: [PATCH 06/16] refactor: split entry classification out of the schema folder walk collectSchemaDir decided what every entry was and accumulated the result in one loop, reaching a cognitive complexity of 25 against a limit of 15. The two jobs are separable: classifying an entry needs nothing but the entry, and grouping needs nothing but the classification. classifyEntry now answers with exactly one of a version to serve, a problem to report, or null for a file that is the tool repo's own business, which leaves the walk short enough to read at a glance. No behaviour change - the synthetic checkout reports the same six problems and serves the same five files. Also drops the redundant A-Z half of the case-insensitive latest. character class. Co-Authored-By: Claude Opus 4.8 --- src/schemas.mjs | 120 +++++++++++++++++++++++++----------------------- 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/src/schemas.mjs b/src/schemas.mjs index cf8ca65..b50ab35 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -22,7 +22,7 @@ const VERSION_FILE = /^v(\d+)\.([A-Za-z0-9]+)$/; // latest. is ours to generate from the highest version, so an upstream // copy is ignored rather than served: two sources disagreeing about which // version is current is the one thing the alias exists to prevent. -const LATEST_FILE = /^latest\.[A-Za-z0-9]+$/i; +const LATEST_FILE = /^latest\.[a-z0-9]+$/i; // Serving a format means having decided how it is validated here and how the // docs generator renders it, so the list is explicit rather than "whatever @@ -82,65 +82,14 @@ function collectSchemaDir(dir, name, schemas, problems) { const byFormat = new Map(); for (const entry of readdirSync(dir, { withFileTypes: true })) { - const rel = `${name}/${entry.name}`; - const problem = (reason) => problems.push({ path: rel, reason }); - - if (entry.isDirectory()) { - problem('is nested deeper than schemas//, so nothing inside it is served'); - continue; - } - // withFileTypes reports a link as a link, so this never reaches the copy - - // a symlink would survive the build but not the Pages artifact. - if (entry.isSymbolicLink()) { - problem('is a symlink, and schemas are published as real files, so it is not served'); - continue; - } - if (LATEST_FILE.test(entry.name)) { - problem('is generated by the site from the highest version, so this copy is ignored'); + const found = classifyEntry(dir, entry); + if (!found) continue; // local to the tool repo; not ours to serve or judge + if (found.problem) { + problems.push({ path: `${name}/${entry.name}`, reason: found.problem }); continue; } - - const m = VERSION_FILE.exec(entry.name); - if (!m) { - if (!LOCAL_FILE.test(entry.name) && NEAR_MISS.test(entry.name)) { - problem('does not match v., so it is not served'); - } - continue; - } - - const [, num, ext] = m; - const format = ext.toLowerCase(); - if (!ALLOWED_FORMATS.has(format)) { - problem(`is .${format}, and the site only serves ${[...ALLOWED_FORMATS].join(', ')}`); - continue; - } - - // An unreadable file is one more thing to skip, not a crash: it reads the - // same to a visitor as a broken one, and the report should say which file - // rather than leaving a bare EACCES to be traced back by hand. - let bytes; - try { - bytes = readFileSync(path.join(dir, entry.name)); - } catch (err) { - problem(`could not be read: ${err.message}`); - continue; - } - - // Parsed to find out whether it parses, and for nothing else: the result - // is discarded because the site serves bytes, not a re-serialisation. - // Only the JSON documents - another format, once allowed, is served - // verbatim and this build has no opinion on its contents. - if (format === 'json') { - try { - JSON.parse(bytes.toString('utf8')); - } catch (err) { - problem(`is not valid JSON: ${err.message}`); - continue; - } - } - - if (!byFormat.has(format)) byFormat.set(format, []); - byFormat.get(format).push({ version: Number(num), file: entry.name, bytes }); + if (!byFormat.has(found.format)) byFormat.set(found.format, []); + byFormat.get(found.format).push(found.version); } for (const [format, versions] of byFormat) { @@ -152,3 +101,58 @@ function collectSchemaDir(dir, name, schemas, problems) { schemas.push({ name, format, versions, latest: versions.at(-1) }); } } + +// What a single entry inside schemas// turns out to be. Exactly one of: +// a { format, version } to serve, a { problem } to report, or null for a file +// that is plainly the tool repo's own and no business of the site's. +function classifyEntry(dir, entry) { + const problem = (reason) => ({ problem: reason }); + + if (entry.isDirectory()) { + return problem('is nested deeper than schemas//, so nothing inside it is served'); + } + // withFileTypes reports a link as a link, so this never reaches the copy - + // a symlink would survive the build but not the Pages artifact. + if (entry.isSymbolicLink()) { + return problem('is a symlink, and schemas are published as real files, so it is not served'); + } + if (LATEST_FILE.test(entry.name)) { + return problem('is generated by the site from the highest version, so this copy is ignored'); + } + + const m = VERSION_FILE.exec(entry.name); + if (!m) { + if (LOCAL_FILE.test(entry.name) || !NEAR_MISS.test(entry.name)) return null; + return problem('does not match v., so it is not served'); + } + + const [, num, ext] = m; + const format = ext.toLowerCase(); + if (!ALLOWED_FORMATS.has(format)) { + return problem(`is .${format}, and the site only serves ${[...ALLOWED_FORMATS].join(', ')}`); + } + + // An unreadable file is one more thing to skip, not a crash: it reads the + // same to a visitor as a broken one, and the report should say which file + // rather than leaving a bare EACCES to be traced back by hand. + let bytes; + try { + bytes = readFileSync(path.join(dir, entry.name)); + } catch (err) { + return problem(`could not be read: ${err.message}`); + } + + // Parsed to find out whether it parses, and for nothing else: the result is + // discarded because the site serves bytes, not a re-serialisation. Only the + // JSON documents - another format, once allowed, is served verbatim and this + // build has no opinion on its contents. + if (format === 'json') { + try { + JSON.parse(bytes.toString('utf8')); + } catch (err) { + return problem(`is not valid JSON: ${err.message}`); + } + } + + return { format, version: { version: Number(num), file: entry.name, bytes } }; +} From 6e625d2187e6cc359c682d799e2f3d48976e854c Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 00:12:52 +0200 Subject: [PATCH 07/16] ci: raise a tracking issue when a schema is not being served Skipping a broken schema keeps the site deploying, but a skip nobody sees is just a silent outage of that schema - and a green deploy is exactly where nobody looks. CI log output alone does not carry: the release that breaks a schema is usually not the one anyone is watching. Every run annotates each skipped file. Deploys additionally maintain one tracking issue: opened on the first bad build, commented when the problem set changes, silent when it has not, and closed automatically on the first clean build. Deduplicated by a fingerprint of the problem set carried in an HTML comment, so a persistent problem across ten deploys is one issue with one comment rather than ten issues. PR builds annotate but never file - a PR should not open an issue about upstream content it did not touch. The step is continue-on-error: reporting a problem must never become a worse problem than the one being reported, which is the whole premise of not failing the build in the first place. report() takes gh as a parameter and the entry point sits behind an argv guard, so the decision logic can be driven end to end against a fake with no path to the real CLI. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/report-schema-problems.mjs | 120 +++++++++++++++++++++ .github/workflows/ci.yml | 9 ++ .github/workflows/deploy.yml | 14 +++ 3 files changed, 143 insertions(+) create mode 100644 .github/scripts/report-schema-problems.mjs diff --git a/.github/scripts/report-schema-problems.mjs b/.github/scripts/report-schema-problems.mjs new file mode 100644 index 0000000..5cadf38 --- /dev/null +++ b/.github/scripts/report-schema-problems.mjs @@ -0,0 +1,120 @@ +// Turns the build's schema-problems.json into something a person will notice. +// +// A file the site cannot serve is skipped rather than fatal (see build.mjs), so +// a broken schema otherwise leaves no trace in a green deploy. This closes that +// gap: run annotations always, plus - where the caller sets REPORT_ISSUE=1 and +// the workflow grants issues:write - a single tracking issue that opens itself +// on the first bad build, updates when the problem set changes, stays quiet +// when it has not, and closes itself once the build is clean again. +// +// Node rather than shell: no jq to depend on, and the workflows have already +// set up Node by the time this runs. gh brings its own JSON handling. +// +// report() takes its gh as an argument and reaches for nothing global, so it +// can be driven end to end against a fake. Anything that shells out to the +// real gh lives below the entry-point guard, where a test cannot reach it. +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +const LABEL = 'schema-problem'; +const TITLE = 'Schema files under schemas/ are not being served'; +const REPO_URL = 'https://github.com/flashtrace/flashtrace'; + +// Identifies the problem set, not the run: the same problems seen by ten +// consecutive deploys should produce one comment, not ten. Discovery emits +// problems sorted by path, so the serialisation is stable. +export function fingerprint(problems) { + return createHash('sha256').update(JSON.stringify(problems)).digest('hex').slice(0, 12); +} + +export function issueBody({ version, served, problems }) { + return [ + `The site build skipped these files under \`schemas/\` while building \`${version}\`. They are **not being served**:`, + '', + '| File | Why it was skipped |', + '|---|---|', + ...problems.map((p) => `| \`schemas/${p.path}\` | ${p.reason} |`), + '', + `The deploy itself succeeded and ${served} schema(s) went out normally, so the site is current - this is only about the files above.`, + '', + `Fix it upstream in [flashtrace/flashtrace](${REPO_URL}) and cut a release, or adjust \`src/schemas.mjs\` here if the layout changed on purpose. This issue closes itself on the first clean build.`, + '', + ``, + ].join('\n'); +} + +// Returns a short tag for what it did, so a caller can assert on the decision +// rather than on log text. +export function report({ data, gh, log = console.log, reportIssue = false }) { + const { version = 'unknown', schemas: served = 0, problems = [] } = data; + + // Annotations cost no permissions and land on the run itself, so they happen + // whether or not this workflow may touch issues. + for (const p of problems) { + log(`::warning title=Schema not served::schemas/${p.path} ${p.reason}`); + } + + if (!reportIssue) { + log(`${problems.length} problem(s) in ${version}; issue reporting is off for this workflow`); + return 'annotated'; + } + + const open = JSON.parse(gh('issue', 'list', '--state', 'open', '--label', LABEL, '--limit', '1', '--json', 'number')); + const existing = open[0]?.number; + + if (problems.length === 0) { + if (!existing) { + log('clean build - nothing to report'); + return 'clean'; + } + gh('issue', 'close', String(existing), '--comment', + `Every file under \`schemas/\` is being served again as of \`${version}\`. Closing automatically.`); + log(`clean build - closed #${existing}`); + return 'closed'; + } + + const body = issueBody({ version, served, problems }); + + // --force so a missing label is created and an existing one left usable, + // rather than the first report ever filed failing on a label nobody made yet. + gh('label', 'create', LABEL, '--color', 'd93f0b', '--force', + '--description', 'A schema under schemas/ is not being served'); + + if (!existing) { + gh('issue', 'create', '--title', TITLE, '--label', LABEL, '--body', body); + log(`opened a tracking issue for ${problems.length} problem(s)`); + return 'opened'; + } + + const issue = JSON.parse(gh('issue', 'view', String(existing), '--json', 'body,comments')); + const said = [issue.body, ...issue.comments.map((c) => c.body)].join('\n'); + if (said.includes(fingerprint(problems))) { + log(`#${existing} already reports exactly these ${problems.length} problem(s) - staying quiet`); + return 'unchanged'; + } + + gh('issue', 'comment', String(existing), '--body', body); + log(`problem set changed - commented on #${existing}`); + return 'commented'; +} + +// Only when run as a program: importing this module must never be able to +// reach the real gh. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const REPORT = 'schema-problems.json'; + if (!existsSync(REPORT)) { + // The build failed before schema discovery. Whatever went wrong, it is not + // this script's story to tell, and staying silent avoids closing a real + // issue on the strength of a build that never looked. + console.log(`no ${REPORT} - the build did not reach schema discovery, so there is nothing to report`); + process.exit(0); + } + report({ + data: JSON.parse(readFileSync(REPORT, 'utf8')), + gh: (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim(), + reportIssue: process.env.REPORT_ISSUE === '1', + }); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c745cb..378b8dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,15 @@ jobs: env: FLASHTRACE_REF: ${{ steps.ref.outputs.tag }} + # annotations only - REPORT_ISSUE is deliberately unset, because a PR + # should not file an issue about upstream content it did not touch. + # Filing belongs to the deploy; here this only makes a skipped schema + # visible while reviewing. + - name: Report skipped schemas + if: always() + continue-on-error: true + run: node .github/scripts/report-schema-problems.mjs + # also exercised here so artifact packaging failures surface in CI, # not first during a deploy - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f82b503..f510624 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,8 @@ permissions: contents: read pages: write id-token: write + # for the schema problem report in the build job below + issues: write concurrency: group: pages @@ -65,6 +67,18 @@ jobs: env: FLASHTRACE_REF: ${{ steps.ref.outputs.tag }} + # The build skips a schema it cannot serve instead of failing, so without + # this a broken schema would ship as a green deploy and nobody would know. + # continue-on-error: reporting the problem must never become a worse + # problem than the one being reported. + - name: Report skipped schemas + if: always() + continue-on-error: true + run: node .github/scripts/report-schema-problems.mjs + env: + REPORT_ISSUE: '1' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: dist From 011c46b88838b7cfabd670ede917558bafe67524 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 00:13:28 +0200 Subject: [PATCH 08/16] docs: record that public/ overwrites generated output public/ is copied over dist/ last, so a file there wins against a generated file at the same path without a word. That is deliberate - it is what makes public/ an escape hatch - but it is invisible at the point where it would bite: a public/schemas/ or public/docs/ would shadow the real thing and the build would still look right. Noted at the copy in build.mjs, where someone debugging a wrong file will be looking, and in the CLAUDE.md structure table, where someone deciding to put a file in public/ will be. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 4 ++-- build.mjs | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 967f23e..4d4525f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,10 +13,10 @@ Ask refining questions before you start writing. | Folder | Purpose | |---|---| | src/ | Source code, used from 'build.mjs' to create 'dist/' | -| public/ | Assets that should be included exactly as they are in the final 'dist/' | +| public/ | Assets that should be included exactly as they are in the final 'dist/'; copied last, so a file here silently overwrites a generated file at the same path | | dist/ | Uncommitted, generated build output | | flashtrace/ | Uncommitted, gitignored clone of [flashtrace/flashtrace](https://github.com/flashtrace/flashtrace); its 'docs/' are a required build input | -| .github/ | Continuous integration/deployment workflows | +| .github/ | Continuous integration/deployment workflows, plus the scripts they run | 'build.mjs' renders the tool repo's 'docs/' into the site, so a build needs access to them. It looks for the docs at `$FLASHTRACE_DOCS`, then './flashtrace/docs', then '../flashtrace/docs' - if none exist, clone the tool repo first: `git clone https://github.com/flashtrace/flashtrace`. diff --git a/build.mjs b/build.mjs index ed97468..1f3589c 100644 --- a/build.mjs +++ b/build.mjs @@ -265,6 +265,11 @@ ${sitePaths.map((p) => ` ${SITE_URL}${p}`).join('\n')} `, ); +// Last, and over the top of everything generated above: a file in public/ wins +// against a generated file at the same path, silently. That is what makes +// public/ useful as an escape hatch, and also the reason a public/schemas/ or +// public/docs/ would quietly shadow the real thing - check here first if a +// generated file is not the one being served. cpSync(path.join(root, 'public'), dist, { recursive: true }); cpSync(path.join(root, 'src', 'styles', 'site.css'), path.join(dist, 'site.css')); cpSync(path.join(root, 'src', 'scripts', 'site.js'), path.join(dist, 'site.js')); From 4db2d5eaed529c947fb45a8f359a8235cdd10b23 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 00:31:58 +0200 Subject: [PATCH 09/16] feat: keep the schema tracking issue current instead of appending to it The issue reported problems by accumulating comments, so the current state was whatever the newest comment said and a reader had to reconstruct it by scrolling. Someone opening the issue should see what is wrong now. The body is now the live view: a short instruction and a table of the most recent build's skips, rewritten in place on every change without touching the issue's state. Each change also appends a comment saying what actually moved - which files stopped being served, which came back, which are failing for a different reason than before - so the history is still there for anyone who wants it. The body carries the problem set it was written from, in a comment marker. That is what lets the next run tell an unchanged set from a changed one and diff the two, with no state kept anywhere else to fall out of step with the issue. A body edited into nonsense degrades to "cannot diff" and is rewritten, rather than producing a wrong diff. Closing now rewrites the body first, so someone arriving from the close notification does not land on a table of problems that no longer exist, and the closing comment lists what was fixed. Reasons are a parser's own words, so table cells escape pipes and newlines and the embedded state escapes > - an error message quoting JSON should not be able to break the row or close the comment early. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/report-schema-problems.mjs | 164 +++++++++++++++++---- 1 file changed, 137 insertions(+), 27 deletions(-) diff --git a/.github/scripts/report-schema-problems.mjs b/.github/scripts/report-schema-problems.mjs index 5cadf38..6dcee48 100644 --- a/.github/scripts/report-schema-problems.mjs +++ b/.github/scripts/report-schema-problems.mjs @@ -3,9 +3,14 @@ // A file the site cannot serve is skipped rather than fatal (see build.mjs), so // a broken schema otherwise leaves no trace in a green deploy. This closes that // gap: run annotations always, plus - where the caller sets REPORT_ISSUE=1 and -// the workflow grants issues:write - a single tracking issue that opens itself -// on the first bad build, updates when the problem set changes, stays quiet -// when it has not, and closes itself once the build is clean again. +// the workflow grants issues:write - a single tracking issue. +// +// The issue is a living record, not a log. Its body always shows the current +// state, so someone opening it sees what is wrong now rather than reconstructing +// it from a pile of comments; every change also appends a comment saying what +// moved, so the history is still there for anyone who wants it. The body +// carries the problem set it was written from, which is what lets the next run +// tell "nothing changed" from "different problems" and diff the two. // // Node rather than shell: no jq to depend on, and the workflows have already // set up Node by the time this runs. gh brings its own JSON handling. @@ -22,6 +27,7 @@ import { pathToFileURL } from 'node:url'; const LABEL = 'schema-problem'; const TITLE = 'Schema files under schemas/ are not being served'; const REPO_URL = 'https://github.com/flashtrace/flashtrace'; +const STATE_OPEN = ' and close the comment + // early. A reason quoting a parser error is not a controlled string. + const json = JSON.stringify({ fingerprint: fingerprint(problems), problems }).replace(/>/g, '\\u003e'); + return `${STATE_OPEN}${json} -->`; +} + +export function readState(body) { + const at = (body ?? '').indexOf(STATE_OPEN); + if (at === -1) return null; + const start = at + STATE_OPEN.length; + const end = body.indexOf('-->', start); + if (end === -1) return null; + try { + return JSON.parse(body.slice(start, end).trim()); + } catch { + return null; + } +} + +// Keyed by path, so a file whose reason changed reads as one changed entry +// rather than as a simultaneous disappearance and arrival. +export function diffProblems(prev, next) { + const was = new Map(prev.map((p) => [p.path, p.reason])); + const now = new Map(next.map((p) => [p.path, p.reason])); + return { + added: next.filter((p) => !was.has(p.path)), + removed: prev.filter((p) => !now.has(p.path)), + changed: next.filter((p) => was.has(p.path) && was.get(p.path) !== p.reason), + }; +} + +// A reason is often a parser's own words, so it can hold a pipe or a newline +// and quietly wreck the row it sits in. +const cell = (s) => String(s).replace(/\s*[\r\n]+\s*/g, ' ').replace(/\|/g, '\\|'); + +const table = (problems) => [ + '| File | Why it was skipped |', + '|---|---|', + ...problems.map((p) => `| \`schemas/${cell(p.path)}\` | ${cell(p.reason)} |`), +]; + +const runNote = (runUrl, verb) => (runUrl ? [`${verb} by [this run](${runUrl}).`] : []); + +export function issueBody({ version, served, problems, runUrl }) { return [ - `The site build skipped these files under \`schemas/\` while building \`${version}\`. They are **not being served**:`, + 'Some files under `schemas/` are not being served by the site. The site itself deployed normally - this is only about the files below.', + '', + `**To fix:** correct it upstream in [flashtrace/flashtrace](${REPO_URL}) and cut a release, or adjust \`src/schemas.mjs\` here if the layout changed on purpose.`, '', - '| File | Why it was skipped |', - '|---|---|', - ...problems.map((p) => `| \`schemas/${p.path}\` | ${p.reason} |`), + `### Not being served, as of \`${version}\``, '', - `The deploy itself succeeded and ${served} schema(s) went out normally, so the site is current - this is only about the files above.`, + ...table(problems), '', - `Fix it upstream in [flashtrace/flashtrace](${REPO_URL}) and cut a release, or adjust \`src/schemas.mjs\` here if the layout changed on purpose. This issue closes itself on the first clean build.`, + `${served} schema(s) went out normally.`, '', - ``, + 'Maintained by the deploy workflow. This table always reflects the most recent build, each change is recorded as a comment below, and the issue closes itself once a build finds nothing wrong. Edits to this body are overwritten.', + ...runNote(runUrl, 'Updated'), + '', + embedState(problems), ].join('\n'); } +export function resolvedBody({ version, runUrl }) { + return [ + `**Resolved.** Every file under \`schemas/\` is being served again as of \`${version}\`.`, + '', + 'What was wrong, and when it changed, is in the comments below.', + '', + ...runNote(runUrl, 'Closed'), + ].join('\n'); +} + +export function changeComment({ version, diff, runUrl }) { + const lines = [`Rebuilt at \`${version}\`, and the problems changed.`, '']; + if (diff.added.length > 0) { + lines.push(`**No longer served (${diff.added.length})**`, '', ...table(diff.added), ''); + } + if (diff.changed.length > 0) { + lines.push(`**Still not served, for a different reason (${diff.changed.length})**`, '', ...table(diff.changed), ''); + } + if (diff.removed.length > 0) { + lines.push( + `**Served again (${diff.removed.length})**`, + '', + ...diff.removed.map((p) => `- \`schemas/${p.path}\``), + '', + ); + } + lines.push('The issue body above now shows the full current state.', '', ...runNote(runUrl, 'Updated')); + return lines.join('\n'); +} + +export function closeComment({ version, resolved, runUrl }) { + const lines = [`Rebuilt at \`${version}\` with nothing skipped - closing.`, '']; + if (resolved.length > 0) { + lines.push( + `**Served again (${resolved.length})**`, + '', + ...resolved.map((p) => `- \`schemas/${p.path}\``), + '', + ); + } + lines.push(...runNote(runUrl, 'Closed')); + return lines.join('\n'); +} + // Returns a short tag for what it did, so a caller can assert on the decision // rather than on log text. -export function report({ data, gh, log = console.log, reportIssue = false }) { +export function report({ data, gh, log = console.log, reportIssue = false, runUrl }) { const { version = 'unknown', schemas: served = 0, problems = [] } = data; // Annotations cost no permissions and land on the run itself, so they happen @@ -64,41 +164,47 @@ export function report({ data, gh, log = console.log, reportIssue = false }) { const open = JSON.parse(gh('issue', 'list', '--state', 'open', '--label', LABEL, '--limit', '1', '--json', 'number')); const existing = open[0]?.number; + // null when there is no issue, or when its body no longer carries a state - + // both mean "cannot diff", which is different from "diffed to nothing". + const previous = existing + ? readState(JSON.parse(gh('issue', 'view', String(existing), '--json', 'body')).body)?.problems ?? null + : null; if (problems.length === 0) { if (!existing) { log('clean build - nothing to report'); return 'clean'; } + // Body first: a reader arriving from the close notification should not + // find a table of problems that no longer exist. + gh('issue', 'edit', String(existing), '--body', resolvedBody({ version, runUrl })); gh('issue', 'close', String(existing), '--comment', - `Every file under \`schemas/\` is being served again as of \`${version}\`. Closing automatically.`); + closeComment({ version, resolved: previous ?? [], runUrl })); log(`clean build - closed #${existing}`); return 'closed'; } - const body = issueBody({ version, served, problems }); - - // --force so a missing label is created and an existing one left usable, - // rather than the first report ever filed failing on a label nobody made yet. - gh('label', 'create', LABEL, '--color', 'd93f0b', '--force', - '--description', 'A schema under schemas/ is not being served'); - if (!existing) { - gh('issue', 'create', '--title', TITLE, '--label', LABEL, '--body', body); + // --force so a missing label is created and an existing one left usable, + // rather than the first report ever filed failing on a label nobody made. + gh('label', 'create', LABEL, '--color', 'd93f0b', '--force', + '--description', 'A schema under schemas/ is not being served'); + gh('issue', 'create', '--title', TITLE, '--label', LABEL, + '--body', issueBody({ version, served, problems, runUrl })); log(`opened a tracking issue for ${problems.length} problem(s)`); return 'opened'; } - const issue = JSON.parse(gh('issue', 'view', String(existing), '--json', 'body,comments')); - const said = [issue.body, ...issue.comments.map((c) => c.body)].join('\n'); - if (said.includes(fingerprint(problems))) { + if (previous && fingerprint(previous) === fingerprint(problems)) { log(`#${existing} already reports exactly these ${problems.length} problem(s) - staying quiet`); return 'unchanged'; } - gh('issue', 'comment', String(existing), '--body', body); - log(`problem set changed - commented on #${existing}`); - return 'commented'; + gh('issue', 'edit', String(existing), '--body', issueBody({ version, served, problems, runUrl })); + gh('issue', 'comment', String(existing), '--body', + changeComment({ version, diff: diffProblems(previous ?? [], problems), runUrl })); + log(`problem set changed - updated #${existing}`); + return 'updated'; } // Only when run as a program: importing this module must never be able to @@ -112,9 +218,13 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(`no ${REPORT} - the build did not reach schema discovery, so there is nothing to report`); process.exit(0); } + const { GITHUB_SERVER_URL = 'https://github.com', GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env; report({ data: JSON.parse(readFileSync(REPORT, 'utf8')), gh: (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim(), reportIssue: process.env.REPORT_ISSUE === '1', + runUrl: GITHUB_REPOSITORY && GITHUB_RUN_ID + ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` + : undefined, }); } From d20ea5c0a9cb5646a95ffcdd3c549b1dbf1a7a0e Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 00:57:59 +0200 Subject: [PATCH 10/16] ci: report the site commit alongside the flashtrace version The schema reports named only the flashtrace release, but that is the one coordinate a fix here does not move. A layout that changed upstream on purpose is resolved by teaching src/schemas.mjs about it and merging to main, which redeploys against the same latest release - so the issue read "problems at v1.2.3" then "resolved at v1.2.3", crediting a release that never changed and leaving no way to see why it works now. The open direction was equally wrong: a regression in src/schemas.mjs filed an issue blaming an innocent release. Every body and comment now states both: the release the docs and schemas were built from, and the linked .github.io commit that built them. Taken from GITHUB_SHA, falling back to the local checkout, and omitted entirely when neither is available. Deliberately not part of the fingerprint - the sha moves on every merge to main, so folding it in would make unrelated deploys re-comment on an unchanged issue. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/report-schema-problems.mjs | 68 +++++++++++++++++----- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/.github/scripts/report-schema-problems.mjs b/.github/scripts/report-schema-problems.mjs index 6dcee48..38c5c97 100644 --- a/.github/scripts/report-schema-problems.mjs +++ b/.github/scripts/report-schema-problems.mjs @@ -83,13 +83,28 @@ const table = (problems) => [ const runNote = (runUrl, verb) => (runUrl ? [`${verb} by [this run](${runUrl}).`] : []); -export function issueBody({ version, served, problems, runUrl }) { +// Two coordinates, because either one alone can lie about the cause. The +// flashtrace version says which release the docs and schemas came from; the +// site commit says which build read them. A schema most often comes back +// because a PR here taught src/schemas.mjs a layout that changed on purpose - +// that moves the commit and leaves the release untouched, so "resolved at +// v1.2.3" against an earlier "problems at v1.2.3" would credit a release that +// never changed and leave nobody able to see why it works now. +const siteNote = (siteRef) => { + if (!siteRef?.sha) return ''; + const short = siteRef.sha.slice(0, 7); + return siteRef.url ? ` (site [\`${short}\`](${siteRef.url}))` : ` (site \`${short}\`)`; +}; + +export const builtAt = ({ version, siteRef }) => `\`${version}\`${siteNote(siteRef)}`; + +export function issueBody({ version, siteRef, served, problems, runUrl }) { return [ 'Some files under `schemas/` are not being served by the site. The site itself deployed normally - this is only about the files below.', '', `**To fix:** correct it upstream in [flashtrace/flashtrace](${REPO_URL}) and cut a release, or adjust \`src/schemas.mjs\` here if the layout changed on purpose.`, '', - `### Not being served, as of \`${version}\``, + `### Not being served, as of ${builtAt({ version, siteRef })}`, '', ...table(problems), '', @@ -102,9 +117,9 @@ export function issueBody({ version, served, problems, runUrl }) { ].join('\n'); } -export function resolvedBody({ version, runUrl }) { +export function resolvedBody({ version, siteRef, runUrl }) { return [ - `**Resolved.** Every file under \`schemas/\` is being served again as of \`${version}\`.`, + `**Resolved.** Every file under \`schemas/\` is being served again as of ${builtAt({ version, siteRef })}.`, '', 'What was wrong, and when it changed, is in the comments below.', '', @@ -112,8 +127,8 @@ export function resolvedBody({ version, runUrl }) { ].join('\n'); } -export function changeComment({ version, diff, runUrl }) { - const lines = [`Rebuilt at \`${version}\`, and the problems changed.`, '']; +export function changeComment({ version, siteRef, diff, runUrl }) { + const lines = [`Rebuilt at ${builtAt({ version, siteRef })}, and the problems changed.`, '']; if (diff.added.length > 0) { lines.push(`**No longer served (${diff.added.length})**`, '', ...table(diff.added), ''); } @@ -132,8 +147,8 @@ export function changeComment({ version, diff, runUrl }) { return lines.join('\n'); } -export function closeComment({ version, resolved, runUrl }) { - const lines = [`Rebuilt at \`${version}\` with nothing skipped - closing.`, '']; +export function closeComment({ version, siteRef, resolved, runUrl }) { + const lines = [`Rebuilt at ${builtAt({ version, siteRef })} with nothing skipped - closing.`, '']; if (resolved.length > 0) { lines.push( `**Served again (${resolved.length})**`, @@ -148,8 +163,12 @@ export function closeComment({ version, resolved, runUrl }) { // Returns a short tag for what it did, so a caller can assert on the decision // rather than on log text. -export function report({ data, gh, log = console.log, reportIssue = false, runUrl }) { +export function report({ data, gh, log = console.log, reportIssue = false, runUrl, siteRef }) { const { version = 'unknown', schemas: served = 0, problems = [] } = data; + // Deliberately not part of the fingerprint: siteRef moves on every merge to + // main, so folding it in would make every unrelated deploy re-comment on an + // unchanged issue - the exact noise the fingerprint exists to prevent. + const built = { version, siteRef }; // Annotations cost no permissions and land on the run itself, so they happen // whether or not this workflow may touch issues. @@ -177,9 +196,9 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr } // Body first: a reader arriving from the close notification should not // find a table of problems that no longer exist. - gh('issue', 'edit', String(existing), '--body', resolvedBody({ version, runUrl })); + gh('issue', 'edit', String(existing), '--body', resolvedBody({ ...built, runUrl })); gh('issue', 'close', String(existing), '--comment', - closeComment({ version, resolved: previous ?? [], runUrl })); + closeComment({ ...built, resolved: previous ?? [], runUrl })); log(`clean build - closed #${existing}`); return 'closed'; } @@ -190,7 +209,7 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr gh('label', 'create', LABEL, '--color', 'd93f0b', '--force', '--description', 'A schema under schemas/ is not being served'); gh('issue', 'create', '--title', TITLE, '--label', LABEL, - '--body', issueBody({ version, served, problems, runUrl })); + '--body', issueBody({ ...built, served, problems, runUrl })); log(`opened a tracking issue for ${problems.length} problem(s)`); return 'opened'; } @@ -200,9 +219,9 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr return 'unchanged'; } - gh('issue', 'edit', String(existing), '--body', issueBody({ version, served, problems, runUrl })); + gh('issue', 'edit', String(existing), '--body', issueBody({ ...built, served, problems, runUrl })); gh('issue', 'comment', String(existing), '--body', - changeComment({ version, diff: diffProblems(previous ?? [], problems), runUrl })); + changeComment({ ...built, diff: diffProblems(previous ?? [], problems), runUrl })); log(`problem set changed - updated #${existing}`); return 'updated'; } @@ -218,7 +237,23 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(`no ${REPORT} - the build did not reach schema discovery, so there is nothing to report`); process.exit(0); } - const { GITHUB_SERVER_URL = 'https://github.com', GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env; + const { + GITHUB_SERVER_URL = 'https://github.com', + GITHUB_REPOSITORY, + GITHUB_RUN_ID, + GITHUB_SHA, + } = process.env; + // On a repository_dispatch from an upstream release this is the head of main, + // which is the right answer: it names the site code that did the deploying. + // Outside Actions, fall back to the checkout someone ran the build from. + const localSha = () => { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + } catch { + return ''; // not a checkout, or no git - the version alone will have to do + } + }; + const sha = GITHUB_SHA || localSha(); report({ data: JSON.parse(readFileSync(REPORT, 'utf8')), gh: (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim(), @@ -226,5 +261,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) runUrl: GITHUB_REPOSITORY && GITHUB_RUN_ID ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` : undefined, + siteRef: sha + ? { sha, url: GITHUB_REPOSITORY ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${sha}` : undefined } + : undefined, }); } From ed8cbc31131a6b0d7e07a41daa1ee26afad12239 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 09:24:21 +0200 Subject: [PATCH 11/16] fix: escape schema problem annotations as workflow-command values A reason is a parser's own words, so it can hold a newline - and a newline in a workflow command ends that command and starts whatever follows it, letting upstream content emit ::add-mask::, ::error:: or any other command into the run's stream. cell() already collapses newlines for the table path; the annotation path did not use it. Reuse it there, and add GitHub's own percent escaping on top: %25/%0D/%0A for the message, plus %3A/%2C for title=, where : and , separate the properties. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/report-schema-problems.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/scripts/report-schema-problems.mjs b/.github/scripts/report-schema-problems.mjs index 38c5c97..fe3a315 100644 --- a/.github/scripts/report-schema-problems.mjs +++ b/.github/scripts/report-schema-problems.mjs @@ -75,6 +75,14 @@ export function diffProblems(prev, next) { // and quietly wreck the row it sits in. const cell = (s) => String(s).replace(/\s*[\r\n]+\s*/g, ' ').replace(/\|/g, '\\|'); +// Workflow commands are line-oriented and ::-delimited, so the same uncontrolled +// reason that can wreck a table row can inject commands of its own into the +// run's command stream - ::error::, ::add-mask::, anything. GitHub's escaping +// for this is percent-encoding; property values additionally need : and ,, +// which are what separate the properties from each other and from the message. +const cmdData = (s) => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +const cmdProp = (s) => cmdData(s).replace(/:/g, '%3A').replace(/,/g, '%2C'); + const table = (problems) => [ '| File | Why it was skipped |', '|---|---|', @@ -173,7 +181,9 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr // Annotations cost no permissions and land on the run itself, so they happen // whether or not this workflow may touch issues. for (const p of problems) { - log(`::warning title=Schema not served::schemas/${p.path} ${p.reason}`); + // cell() first so the annotation reads as one line, then the escaping that + // makes it a value rather than a command. + log(`::warning title=${cmdProp('Schema not served')}::${cmdData(cell(`schemas/${p.path} ${p.reason}`))}`); } if (!reportIssue) { From 1cd1054d789d23f8859ab94e304e1e971af0de10 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 09:27:47 +0200 Subject: [PATCH 12/16] fix: do not report an unreadable previous state as a fresh diff A null previous state means "cannot diff", not "diffed to nothing", but it was passed as `previous ?? []` into diffProblems - so an issue body that had lost its state marker produced a comment announcing every long-standing problem as newly broken, and a close comment with a silent gap where the list of recovered files should be. Honour the distinction the comment already claimed: an unreadable state gets its own comment stating the current set and saying plainly that it is not a comparison, and closeComment says so rather than listing nothing. Both self-heal - the body is re-stamped in the same run, so the next build can diff again. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/report-schema-problems.mjs | 39 +++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/scripts/report-schema-problems.mjs b/.github/scripts/report-schema-problems.mjs index fe3a315..2d8b501 100644 --- a/.github/scripts/report-schema-problems.mjs +++ b/.github/scripts/report-schema-problems.mjs @@ -135,6 +135,23 @@ export function resolvedBody({ version, siteRef, runUrl }) { ].join('\n'); } +// When the previous set is unreadable there is no diff to state, and stating +// one anyway would announce every long-standing problem as newly broken. Say +// what is actually known: the current set, and why it is not a comparison. +export function unknownPreviousComment({ version, siteRef, problems, runUrl }) { + return [ + `Rebuilt at ${builtAt({ version, siteRef })}.`, + '', + 'The previous state could not be read from this issue\'s body, so what changed since the last build cannot be shown. The full current set is below - some of it may have been here all along.', + '', + ...table(problems), + '', + 'The issue body above now shows the full current state, and the next build will be able to diff against it again.', + '', + ...runNote(runUrl, 'Updated'), + ].join('\n'); +} + export function changeComment({ version, siteRef, diff, runUrl }) { const lines = [`Rebuilt at ${builtAt({ version, siteRef })}, and the problems changed.`, '']; if (diff.added.length > 0) { @@ -155,9 +172,13 @@ export function changeComment({ version, siteRef, diff, runUrl }) { return lines.join('\n'); } +// resolved is null when the previous set was unreadable: say so, rather than +// closing with a silent gap where the list of what came back should be. export function closeComment({ version, siteRef, resolved, runUrl }) { const lines = [`Rebuilt at ${builtAt({ version, siteRef })} with nothing skipped - closing.`, '']; - if (resolved.length > 0) { + if (resolved === null) { + lines.push('The previous state could not be read from this issue\'s body, so which files came back cannot be listed. The comments above are the record.', ''); + } else if (resolved.length > 0) { lines.push( `**Served again (${resolved.length})**`, '', @@ -183,7 +204,8 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr for (const p of problems) { // cell() first so the annotation reads as one line, then the escaping that // makes it a value rather than a command. - log(`::warning title=${cmdProp('Schema not served')}::${cmdData(cell(`schemas/${p.path} ${p.reason}`))}`); + const message = cmdData(cell(`schemas/${p.path} ${p.reason}`)); + log(`::warning title=${cmdProp('Schema not served')}::${message}`); } if (!reportIssue) { @@ -194,7 +216,9 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr const open = JSON.parse(gh('issue', 'list', '--state', 'open', '--label', LABEL, '--limit', '1', '--json', 'number')); const existing = open[0]?.number; // null when there is no issue, or when its body no longer carries a state - - // both mean "cannot diff", which is different from "diffed to nothing". + // both mean "cannot diff", which is different from "diffed to nothing" and is + // kept distinct all the way down: an unreadable body must not produce a + // comment announcing every long-standing problem as newly broken. const previous = existing ? readState(JSON.parse(gh('issue', 'view', String(existing), '--json', 'body')).body)?.problems ?? null : null; @@ -208,7 +232,7 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr // find a table of problems that no longer exist. gh('issue', 'edit', String(existing), '--body', resolvedBody({ ...built, runUrl })); gh('issue', 'close', String(existing), '--comment', - closeComment({ ...built, resolved: previous ?? [], runUrl })); + closeComment({ ...built, resolved: previous, runUrl })); log(`clean build - closed #${existing}`); return 'closed'; } @@ -230,8 +254,13 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr } gh('issue', 'edit', String(existing), '--body', issueBody({ ...built, served, problems, runUrl })); + if (previous === null) { + gh('issue', 'comment', String(existing), '--body', unknownPreviousComment({ ...built, problems, runUrl })); + log(`#${existing} carries no readable state - updated it with the current ${problems.length} problem(s), without a diff`); + return 'restated'; + } gh('issue', 'comment', String(existing), '--body', - changeComment({ ...built, diff: diffProblems(previous ?? [], problems), runUrl })); + changeComment({ ...built, diff: diffProblems(previous, problems), runUrl })); log(`problem set changed - updated #${existing}`); return 'updated'; } From 4f981c76b865c2c8403d1127a6003a70a1aee7f3 Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 09:29:47 +0200 Subject: [PATCH 13/16] fix: roll back the resolved body when closing the issue fails The close path writes the body before closing, so a reader arriving from the close notification does not land on a table of problems that no longer exist. Nothing caught a failure between the two calls, though, and execFileSync throws on a non-zero gh: a failed close left an open issue whose body claims to be resolved and whose table is gone - worse than either call not having happened. Keep the ordering, and make it recoverable. The pre-existing body is now retained rather than only parsed, and a failed close puts it back, so the next clean build retries the whole thing. If the restore fails too, the log says exactly what state the issue is in. The error is rethrown either way: the step is continue-on-error, so it surfaces on the run without failing the deploy. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/report-schema-problems.mjs | 30 ++++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/scripts/report-schema-problems.mjs b/.github/scripts/report-schema-problems.mjs index 2d8b501..4b07803 100644 --- a/.github/scripts/report-schema-problems.mjs +++ b/.github/scripts/report-schema-problems.mjs @@ -219,9 +219,12 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr // both mean "cannot diff", which is different from "diffed to nothing" and is // kept distinct all the way down: an unreadable body must not produce a // comment announcing every long-standing problem as newly broken. - const previous = existing - ? readState(JSON.parse(gh('issue', 'view', String(existing), '--json', 'body')).body)?.problems ?? null + // Kept as written, not just parsed: it is what a failed close has to be + // rolled back to. + const existingBody = existing + ? JSON.parse(gh('issue', 'view', String(existing), '--json', 'body')).body : null; + const previous = existing ? readState(existingBody)?.problems ?? null : null; if (problems.length === 0) { if (!existing) { @@ -229,10 +232,27 @@ export function report({ data, gh, log = console.log, reportIssue = false, runUr return 'clean'; } // Body first: a reader arriving from the close notification should not - // find a table of problems that no longer exist. + // find a table of problems that no longer exist. That ordering leaves a + // window, though - if the close fails, an open issue is left claiming to + // be resolved, with the table it should still be showing gone. That state + // is worse than either call simply not having happened, so put the old + // body back and let the next clean build try the whole thing again. gh('issue', 'edit', String(existing), '--body', resolvedBody({ ...built, runUrl })); - gh('issue', 'close', String(existing), '--comment', - closeComment({ ...built, resolved: previous, runUrl })); + try { + gh('issue', 'close', String(existing), '--comment', + closeComment({ ...built, resolved: previous, runUrl })); + } catch (error) { + try { + gh('issue', 'edit', String(existing), '--body', existingBody); + log(`could not close #${existing} - restored its body, so it still reports the last known problems`); + } catch { + // Both calls failing means gh or the API is not usable at all, so + // there is nothing left to try from here. Say precisely what state the + // issue is in, because it is one nobody would otherwise expect. + log(`could not close #${existing}, and could not restore its body: it is open and reads as resolved. The comments hold what was wrong; the next failing build rewrites the body.`); + } + throw error; + } log(`clean build - closed #${existing}`); return 'closed'; } From 131429efa10946ebff8c1102eb9b98da36c9776d Mon Sep 17 00:00:00 2001 From: LennarX Date: Tue, 21 Jul 2026 09:31:11 +0200 Subject: [PATCH 14/16] fix: fingerprint the problem set, not its serialisation JSON.stringify over the raw array folds array order and key insertion order into the hash, so a discovery pass returning the same problems in a different order - or upstream adding an incidental field like a line number or a timestamp - would read as a changed set and re-comment on every deploy. The comment claimed discovery sorts by path; nothing here enforced it. Normalise to sorted [path, reason] tuples before hashing. Sorted by code point rather than localeCompare, which can call two distinct paths equal and leave their order dependent on arrival order - the same instability. No migration: the fingerprint stored in the issue body is informational, and the staying-quiet check recomputes both sides from their problem sets, so an issue open across this change still compares correctly. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/report-schema-problems.mjs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/scripts/report-schema-problems.mjs b/.github/scripts/report-schema-problems.mjs index 4b07803..0adc30c 100644 --- a/.github/scripts/report-schema-problems.mjs +++ b/.github/scripts/report-schema-problems.mjs @@ -29,11 +29,25 @@ const TITLE = 'Schema files under schemas/ are not being served'; const REPO_URL = 'https://github.com/flashtrace/flashtrace'; const STATE_OPEN = '