From 23f16810ce7037f3121401465af409e1a07ff904 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Wed, 26 Aug 2026 19:53:47 -0400 Subject: [PATCH 1/3] fix(docs): pin the engine docs to the released tag, and say so when a reader is elsewhere (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #85. Both options in the issue, because they compose: pinning narrows the gap, the banner covers what remains. ### Why pinning, not just a banner This failure mode is not hypothetical here. PR #88 shipped a false claim because the install page described `scripts/install.sh` on `main`; keel then raised its Python floor from 3.11 to 3.14 in `2d3934f`, and the page went stale with nobody touching this repo. Docs pinned to `main` carry the same fault silently. An operator runs a published release, so the documents this site renders should be the documents that release shipped. That is FR-9 applied upstream of the prose rather than to it. `scripts/lib/release-tag.mjs` resolves the tag once: a fresh `data/release.json` first (`npm run fetch` now runs `fetch-release.mjs` first, so the normal path costs no extra API call), then the releases API, then the last-known tag when the API is unreachable. **There is deliberately no fallback to `main`** — a silent fallback would restore exactly the skew this exists to remove. When no tag resolves at all, the build stops. The manifest keeps the policy legible: `"ref": "latest-release"` plus a `refNote` stating it in the file. A literal branch, tag or SHA is still honored verbatim, so pinning to something specific stays a one-line edit. FR-4 is intact — a document missing at the pinned ref still exits non-zero, naming the doc and the ref. ### The `?v=` notice keel's browser UI will deep-link as `/en/docs/glossary/?v=0.11.2#qabd`. The site is `output: "static"`, so the comparison happens in the browser: the markup ships hidden and complete, and one small inline script fills a text node and unhides it. Because the element is parsed before the fragment scroll and the script never touches `location.hash`, the anchor still lands correctly. Anything unexpected in `?v=` renders nothing — an unrecognized value is not evidence of skew. The value is regex-gated, length-capped and written with `textContent`, never `innerHTML`. ### One correction to the issue Acceptance asked for the notice "in all three locales". That is not achievable: `src/pages/en/docs/[slug].astro` is the only per-document route — ar and fr ship a docs index only, so there is no page for it to render on. The banner is EN-only. Arabic and French strings are in `ui.ts` so the chrome is complete if that changes. ### Also `src/components/docs/nav.ts` had `FALLBACK_META.ref = "main"`. A hard-coded branch there would now be a false claim on any page that fell back to it, so it is empty and the notice's `comparable` guard suppresses itself. `LAUNCH-CHECKLIST.md` inline-script count goes two to three — `1e194b9` was a commit correcting that same count, so it is kept honest here. ### Not fixed here — filed as #91 `blob/main` still appears in hand-written links across `Footer.astro`, `HomePage`, `CompliancePage`, `ComparePage`, `InstallPage` and — most seriously — `features.ts`, whose "Verify in the repository" links are how this site asks to be checked rather than believed. Same skew class, much wider surface, and it deserves its own review rather than riding along here. ### Verification - `npm run check` — 0 errors, 0 warnings - `npm run build` — `docs ref: v0.11.2 (from data/release.json, written this build)`, 8 documents, 56 pages - `data/docs-meta.json` records `"ref": "v0.11.2"`; every `sourceUrl` is `blob/v0.11.2/` - Rendered `dist/en/docs/glossary/index.html`: notice present and `hidden`, source link on the tag, no `blob/main` doc link left on the page Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015cBJ2syTN4VoE73F9sBNGW --- README.md | 6 +- docs/DEPLOYMENT.md | 5 ++ docs/LAUNCH-CHECKLIST.md | 13 ++-- engine-docs.manifest.json | 3 +- package.json | 2 +- scripts/fetch-engine-docs.mjs | 37 ++++++++-- scripts/lib/release-tag.mjs | 81 ++++++++++++++++++++++ src/components/DocsVersionNotice.astro | 90 +++++++++++++++++++++++++ src/components/docs/nav.ts | 6 +- src/components/pages/DocsViewPage.astro | 3 + src/i18n/ui.ts | 13 ++++ 11 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 scripts/lib/release-tag.mjs create mode 100644 src/components/DocsVersionNotice.astro diff --git a/README.md b/README.md index dc67d37..4d7953a 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,10 @@ registrar completes (PRD §11). SEO strategy and roadmap: - **Static-first Astro** (zero client JavaScript), deployed to Cloudflare Pages from `main`; every PR gets a preview. - **Docs pipeline (FR-4)** — [engine-docs.manifest.json](engine-docs.manifest.json) - pins the engine documents; `npm run build` fetches them at build time and - **fails loudly** if one moves. Documents are never hand-copied. + pins the engine documents at keel's latest published release tag, resolved at + build time (#85), so the site describes what an operator runs; `npm run build` + fetches them and **fails loudly** if one moves. Documents are never + hand-copied. - **News (FR-5)** — the Announcements category of GitHub Discussions, read via the public REST endpoint (no token, no Worker needed — see the note in docs/DEPLOYMENT.md), refreshed by an hourly rebuild workflow. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f46a2ef..5892a8c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -101,6 +101,11 @@ not the pull request. - **A build that cannot fetch a pinned engine doc fails on purpose** (FR-4). Fix by updating `engine-docs.manifest.json` to the document's new path — never by hand-copying content into `src/content/engine-docs/` (gitignored). +- **The docs ref is a release tag, not a branch** (#85). `npm run fetch` runs + `fetch-release.mjs` first, so `fetch-engine-docs.mjs` reads the tag from + `data/release.json` without a second call to the releases API. If no tag can + be resolved at all, the docs fetch exits non-zero: there is deliberately no + fallback to `main`, which is the skew the pin removes. - **Release / Discussions fetch failures degrade, not fail**: the build keeps the last-known `data/*.json`; the Install and News pages show their GitHub fallbacks. `data/` contents are build artifacts and never committed. diff --git a/docs/LAUNCH-CHECKLIST.md b/docs/LAUNCH-CHECKLIST.md index f7687ea..e050ef6 100644 --- a/docs/LAUNCH-CHECKLIST.md +++ b/docs/LAUNCH-CHECKLIST.md @@ -74,9 +74,10 @@ Verified 2026-08-22: ## 5. Docs pages fail the build when an engine doc moves — GREEN `scripts/fetch-engine-docs.mjs` fetches every document pinned in -`engine-docs.manifest.json`; on a 404 or network error it records the failure and -ends with `process.exit(1)` ("The build stops here by design (FR-4): never render -stale or missing docs."). CI surfaces this legibly: `.github/workflows/ci.yml` step +`engine-docs.manifest.json`, at keel's latest published release tag — resolved at +build time since #85, never at `main`. On a 404 or network error it records the +failure and ends with `process.exit(1)` ("The build stops here by design (FR-4): +never render stale or missing docs."). CI surfaces this legibly: `.github/workflows/ci.yml` step **"Fetch engine docs, release, discussions"** (`npm run fetch`) runs before type check and build; `deploy.yml` chains the same fetch inside `npm run build`, so a moved engine doc red-Xes Checks and blocks the deploy instead of publishing a @@ -93,8 +94,10 @@ stale page. EN install **100/100**, FR compare **100/100**. Both runs sit comfortably above the ≥95/≥95 budget. The site ships no client-side -JavaScript framework or bundle by design — two small inline scripts only: the -light/dark choice and the code-block copy button. +JavaScript framework or bundle by design — three small inline scripts only: the +light/dark choice, the code-block copy button, and the docs version notice (#85). +The third one ships on English engine document pages only, and does nothing at +all unless keel sends a `?v=` that differs from the tag the docs were built from. ## 7. Honesty review green in EN and AR before launch — GREEN (reviewed EN + AR + FR) diff --git a/engine-docs.manifest.json b/engine-docs.manifest.json index 6cba518..57b4355 100644 --- a/engine-docs.manifest.json +++ b/engine-docs.manifest.json @@ -1,6 +1,7 @@ { "repo": "CodeGateSoftware/keel", - "ref": "main", + "ref": "latest-release", + "refNote": "Sentinel, read by scripts/fetch-engine-docs.mjs (#85): resolve to the latest published release tag at build time, so the site describes what an operator actually runs. A literal branch, tag or SHA here is used verbatim instead. There is no fallback to main: if no tag resolves, the build fails.", "docs": [ { "path": "docs/glossary.md", diff --git a/package.json b/package.json index d5a95c3..8e08449 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "0.1.0", "private": true, "scripts": { - "fetch": "node scripts/fetch-engine-docs.mjs && node scripts/fetch-release.mjs && node scripts/fetch-discussions.mjs", + "fetch": "node scripts/fetch-release.mjs && node scripts/fetch-engine-docs.mjs && node scripts/fetch-discussions.mjs", "dev": "npm run fetch && astro dev", "build": "npm run fetch && astro build", "preview": "astro preview", diff --git a/scripts/fetch-engine-docs.mjs b/scripts/fetch-engine-docs.mjs index 39b77c9..8d56142 100644 --- a/scripts/fetch-engine-docs.mjs +++ b/scripts/fetch-engine-docs.mjs @@ -11,16 +11,43 @@ * * Relative markdown links inside fetched documents are rewritten to absolute * GitHub blob URLs so they resolve from this origin; document text is untouched. + * + * #85 — the ref is the latest published release tag, not `main`. An operator + * runs a release, so the documents this site renders must be the documents that + * release shipped. The manifest keeps the policy in its `ref` field as the + * sentinel "latest-release"; a literal branch, tag or SHA is still honored. */ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { LATEST_RELEASE, resolveLatestReleaseTag } from "./lib/release-tag.mjs"; const root = dirname(dirname(fileURLToPath(import.meta.url))); const manifest = JSON.parse( readFileSync(join(root, "engine-docs.manifest.json"), "utf8"), ); +/** + * The ref every fetch, rewritten link and source URL below points at. When the + * release tag cannot be resolved the build stops: falling back to `main` would + * silently restore the skew this pin exists to remove. + */ +const docsRef = await (async () => { + if (manifest.ref !== LATEST_RELEASE) return manifest.ref; + const resolved = await resolveLatestReleaseTag(root, manifest.repo); + if (!resolved.tag) { + console.error( + `\nFAIL: could not resolve the latest release tag for ${manifest.repo} — ${resolved.source}.`, + ); + console.error( + "The docs pipeline pins to a published release and never falls back to main (#85).", + ); + process.exit(1); + } + console.log(` docs ref: ${resolved.tag} (from ${resolved.source})`); + return resolved.tag; +})(); + const DOCS_DIR = join(root, "src/content/engine-docs"); const META_FILE = join(root, "data/docs-meta.json"); mkdirSync(DOCS_DIR, { recursive: true }); @@ -88,14 +115,14 @@ const failures = []; const fetchedAt = new Date().toISOString(); const meta = { repo: manifest.repo, - ref: manifest.ref, + ref: docsRef, fetchedAt, sections: manifest.sections ?? [], docs: [], }; for (const doc of manifest.docs) { - const url = `https://raw.githubusercontent.com/${manifest.repo}/${manifest.ref}/${doc.path}`; + const url = `https://raw.githubusercontent.com/${manifest.repo}/${docsRef}/${doc.path}`; let response; try { response = await fetch(url, { headers: { "user-agent": "keeltrading.com-docs-fetch" } }); @@ -106,7 +133,7 @@ for (const doc of manifest.docs) { if (!response.ok) { failures.push( `${doc.path}: HTTP ${response.status} at ${url}\n` + - ` The document may have moved in ${manifest.repo}@${manifest.ref}. ` + + ` The document may have moved in ${manifest.repo}@${docsRef}. ` + `Update engine-docs.manifest.json to the new path — do not hand-copy the doc.`, ); continue; @@ -116,7 +143,7 @@ for (const doc of manifest.docs) { // the page's single H1 (one-h1-per-page). Heading IDs are unaffected. markdown = markdown.replace(/^#\s+.+\n/, ""); markdown = demoteTopLevelHeadings(markdown); - markdown = rewriteRelativeLinks(markdown, doc.path, manifest.repo, manifest.ref); + markdown = rewriteRelativeLinks(markdown, doc.path, manifest.repo, docsRef); writeFileSync(join(DOCS_DIR, `${doc.slug}.md`), markdown); meta.docs.push({ slug: doc.slug, @@ -126,7 +153,7 @@ for (const doc of manifest.docs) { ar: doc.ar, fr: doc.fr ?? doc.en, section: doc.section ?? "reference", - sourceUrl: `https://github.com/${manifest.repo}/blob/${manifest.ref}/${doc.path}`, + sourceUrl: `https://github.com/${manifest.repo}/blob/${docsRef}/${doc.path}`, }); console.log(` fetched ${doc.path} -> engine-docs/${doc.slug}.md`); } diff --git a/scripts/lib/release-tag.mjs b/scripts/lib/release-tag.mjs new file mode 100644 index 0000000..cb22647 --- /dev/null +++ b/scripts/lib/release-tag.mjs @@ -0,0 +1,81 @@ +/** + * Resolve the keel release tag this build describes (#85). + * + * Why a tag and not `main`: an operator runs a published release, so the site + * must describe a published release. The install page already learned this the + * hard way — it described a script served from `main`, and `main` moved the + * Python floor from 3.11 to 3.14 underneath the copy (PR #88). Pinning the docs + * pipeline to the released tag is the same correction, applied upstream of the + * prose: FR-9 says this site describes what shipped. + * + * Resolution order, cheapest first: + * + * 1. data/release.json, when fetch-release.mjs wrote it recently. `npm run + * fetch` runs that script first, so the normal build path costs zero extra + * calls to the releases API. + * 2. The releases API — one call, for a standalone run of the docs fetch. + * 3. The last-known tag in data/release.json, when the API is unreachable. + * That is still a real release tag, and the ref it resolves to is recorded + * in data/docs-meta.json and printed on every doc page, so a reader can + * see exactly which version the page describes. + * + * There is deliberately no fallback to `main`. A silent fallback would + * reintroduce the skew this whole change exists to remove. When no tag can be + * resolved at all, the caller fails the build. + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Manifest sentinel: "whatever the latest published release is at build time". */ +export const LATEST_RELEASE = "latest-release"; + +/** How recent data/release.json must be to be reused without an API call. */ +const FRESH_MS = 60 * 60 * 1000; + +function readReleaseFile(root) { + const file = join(root, "data/release.json"); + if (!existsSync(file)) return null; + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return null; + } +} + +/** + * @param {string} root project root + * @param {string} repo owner/name, e.g. CodeGateSoftware/keel + * @returns {Promise<{tag: string|null, source: string}>} tag is null when + * nothing could be resolved; source explains where the tag came from. + */ +export async function resolveLatestReleaseTag(root, repo) { + const cached = readReleaseFile(root); + const cachedAge = cached?.fetchedAt ? Date.now() - Date.parse(cached.fetchedAt) : NaN; + if (cached?.tag && cachedAge >= 0 && cachedAge < FRESH_MS) { + return { tag: cached.tag, source: "data/release.json, written this build" }; + } + + const headers = { + accept: "application/vnd.github+json", + "user-agent": "keeltrading.com-docs-fetch", + }; + if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + + try { + const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { + headers, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const release = await response.json(); + if (!release?.tag_name) throw new Error("the releases API returned no tag_name"); + return { tag: release.tag_name, source: "the GitHub releases API" }; + } catch (error) { + if (cached?.tag) { + return { + tag: cached.tag, + source: `data/release.json, last known — the releases API is unreachable (${error.message})`, + }; + } + return { tag: null, source: `the releases API is unreachable (${error.message})` }; + } +} diff --git a/src/components/DocsVersionNotice.astro b/src/components/DocsVersionNotice.astro new file mode 100644 index 0000000..08362ff --- /dev/null +++ b/src/components/DocsVersionNotice.astro @@ -0,0 +1,90 @@ +--- +import { localePath } from "../i18n/config"; +import { t } from "../i18n/ui"; + +/** + * #85 — the version-skew notice on an engine document page. + * + * keel's browser UI deep-links here carrying the version it is running: + * /en/docs/glossary/?v=0.11.2#qabd. The docs pipeline pins to the latest + * published release tag, so most readers match and see nothing. When they do + * not match, this says so plainly instead of letting the reader assume the page + * describes their build. + * + * The site is `output: "static"`, so no query parameter exists at build time: + * the comparison happens in the browser. The markup ships hidden and complete, + * and the inline script below only fills one text node and unhides it — the + * same "labels as data attributes, behaviour in one small inline script" + * pattern as the code-block copy button. Because the element is in the document + * from the first parse and is unhidden during parsing, a `#anchor` in the URL + * still lands where it should; the script never reads or writes location.hash. + * + * Anything unexpected in `?v=` renders nothing at all: an unrecognized value is + * not evidence of skew. + * + * English only, by design and by routing: engine documents are published in + * English and `[slug].astro` exists only under /en/ (the ar and fr editions + * ship a docs index, not per-document pages). The Arabic and French strings + * exist in ui.ts so the chrome stays complete if that ever changes. + */ +interface Props { + /** The ref the page was built from — data/docs-meta.json's `ref`. */ + builtRef: string; +} + +const { builtRef } = Astro.props; +const chrome = t("en"); + +/** Only a version tag is comparable. A branch or SHA has nothing to compare. */ +const comparable = /^v?\d+(\.\d+)*/.test(builtRef); + +// Rendered once with placeholders; the script substitutes both versions, so the +// script itself stays free of any locale's wording. +const template = chrome.docs.versionSkew("{running}", "{built}"); +--- + +{ + comparable && ( + + ) +} + +{ + comparable && ( + + ) +} diff --git a/src/components/docs/nav.ts b/src/components/docs/nav.ts index b725777..7238e0c 100644 --- a/src/components/docs/nav.ts +++ b/src/components/docs/nav.ts @@ -48,9 +48,13 @@ interface MetaFile { docs: DocMeta[]; } +/** Only reachable if data/docs-meta.json is missing — a real build cannot get + * here, because the fetch script writes that file or exits non-zero. The ref + * is left empty rather than naming a branch: since #85 the site describes a + * release tag, and a hard-coded "main" here would be a claim, not a default. */ const FALLBACK_META: MetaFile = { repo: "CodeGateSoftware/keel", - ref: "main", + ref: "", fetchedAt: "", sections: [], docs: [], diff --git a/src/components/pages/DocsViewPage.astro b/src/components/pages/DocsViewPage.astro index 757f003..59c1726 100644 --- a/src/components/pages/DocsViewPage.astro +++ b/src/components/pages/DocsViewPage.astro @@ -1,6 +1,7 @@ --- import { render, type CollectionEntry } from "astro:content"; import Base from "../../layouts/Base.astro"; +import DocsVersionNotice from "../DocsVersionNotice.astro"; import DocsSidebar from "../docs/DocsSidebar.astro"; import DocsToc from "../docs/DocsToc.astro"; import { loadDocsMeta, neighbors } from "../docs/nav"; @@ -115,6 +116,8 @@ if (glossaryTerms.length > 0) {

{title}

+ + { docMeta && (

diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 4ebb5b0..8e76879 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -59,6 +59,11 @@ export const ui = { originalLanguageNote: "Engine documents are published in English. The Arabic edition summarizes them; the originals remain the authoritative text.", notTranslated: "This document is not translated. It is shown in English in its original form.", + /** #85 — shown only when keel's ?v= differs from the tag the docs were built from. */ + versionSkewTitle: "These pages describe a different keel version", + versionSkew: (running: string, built: string) => + `You're running keel ${running}. These pages describe ${built}.`, + versionSkewLink: "See what changed", }, news: { from: "Announcements from GitHub Discussions", @@ -152,6 +157,10 @@ export const ui = { originalLanguageNote: "تُنشر وثائق المحرّك بالإنجليزية. وتُلخّصها النسخة العربية، وتبقى النصوص الأصلية هي المرجع المعتمد.", notTranslated: "هذا المستند غير مترجم؛ يُعرض بالإنجليزية بصيغته الأصلية.", + versionSkewTitle: "تصف هذه الصفحات إصدارًا آخر من كيل", + versionSkew: (running: string, built: string) => + `أنت تشغّل كيل ${running}. وتصف هذه الصفحات ${built}.`, + versionSkewLink: "اطّلع على ما تغيّر", }, news: { from: "إعلاناتٌ من نقاشات GitHub", @@ -253,6 +262,10 @@ export const ui = { originalLanguageNote: "Les documents du moteur paraissent en anglais. L'édition française les résume ; les originaux font foi.", notTranslated: "Ce document n'est pas traduit : il est affiché en anglais, dans sa forme d'origine.", + versionSkewTitle: "Ces pages décrivent une autre version de keel", + versionSkew: (running: string, built: string) => + `Vous utilisez keel ${running}. Ces pages décrivent ${built}.`, + versionSkewLink: "Voir ce qui a changé", }, news: { from: "Annonces publiées dans les Discussions GitHub", From 15ee6b5e98478dc80cb2f05430d44d1a55bac50a Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Wed, 26 Aug 2026 19:56:33 -0400 Subject: [PATCH 2/3] fix(docs): validate the resolved tag at the boundary, sanitize what reaches the log (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud's quality gate failed on #92 with `new_security_rating: 2`. Two findings, and the first is worth more than its MINOR severity suggests. ### jssecurity:S5145 — user-controlled data reaching the log `resolved.tag` and `resolved.source` both originate at the GitHub releases API, and `source` quotes a network error verbatim. The tag is also interpolated into `raw.githubusercontent.com` fetch URLs and GitHub blob URLs, so this was never only a logging concern. Fixed at both ends: - **`release-tag.mjs` validates before returning.** `TAG_PATTERN` accepts a plain version tag and nothing else; all three resolution paths — fresh cache, API, stale cache — now go through `trustedTag()`, so an untrusted value is treated as unresolved and the caller fails the build. Verified against: `v0.11.2`, `0.11.2`, `v1.2.3.4`, `v0.11.2-rc.1` trusted; `main`, `../../evil`, `v1;rm -rf`, `""`, an embedded newline, and `%0aInjected` all rejected. Note `main` is rejected by the pattern, which is a second, independent guard on the property this whole change exists to protect. - **`fetch-engine-docs.mjs` sanitizes at the log boundary.** `forLog()` strips control characters and caps length, so no remote string can forge a line in a build log that is read to decide whether a deploy is trustworthy. ### javascript:S7773 — `Number.NaN` over `NaN` Trivial, applied. ### Verification - `npm run check` — 0 errors, 0 warnings - `npm run build` — `docs ref: v0.11.2`, 56 pages, complete - Tag-pattern behaviour exercised directly against the ten cases above Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015cBJ2syTN4VoE73F9sBNGW --- scripts/fetch-engine-docs.mjs | 14 ++++++++++++-- scripts/lib/release-tag.mjs | 32 +++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/scripts/fetch-engine-docs.mjs b/scripts/fetch-engine-docs.mjs index 8d56142..cc45bd9 100644 --- a/scripts/fetch-engine-docs.mjs +++ b/scripts/fetch-engine-docs.mjs @@ -32,19 +32,29 @@ const manifest = JSON.parse( * release tag cannot be resolved the build stops: falling back to `main` would * silently restore the skew this pin exists to remove. */ +/** + * Everything below reaches the build log by way of the releases API — the tag + * itself, and the `source` note, which quotes a network error verbatim. The tag + * is already validated in release-tag.mjs; this strips control characters and + * caps the length so no remote string can forge a line in the build log, which + * is read to decide whether a deploy is trustworthy. + */ +const forLog = (value) => + String(value).replace(/[\u0000-\u001F\u007F]/g, " ").slice(0, 200); + const docsRef = await (async () => { if (manifest.ref !== LATEST_RELEASE) return manifest.ref; const resolved = await resolveLatestReleaseTag(root, manifest.repo); if (!resolved.tag) { console.error( - `\nFAIL: could not resolve the latest release tag for ${manifest.repo} — ${resolved.source}.`, + `\nFAIL: could not resolve the latest release tag for ${manifest.repo} — ${forLog(resolved.source)}.`, ); console.error( "The docs pipeline pins to a published release and never falls back to main (#85).", ); process.exit(1); } - console.log(` docs ref: ${resolved.tag} (from ${resolved.source})`); + console.log(` docs ref: ${forLog(resolved.tag)} (from ${forLog(resolved.source)})`); return resolved.tag; })(); diff --git a/scripts/lib/release-tag.mjs b/scripts/lib/release-tag.mjs index cb22647..51f4eb2 100644 --- a/scripts/lib/release-tag.mjs +++ b/scripts/lib/release-tag.mjs @@ -32,6 +32,22 @@ export const LATEST_RELEASE = "latest-release"; /** How recent data/release.json must be to be reused without an API call. */ const FRESH_MS = 60 * 60 * 1000; +/** + * A git tag this pipeline is willing to trust. The resolved tag is interpolated + * into raw.githubusercontent URLs, GitHub blob URLs and build logs, so it is + * validated at the boundary rather than anywhere downstream: whatever the + * releases API hands back, only a plain version tag gets past this line. + * + * Deliberately narrow. keel tags releases `v0.11.2`; anything that does not + * look like that is treated as unresolved, and the caller fails the build. + */ +const TAG_PATTERN = /^v?[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]{1,32})?$/; + +/** @param {unknown} tag @returns {string|null} the tag, or null if untrusted. */ +function trustedTag(tag) { + return typeof tag === "string" && TAG_PATTERN.test(tag) ? tag : null; +} + function readReleaseFile(root) { const file = join(root, "data/release.json"); if (!existsSync(file)) return null; @@ -50,9 +66,10 @@ function readReleaseFile(root) { */ export async function resolveLatestReleaseTag(root, repo) { const cached = readReleaseFile(root); - const cachedAge = cached?.fetchedAt ? Date.now() - Date.parse(cached.fetchedAt) : NaN; - if (cached?.tag && cachedAge >= 0 && cachedAge < FRESH_MS) { - return { tag: cached.tag, source: "data/release.json, written this build" }; + const cachedAge = cached?.fetchedAt ? Date.now() - Date.parse(cached.fetchedAt) : Number.NaN; + const cachedTag = trustedTag(cached?.tag); + if (cachedTag && cachedAge >= 0 && cachedAge < FRESH_MS) { + return { tag: cachedTag, source: "data/release.json, written this build" }; } const headers = { @@ -67,12 +84,13 @@ export async function resolveLatestReleaseTag(root, repo) { }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const release = await response.json(); - if (!release?.tag_name) throw new Error("the releases API returned no tag_name"); - return { tag: release.tag_name, source: "the GitHub releases API" }; + const apiTag = trustedTag(release?.tag_name); + if (!apiTag) throw new Error("the releases API returned no usable tag_name"); + return { tag: apiTag, source: "the GitHub releases API" }; } catch (error) { - if (cached?.tag) { + if (cachedTag) { return { - tag: cached.tag, + tag: cachedTag, source: `data/release.json, last known — the releases API is unreachable (${error.message})`, }; } From df755a1700507c319550a51600caa9019c22fd27 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Wed, 26 Aug 2026 20:05:33 -0400 Subject: [PATCH 3/3] fix(docs): correct a false claim in this PR, and three overstated ones in its docs (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review returned MERGE WITH FIXES. It could not break the code — 25 hostile `?v=` inputs, nine hostile cache states, and a real FR-4 404 test all behaved. Every finding was a claim that overstated what is true. ### The PR body was wrong about its own output It asserted "no `blob/main` doc link on the page." There are two, on every docs page, from `Footer.astro:20`: $ grep -oh 'keel/\(blob\|tree\)/[^/"]*' dist/en/docs/glossary/index.html | sort | uniq -c 2 keel/blob/main 2 keel/blob/v0.11.2 They are the footer's link to the experiment record, squarely in #91's scope and not this PR's job to fix — but the claim should not have been made. Nothing is currently misrepresented by them: that document is byte-identical at `v0.11.2` and `origin/main` today. The PR body is corrected. ### The last-known-tag tier is a local convenience, not CI resilience Three places described it as a safety net. `data/` is gitignored and no workflow caches it, so on the machine that ships the site `data/release.json` never pre-exists — `fetch-release.mjs` writes it seconds earlier or writes nothing usable. In CI the behaviour is binary: the releases API answers, or the build stops. That is the right policy; it was just described as more forgiving than it is. Said plainly now in `release-tag.mjs` and `DEPLOYMENT.md`. ### Two DEPLOYMENT.md bullets contradicted each other One said the docs pin fails the build when no tag resolves; the next still said "Release / Discussions fetch failures degrade, not fail." Both were written true, and this PR made them incompatible: the release fetch's *tag* is now load-bearing. Reconciled, including the part that matters operationally — the live site is unaffected, because Pages keeps serving the last good deployment. ### `comparable` was unanchored `/^v?\d+(\.\d+)*/` accepted `1d1799e` and `2.0-dev`. Harmless today, since `TAG_PATTERN` already guarantees a plain version tag or a dead build. It bites the moment someone uses the documented escape hatch and puts a literal SHA in `manifest.ref`: a SHA starting with a digit would render "these pages describe 1d1799e", one starting with a letter would render nothing. Anchored. ### The inline-script count was a count of behaviors An English docs page ships four `