From 87d2bc1da63ddf2ae01fe58611b2c19d72a54f09 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Thu, 27 Aug 2026 21:15:26 +0000 Subject: [PATCH 1/4] chore(ci): verify giscus comment threads still map to their articles --- .gitlab-ci.yml | 12 +++ nuxt/scripts/verify-giscus.mjs | 160 +++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 nuxt/scripts/verify-giscus.mjs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e489ccfa..0a4bb708 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -201,6 +201,18 @@ linkcheck: expire_in: 1 week expose_as: "Linkcheck report" +# Giscus maps each article to a GitHub Discussion by pathname, and a broken +# mapping renders an empty comment box rather than an error, so nothing else in +# the pipeline would notice. Read-only: it calls the same public resolver the +# widget uses on every page load, needs no credentials and writes nothing. +lint:giscus: + stage: lint + image: node:22.23.2 + before_script: [] + retry: 1 + script: + - node nuxt/scripts/verify-giscus.mjs + # --------------------------------------------------------------------------- # Test stage # --------------------------------------------------------------------------- diff --git a/nuxt/scripts/verify-giscus.mjs b/nuxt/scripts/verify-giscus.mjs new file mode 100644 index 00000000..2e288e6b --- /dev/null +++ b/nuxt/scripts/verify-giscus.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Verify that the giscus comment threads still resolve for the articles that + * have them. + * + * Giscus maps a page to a GitHub Discussion by its `pathname`, so the + * discussion's *title* has to equal `writing/`. Nothing in the build + * enforces that: if a slug changes, the URL scheme moves, or the category is + * edited, the widget silently renders an empty "no comments yet" state instead + * of erroring. That is how two real discussions sat orphaned under the site's + * previous `articles/` and `blog/` path schemes, one of them holding an actual + * reader's comment that nobody could see. + * + * This queries the same public resolver the widget calls on every page load, so + * it needs no credentials and writes nothing. + * + * An article with no thread is not a failure. Giscus creates the discussion on + * the first comment, so most articles legitimately have none. The failure this + * guards against is a thread that used to resolve and no longer does, which is + * why known threads are listed explicitly below. + * + * Usage: node scripts/verify-giscus.mjs [--json] + */ + +import { readdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +// Mirrors the attributes set in app/components/AppGiscusComments.vue. If you +// change them there, change them here, and vice versa: the component's unit +// test pins the same values. +const REPO = 'Decipher/stuar.tc' +const CATEGORY = 'General' +const CATEGORY_ID = 'DIC_kwDOGZt9684CAB_7' + +/** + * Article slugs known to have a discussion thread. + * + * Add a slug here once a thread exists for it, which you can see in this + * script's own output. Kept explicit rather than discovered from the GitHub API + * so the check needs no token, and so deleting a thread is a deliberate edit + * rather than a silent pass. + */ +const EXPECTED_THREADS = [ + 'hello-world-20211126', + 'decoupling-configuration-config-pages-20220412', +] + +const ARTICLES_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'content', + 'articles-data', +) + +/** + * Ask giscus to resolve one term, retrying transient failures. + * + * A 404 is a definitive "no thread" and is returned as such. Network errors and + * 5xx are retried, because this check talks to a third-party service and a blip + * there should not read as a broken site. + * + * @param {string} term - The pathname-derived discussion title. + * @param {number} attempts - Remaining tries for transient failures. + * @returns {Promise<{found: boolean, url?: string, comments?: number}>} Result. + */ +async function resolveTerm(term, attempts = 3) { + const qs = new URLSearchParams({ + repo: REPO, + term, + category: CATEGORY, + categoryId: CATEGORY_ID, + strict: 'false', + last: '1', + }) + try { + const res = await fetch(`https://giscus.app/api/discussions?${qs}`) + if (res.status === 404) return { found: false } + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const body = await res.json() + return { + found: Boolean(body.discussion), + url: body.discussion?.url, + comments: body.discussion?.totalCommentCount ?? 0, + } + } + catch (err) { + if (attempts <= 1) throw new Error(`giscus lookup failed for "${term}": ${err.message}`) + await new Promise(r => setTimeout(r, 2000)) + return resolveTerm(term, attempts - 1) + } +} + +const files = (await readdir(ARTICLES_DIR)).filter(f => f.endsWith('.json')) +const articleSlugs = new Set(files.map(f => path.basename(f, '.json'))) + +// Check the union, not just the articles on disk. An expected thread whose +// article has been renamed away is exactly the orphaning this guards against, +// and iterating only over the directory would skip it silently. +const slugs = [...new Set([...articleSlugs, ...EXPECTED_THREADS])].sort() + +const results = [] +for (const slug of slugs) { + const r = await resolveTerm(`writing/${slug}`) + results.push({ + slug, + ...r, + expected: EXPECTED_THREADS.includes(slug), + hasArticle: articleSlugs.has(slug), + }) +} + +// A thread with no article is orphaned: readers can never reach it, which is +// the state this whole check exists because of. +const orphaned = results.filter(r => r.found && !r.hasArticle) + +const missing = results.filter(r => r.expected && !r.found) +const undeclared = results.filter(r => !r.expected && r.found) + +if (process.argv.includes('--json')) { + console.log(JSON.stringify({ results, missing, undeclared }, null, 2)) +} +else { + console.log(`giscus mapping: ${REPO} (${CATEGORY}), term = "writing/"\n`) + for (const r of results) { + const mark = r.found ? '✓' : r.expected ? '✗' : '-' + const detail = r.found + ? `${r.comments} comment(s) ${r.url}${r.hasArticle ? '' : ' [NO ARTICLE]'}` + : r.hasArticle ? 'no thread yet' : 'no thread, no article' + console.log(` ${mark} writing/${r.slug}`.padEnd(66) + detail) + } +} + +if (undeclared.length) { + console.log(`\nNote: ${undeclared.length} thread(s) exist that are not in EXPECTED_THREADS.`) + console.log('Add them to lock in the mapping:') + for (const r of undeclared) console.log(` '${r.slug}',`) +} + +if (orphaned.length) { + console.error(`\nFAIL: ${orphaned.length} thread(s) have no matching article:`) + for (const r of orphaned) console.error(` writing/${r.slug} ${r.url}`) + console.error('Readers cannot reach these. Rename the discussion to the current') + console.error('article path, or drop the slug from EXPECTED_THREADS if it is retired.') +} + +if (missing.length) { + console.error(`\nFAIL: ${missing.length} expected thread(s) no longer resolve:`) + for (const r of missing) { + console.error(` writing/${r.slug}`) + } + console.error('\nThe discussion title must equal the article pathname without a leading slash.') + console.error('Either the slug changed, the discussion was renamed or deleted, or the') + console.error('category in AppGiscusComments.vue no longer matches. Readers see an empty') + console.error('comment box, not an error, so nothing else will report this.') +} + +if (missing.length || orphaned.length) process.exit(1) + +console.log('\nAll expected threads resolve and map to a published article.') From 3d6bf13bd1a1a76afee3438dab7676766fff5e01 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Thu, 27 Aug 2026 21:38:22 +0000 Subject: [PATCH 2/4] fix(ci): attach cause when rethrowing a giscus lookup failure --- nuxt/scripts/verify-giscus.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nuxt/scripts/verify-giscus.mjs b/nuxt/scripts/verify-giscus.mjs index 2e288e6b..c25c13f2 100644 --- a/nuxt/scripts/verify-giscus.mjs +++ b/nuxt/scripts/verify-giscus.mjs @@ -85,7 +85,9 @@ async function resolveTerm(term, attempts = 3) { } } catch (err) { - if (attempts <= 1) throw new Error(`giscus lookup failed for "${term}": ${err.message}`) + if (attempts <= 1) { + throw new Error(`giscus lookup failed for "${term}": ${err.message}`, { cause: err }) + } await new Promise(r => setTimeout(r, 2000)) return resolveTerm(term, attempts - 1) } From 8e72041719b62b921848cad91749e16a0cbdcaf7 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Thu, 27 Aug 2026 22:43:59 +0000 Subject: [PATCH 3/4] fix(ci): register verify-giscus as a knip entry point --- nuxt/knip.jsonc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nuxt/knip.jsonc b/nuxt/knip.jsonc index 12932c2c..3092ff98 100644 --- a/nuxt/knip.jsonc +++ b/nuxt/knip.jsonc @@ -5,7 +5,8 @@ "scripts/sync-content.mjs", "scripts/push-story.mjs", "scripts/screenshot-story.mjs", - "scripts/audit-budgets.mjs" + "scripts/audit-budgets.mjs", + "scripts/verify-giscus.mjs" ], "ignore": [ "content.config.ts", From d1da02c3fdbd4adbaf8cb1da90f250af1936d397 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Fri, 28 Aug 2026 03:46:55 +0000 Subject: [PATCH 4/4] fix(ci): assert giscus threads resolve to their own discussion, not a fuzzy match --- nuxt/scripts/verify-giscus.mjs | 106 +++++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 19 deletions(-) diff --git a/nuxt/scripts/verify-giscus.mjs b/nuxt/scripts/verify-giscus.mjs index c25c13f2..9d5665fb 100644 --- a/nuxt/scripts/verify-giscus.mjs +++ b/nuxt/scripts/verify-giscus.mjs @@ -34,17 +34,29 @@ const CATEGORY = 'General' const CATEGORY_ID = 'DIC_kwDOGZt9684CAB_7' /** - * Article slugs known to have a discussion thread. + * Article slugs known to have a discussion thread, mapped to the discussion + * number that slug must resolve to. * - * Add a slug here once a thread exists for it, which you can see in this - * script's own output. Kept explicit rather than discovered from the GitHub API - * so the check needs no token, and so deleting a thread is a deliberate edit - * rather than a silent pass. + * The number is not decoration. Giscus matches titles loosely, so a term that + * is merely a prefix of a real discussion title still resolves: asking for + * `writing/hello` returns the `writing/hello-world-20211126` thread. Checking + * only that *something* came back would therefore report a healthy mapping for + * an article whose own thread does not exist, and would miss two articles + * colliding onto one thread. Pinning the number makes the assertion an identity + * check rather than a liveness check. + * + * Add a slug here once a thread exists for it; the script prints the exact line + * to paste. Kept explicit rather than discovered from the GitHub API so the + * check needs no token, and so removing a thread is a deliberate edit rather + * than a silent pass. */ -const EXPECTED_THREADS = [ - 'hello-world-20211126', - 'decoupling-configuration-config-pages-20220412', -] +const EXPECTED_THREADS = { + 'hello-world-20211126': 2, + 'decoupling-configuration-config-pages-20220412': 82, +} + +/** Abort a giscus request that has not answered in this long, in ms. */ +const REQUEST_TIMEOUT_MS = 15000 const ARTICLES_DIR = path.join( path.dirname(fileURLToPath(import.meta.url)), @@ -60,9 +72,14 @@ const ARTICLES_DIR = path.join( * 5xx are retried, because this check talks to a third-party service and a blip * there should not read as a broken site. * + * Mirrors the widget's own non-strict matching rather than forcing + * `strict=true`, because the point is to observe what a reader's browser + * actually resolves. The looseness that creates is handled by the caller, which + * compares the returned discussion number against the expected one. + * * @param {string} term - The pathname-derived discussion title. * @param {number} attempts - Remaining tries for transient failures. - * @returns {Promise<{found: boolean, url?: string, comments?: number}>} Result. + * @returns {Promise<{found: boolean, url?: string, number?: number, comments?: number}>} Result. */ async function resolveTerm(term, attempts = 3) { const qs = new URLSearchParams({ @@ -74,13 +91,19 @@ async function resolveTerm(term, attempts = 3) { last: '1', }) try { - const res = await fetch(`https://giscus.app/api/discussions?${qs}`) + // Without a signal a hung connection would stall the CI job indefinitely + // rather than failing into the retry below. + const res = await fetch(`https://giscus.app/api/discussions?${qs}`, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) if (res.status === 404) return { found: false } if (!res.ok) throw new Error(`HTTP ${res.status}`) const body = await res.json() + const url = body.discussion?.url return { found: Boolean(body.discussion), - url: body.discussion?.url, + url, + number: url ? Number(url.match(/\/discussions\/(\d+)$/)?.[1]) : undefined, comments: body.discussion?.totalCommentCount ?? 0, } } @@ -99,15 +122,18 @@ const articleSlugs = new Set(files.map(f => path.basename(f, '.json'))) // Check the union, not just the articles on disk. An expected thread whose // article has been renamed away is exactly the orphaning this guards against, // and iterating only over the directory would skip it silently. -const slugs = [...new Set([...articleSlugs, ...EXPECTED_THREADS])].sort() +const expectedSlugs = Object.keys(EXPECTED_THREADS) +const slugs = [...new Set([...articleSlugs, ...expectedSlugs])].sort() const results = [] for (const slug of slugs) { const r = await resolveTerm(`writing/${slug}`) + const expectedNumber = EXPECTED_THREADS[slug] results.push({ slug, ...r, - expected: EXPECTED_THREADS.includes(slug), + expected: expectedNumber !== undefined, + expectedNumber, hasArticle: articleSlugs.has(slug), }) } @@ -117,10 +143,28 @@ for (const slug of slugs) { const orphaned = results.filter(r => r.found && !r.hasArticle) const missing = results.filter(r => r.expected && !r.found) + +// Resolved, but to the wrong discussion. Giscus matches titles loosely, so this +// is how a renamed slug still "resolves" — to a neighbouring article's thread. +const mismatched = results.filter(r => r.expected && r.found && r.number !== r.expectedNumber) + +// Two articles resolving to one discussion means readers of one see the other's +// comments. Loose matching makes this reachable whenever one slug is a prefix +// of another. +const byNumber = new Map() +for (const r of results.filter(x => x.found && x.hasArticle)) { + byNumber.set(r.number, [...(byNumber.get(r.number) ?? []), r.slug]) +} +const collisions = [...byNumber.entries()].filter(([, s]) => s.length > 1) + const undeclared = results.filter(r => !r.expected && r.found) if (process.argv.includes('--json')) { - console.log(JSON.stringify({ results, missing, undeclared }, null, 2)) + console.log(JSON.stringify( + { results, missing, mismatched, orphaned, undeclared, collisions }, + null, + 2, + )) } else { console.log(`giscus mapping: ${REPO} (${CATEGORY}), term = "writing/"\n`) @@ -133,10 +177,32 @@ else { } } -if (undeclared.length) { +// Everything below writes to stderr or is suppressed under --json, so that +// --json emits exactly one JSON document on stdout and stays pipeable to jq. +const json = process.argv.includes('--json') + +if (undeclared.length && !json) { console.log(`\nNote: ${undeclared.length} thread(s) exist that are not in EXPECTED_THREADS.`) console.log('Add them to lock in the mapping:') - for (const r of undeclared) console.log(` '${r.slug}',`) + for (const r of undeclared) console.log(` '${r.slug}': ${r.number},`) +} + +if (mismatched.length) { + console.error(`\nFAIL: ${mismatched.length} thread(s) resolve to the wrong discussion:`) + for (const r of mismatched) { + console.error(` writing/${r.slug} expected #${r.expectedNumber}, got #${r.number} ${r.url}`) + } + console.error('Giscus matches titles loosely, so a renamed or shortened slug can still') + console.error('resolve, to a neighbouring article\'s thread. Readers would see the wrong') + console.error('comments rather than none.') +} + +if (collisions.length) { + console.error(`\nFAIL: ${collisions.length} discussion(s) are claimed by more than one article:`) + for (const [number, slugList] of collisions) { + console.error(` #${number} <- ${slugList.map(s => `writing/${s}`).join(', ')}`) + } + console.error('Readers of one article would see another article\'s comments.') } if (orphaned.length) { @@ -157,6 +223,8 @@ if (missing.length) { console.error('comment box, not an error, so nothing else will report this.') } -if (missing.length || orphaned.length) process.exit(1) +if (missing.length || orphaned.length || mismatched.length || collisions.length) process.exit(1) -console.log('\nAll expected threads resolve and map to a published article.') +if (!json) { + console.log('\nAll expected threads resolve to their own discussion and map to a published article.') +}