Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions .github/scripts/check-readme-sources.mjs
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';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — statSync is imported but never used.

This line imports statSync from node:fs, but the script never calls it (only readFileSync, existsSync, and readdirSync are used). Drop it to keep the import clean.

Suggested change
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { readFileSync, existsSync, readdirSync } 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 ([docs]: ./docs) and <a href="./docs"> bypass this gate entirely. That allows exactly the registry-broken links this check is intended to reject. Validate reference destinations and HTML href attributes in addition to inline Markdown and <img src>. Add fixtures for both forms.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/check-readme-sources.mjs around lines 103 - 131, Extend the
link validation around the existing LINK and <img src> checks to also inspect
Markdown reference definitions such as “[name]: ./target” and HTML <a
href="..."> attributes. Reuse ABSOLUTE to ignore absolute, mailto, and fragment
targets, and add the same relative-target diagnostics with appropriate link
context; include fixtures covering both reference-style and HTML anchor links.

}

// -- 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(', ')}`
);
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 . || true

Repository: 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]}')
PY

Repository: 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 actions/checkout with persist-credentials: false and set the job-level permissions: contents: read so the runner-local token is not left writable in the Git config.

🧰 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 231 - 234, Update the README sources
job around actions/checkout and actions/setup-node to set job-level permissions
to contents: read and configure actions/checkout with persist-credentials:
false. Keep the existing checkout revision and Node setup unchanged.

Source: 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the README gate block CI OK.

ci-ok is documented as the only required branch-protection job, but its needs list and result output omit readme-sources. A failing validator therefore leaves CI OK green and does not enforce this gate. Add readme-sources to ci-ok.needs and its diagnostic output.

🧰 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 226 - 236, Update the ci-ok job to
include readme-sources in its needs list and diagnostic result output, ensuring
a failing README validator causes CI OK to fail and remains visible in the
status summary.


# ---------------------------------------------------------------------------
# gitleaks — secret scan. Installs the binary directly because the upstream
# gitleaks-action v2 requires a paid GITLEAKS_LICENSE for org-owned repos;
Expand Down
Loading