From 800ddf90289d9153bb24d56febb06d735049e97a Mon Sep 17 00:00:00 2001 From: Dmitrii Kataraev Date: Wed, 29 Jul 2026 18:52:46 -0700 Subject: [PATCH] chore(ci): gate the registry README sources against silent staleness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1738. The four registry READMEs are not in git — each package's scripts/tasks.js copies one from docs/ at build time, which is why those directories have no README.md. The copy is silent when it breaks, and a registry README cannot be corrected without publishing a new version, so a wrong one stays live until the next release of that artifact. Adds .github/scripts/check-readme-sources.mjs, run as a small CI job. It fails on the three ways this breaks: 1. a README_SRC pointing at a file that does not exist 2. a README.md committed into a package directory, shadowing the generated one 3. a relative link or in a source README (3) is the one worth automating: each registry resolves relative paths against ITSELF rather than against GitHub, so a README that renders perfectly in the repo can render broken on npm, PyPI and the Marketplace at the same time. Currently all four sources are clean (11 links in the Python one, 15 in the VS Code one, zero relative) — the check is there to keep it that way rather than to fix anything. The script also fails if it finds NO tasks.js referencing README_SRC at all, rather than passing vacuously: an empty match would mean the copy convention had moved and the gate had silently stopped checking anything, which is the exact class of failure it exists to prevent. Verified in both directions, because a gate that only ever passes is decoration: clean tree -> exit 0 README_SRC renamed to a missing file -> exit 1, names the file README.md committed in a package -> exit 1, names the path relative link + relative -> exit 1, names file:line and target Exit codes checked without a pipe — `$?` after `node ... | head` reports head's status, not node's, and reading it that way is how a non-gating gate gets shipped. Lives in .github/scripts/ rather than build/ because build/ is gitignored; the script would have existed locally and been missing in CI. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015nTVr6jfSFYm1GppxbjghP --- .github/scripts/check-readme-sources.mjs | 149 +++++++++++++++++++++++ .github/workflows/ci.yml | 20 +++ 2 files changed, 169 insertions(+) create mode 100644 .github/scripts/check-readme-sources.mjs diff --git a/.github/scripts/check-readme-sources.mjs b/.github/scripts/check-readme-sources.mjs new file mode 100644 index 000000000..227bd06c1 --- /dev/null +++ b/.github/scripts/check-readme-sources.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// ============================================================================= +// README source gate +// +// Four artifacts publish a README to a registry — npm (`rocketride`), PyPI +// (`rocketride`), PyPI (`rocketride-mcp`), and the VS Code Marketplace plus +// OpenVSX. None of them stores its README in git: each package's +// scripts/tasks.js copies one in from docs/ at build time, which is why those +// directories have no README.md. +// +// That copy is silent when it goes wrong. If a README_SRC path is renamed on +// one side only, the build copies nothing (or the wrong file) and every check +// stays green while the registry page goes stale — and a registry README cannot +// be corrected without publishing a new version, so the mistake is live until +// the next release of that artifact. +// +// This gate makes the three failure modes loud: +// +// 1. a README_SRC pointing at a file that does not exist +// 2. a README.md committed into a package directory, shadowing the generated +// one — whichever wins is then decided by build order, not intent +// 3. a relative link or image in a source README +// +// (3) needs saying because it is invisible in review: each registry resolves +// relative paths against ITSELF, not against GitHub. A README that renders +// perfectly in the repo can render broken on npm, PyPI and the Marketplace +// simultaneously, and the VS Code packer needs --baseContentUrl to have any +// chance at all. Absolute URLs are the only form that works in all five places. +// +// Run: node .github/scripts/check-readme-sources.mjs +// ============================================================================= + +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, dirname, resolve, basename } from 'node:path'; + +const ROOT = resolve(import.meta.dirname, '..', '..'); +const problems = []; + +/** Directories that may contain a package with a generated README. */ +function findTaskFiles(dir, found = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) findTaskFiles(full, found); + else if (entry.name === 'tasks.js') found.push(full); + } + return found; +} + +// -- 1 + 2: every README_SRC resolves, and nothing shadows the generated file -- +const taskFiles = findTaskFiles(ROOT).filter((f) => + readFileSync(f, 'utf8').includes('README_SRC') +); + +if (taskFiles.length === 0) { + // Fail rather than pass vacuously. An empty match means the convention moved + // and this gate silently stopped checking anything — the exact class of + // failure it exists to prevent. + problems.push( + 'no tasks.js references README_SRC at all — the copy convention has moved ' + + 'and this gate is no longer checking anything. Update it or delete it.' + ); +} + +const sources = new Set(); + +for (const taskFile of taskFiles) { + const src = readFileSync(taskFile, 'utf8'); + const rel = relative(ROOT, taskFile); + + // const README_SRC = path.join(DOCS_DIR, 'README-x.md') + const m = src.match(/README_SRC\s*=\s*path\.join\(\s*DOCS_DIR\s*,\s*['"]([^'"]+)['"]/); + if (!m) { + problems.push( + `${rel}: mentions README_SRC but not in the expected ` + + `path.join(DOCS_DIR, '...') form — this gate cannot verify it` + ); + continue; + } + + const docFile = join(ROOT, 'docs', m[1]); + if (!existsSync(docFile)) { + problems.push(`${rel}: README_SRC -> docs/${m[1]} does not exist`); + } else { + sources.add(docFile); + } + + // scripts/tasks.js -> the package root two levels up + const pkgDir = dirname(dirname(taskFile)); + for (const name of ['README.md', 'readme.md']) { + if (existsSync(join(pkgDir, name))) { + problems.push( + `${relative(ROOT, join(pkgDir, name))}: committed, but this package's ` + + `README is generated from docs/${m[1]} — delete the committed copy` + ); + } + } +} + +// -- 3: no relative links or images in any source README ---------------------- +// Anchors, mailto: and absolute URLs are fine everywhere. Anything else is a +// path, and a path resolves differently on each registry. +const LINK = /(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; +const ABSOLUTE = /^(https?:\/\/|mailto:|#)/; + +for (const docFile of [...sources].sort()) { + const text = readFileSync(docFile, 'utf8'); + const rel = relative(ROOT, docFile); + const lines = text.split('\n'); + + lines.forEach((line, i) => { + for (const match of line.matchAll(LINK)) { + const [, bang, target] = match; + if (ABSOLUTE.test(target)) continue; + problems.push( + `${rel}:${i + 1}: ${bang ? 'image' : 'link'} target "${target}" is ` + + `relative — registries resolve it against themselves, not GitHub. ` + + `Use an absolute URL.` + ); + } + }); + + // Bare too — HTML is common in badge rows and PyPI sanitises + // it inconsistently, but a relative src is broken everywhere regardless. + for (const match of text.matchAll(/]*src=["']([^"']+)["']/gi)) { + if (!ABSOLUTE.test(match[1])) { + problems.push( + `${rel}: is relative — use an absolute URL` + ); + } + } +} + +// -- report ------------------------------------------------------------------- +const checked = [...sources].map((f) => `docs/${basename(f)}`).sort(); + +if (problems.length > 0) { + console.error(`README source gate FAILED (${problems.length} problem(s)):\n`); + for (const p of problems) console.error(` - ${p}`); + console.error( + `\nChecked ${taskFiles.length} copy step(s) and ${checked.length} source file(s).` + ); + process.exit(1); +} + +console.log( + `README source gate OK — ${taskFiles.length} copy step(s), ` + + `${checked.length} source(s): ${checked.join(', ')}` +); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edeed1460..0188fb634 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,6 +215,26 @@ jobs: - run: ruff check - run: ruff format --check + # --------------------------------------------------------------------------- + # README sources — the four registry READMEs (npm, PyPI x2, VS Code + # Marketplace + OpenVSX) are NOT stored in git: each package's + # scripts/tasks.js copies one in from docs/ at build time. That copy is + # silent when it breaks, and a registry README cannot be corrected without + # publishing a new version — so a stale one is live until the next release + # of that artifact. This makes the three ways it breaks loud instead. + # --------------------------------------------------------------------------- + readme-sources: + name: README sources + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + # Dependency-free (node:fs + node:path only), so no install step. + - run: node .github/scripts/check-readme-sources.mjs + # --------------------------------------------------------------------------- # gitleaks — secret scan. Installs the binary directly because the upstream # gitleaks-action v2 requires a paid GITLEAKS_LICENSE for org-owned repos;