-
Notifications
You must be signed in to change notification settings - Fork 1.9k
chore(ci): gate the registry README sources against silent staleness #1739
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <img src="..."> 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(/<img\s[^>]*src=["']([^"']+)["']/gi)) { | ||
| if (!ABSOLUTE.test(match[1])) { | ||
| problems.push( | ||
| `${rel}: <img src="${match[1]}"> is relative — use an absolute URL` | ||
| ); | ||
| } | ||
| } | ||
|
Comment on lines
+103
to
+131
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Catch reference-style and HTML anchor links. Relative targets in reference definitions ( 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // -- 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(', ')}` | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+231
to
+234
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Workflow outline:"
wc -l .github/workflows/ci.yml
sed -n '1,280p' .github/workflows/ci.yml
echo
echo "Relevant checkout/setup-node/git usages:"
rg -n "actions/checkout|actions/setup-node|persist-credentials|permissions:|permissions: contents|git |credential|gh " .github/workflows/ci.yml
echo
echo "Git usages in tracked repository files, showing contexts:"
rg -n "git " .github/workflows/ci.yml . || trueRepository: rocketride-org/rocketride-server Length of output: 27448 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "README sources job with context:"
sed -n '216,310p' .github/workflows/ci.yml
echo
echo "All checkout steps with persist-credentials/job permissions around them:"
python3 - <<'PY'
from pathlib import Path
p=Path('.github/workflows/ci.yml')
lines=p.read_text().splitlines()
for i,l in enumerate(lines,1):
if 'actions/checkout' in l:
start=max(1,i-4); end=min(len(lines),i+7)
print(f'\n# checkout around line {i}')
for n in range(start,end+1):
print(f'{n}: {lines[n-1]}')
PYRepository: rocketride-org/rocketride-server Length of output: 9463 Scope this checkout to read-only credentials. The README sources job reads the checkout only to run a local script; configure 🧰 Tools🪛 zizmor (1.28.0)[warning] 231-231: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false (artipacked) [error] 232-232: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): enables caching by default (cache-poisoning) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| # Dependency-free (node:fs + node:path only), so no install step. | ||
| - run: node .github/scripts/check-readme-sources.mjs | ||
|
Comment on lines
+226
to
+236
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Make the README gate block
🧰 Tools🪛 zizmor (1.28.0)[warning] 231-231: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false (artipacked) [error] 232-232: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): enables caching by default (cache-poisoning) 🤖 Prompt for AI Agents |
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # gitleaks — secret scan. Installs the binary directly because the upstream | ||
| # gitleaks-action v2 requires a paid GITLEAKS_LICENSE for org-owned repos; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit —
statSyncis imported but never used.This line imports
statSyncfromnode:fs, but the script never calls it (onlyreadFileSync,existsSync, andreaddirSyncare used). Drop it to keep the import clean.