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;