diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index ca6275e91..d425e2991 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -276,8 +276,8 @@ function Releases() { }} > godot-iap 3.3.3 declares the Apple-only GDExtension with the key - Godot actually reads, so editors on Windows and Linux stop logging a - missing-library error on every project scan. + Godot actually reads, so Godot 4.8 editors on Windows and Linux skip + it instead of logging a missing-library error on every project scan.

Framework libraries
diff --git a/scripts/audit-release-sync-script.mjs b/scripts/audit-release-sync-script.mjs index 9967c29ca..54a032fb0 100644 --- a/scripts/audit-release-sync-script.mjs +++ b/scripts/audit-release-sync-script.mjs @@ -1,66 +1,143 @@ #!/usr/bin/env node -// The release lanes all call sync-release-generated.sh, and nothing else ever -// runs it — a dropped line continuation in its `git add` shipped once and only -// surfaced mid-release (exit 126, the next path ran as a command). Neither -// `bash -n` nor shellcheck flags that, so check the shape here. +// Every release lane calls sync-release-generated.sh and nothing else runs it, +// so a dropped line continuation in its `git add` shipped once and only +// surfaced mid-release: bash ended the command early and ran the next path as +// a command (exit 126, "is a directory"). Neither `bash -n` nor shellcheck +// flags that — an orphaned path is a syntactically valid command — so this +// audit reproduces bash's own continuation rules and then asserts that no +// logical command starts with a repository path. -import { readFileSync, existsSync } from 'node:fs'; +import { + readFileSync, + existsSync, + statSync, + accessSync, + constants, +} from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; -const SCRIPT = 'scripts/sync-release-generated.sh'; +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPT = "scripts/sync-release-generated.sh"; + +// A line continues only when it ends in an odd number of backslashes at the +// very end of the line. `foo \ ` (backslash, space) is an escaped space, so +// bash ends the command there — the exact trap the previous check missed. +function continuesLine(line) { + const match = /(\\+)$/u.exec(line); + return match ? match[1].length % 2 === 1 : false; +} + +export function toLogicalLines(source) { + const logical = []; + let buffer = null; + + source.split("\n").forEach((raw, index) => { + const isContinuation = buffer !== null; + const text = isContinuation ? raw : raw.replace(/^\s+/u, ""); + + if (!isContinuation && (text === "" || text.startsWith("#"))) return; + + // Bash deletes the backslash-newline pair outright, so `a\b` is + // the single token `ab`. Concatenate rather than joining with a space. + if (continuesLine(raw)) { + const joined = text.replace(/\\$/u, ""); + buffer = buffer + ? { ...buffer, text: buffer.text + joined } + : { line: index + 1, text: joined }; + return; + } + + logical.push( + buffer + ? { line: buffer.line, text: buffer.text + text } + : { line: index + 1, text }, + ); + buffer = null; + }); + + if (buffer) logical.push(buffer); + return logical; +} + +// git refuses to stage outside the work tree, so a token that escapes the +// repository fails the release rather than this audit unless caught here. +function insideRepo(target) { + const rel = relative(REPO_ROOT, target); + // An empty relative path is REPO_ROOT itself, which `git add .` stages. + return !rel.startsWith("..") && !isAbsolute(rel); +} + +function isRunnable(target) { + if (statSync(target).isDirectory()) return false; + try { + accessSync(target, constants.X_OK); + return true; + } catch { + return false; + } +} export function auditGitAddBlocks(source) { const failures = []; - const lines = source.split('\n'); + const logical = toLogicalLines(source); + let sawGitAdd = false; - lines.forEach((line, index) => { - if (!/^\s*git add\s*\\\s*$/.test(line)) return; + logical.forEach(({ line, text }) => { + const first = text.trim().split(/\s+/u)[0] ?? ""; - for (let i = index + 1; i < lines.length; i += 1) { - const raw = lines[i]; - const continues = /\\\s*$/.test(raw); - const path = raw.replace(/\\\s*$/, '').trim(); - - if (path === '') { - failures.push(`${SCRIPT}:${i + 1}: blank line inside the git add list`); - break; + // Running a path is fine when it is an executable script; a directory or + // a plain file can only have become a command by accident. + if ( + first.includes("/") && + !first.startsWith("$") && + !first.startsWith('"') + ) { + const target = join(REPO_ROOT, first.replace(/^\.\//u, "")); + if (existsSync(target) && !isRunnable(target)) { + failures.push( + `${SCRIPT}:${line}: "${first}" runs as a command; a line above it lost its "\\"`, + ); } + } - failures.push(...auditPath(path, i + 1)); + if (!/^git add\b/u.test(text.trim())) return; + sawGitAdd = true; - if (!continues) { - // Last entry. A following indented path means a lost continuation. - const next = lines[i + 1] ?? ''; - if (/^\s+\S/.test(next) && !/^\s*(#|fi\b|done\b|esac\b|\})/.test(next)) { + text + .trim() + .replace(/^git add\s*/u, "") + .split(/\s+/u) + .filter((token) => token && !token.startsWith("-")) + .forEach((token) => { + if (token.includes("$") || token.includes("*")) return; + const target = resolve(REPO_ROOT, token); + if (!insideRepo(target)) { failures.push( - `${SCRIPT}:${i + 1}: git add list ends here but line ${i + 2} ` + - `("${next.trim()}") is indented — a lost "\\" would run it as a command`, + `${SCRIPT}:${line}: staged path escapes the repository: ${token}`, ); + return; } - break; - } - } + if (!existsSync(target)) { + failures.push( + `${SCRIPT}:${line}: staged path does not exist: ${token}`, + ); + } + }); }); - if (!/^\s*git add\s*\\\s*$/m.test(source)) { - failures.push(`${SCRIPT}: no multi-line git add block found`); - } - + if (!sawGitAdd) failures.push(`${SCRIPT}: no git add command found`); return failures; } -function auditPath(path, line) { - if (path.includes('$') || path.includes('*')) return []; - return existsSync(path) - ? [] - : [`${SCRIPT}:${line}: staged path does not exist: ${path}`]; -} - -if (import.meta.url === `file://${process.argv[1]}`) { - const failures = auditGitAddBlocks(readFileSync(SCRIPT, 'utf8')); +if (fileURLToPath(import.meta.url) === process.argv[1]) { + const failures = auditGitAddBlocks( + readFileSync(join(REPO_ROOT, SCRIPT), "utf8"), + ); if (failures.length) { - console.error('Release sync script audit failed:'); - failures.forEach((f) => console.error(`- ${f}`)); + console.error("Release sync script audit failed:"); + failures.forEach((failure) => console.error(`- ${failure}`)); process.exit(1); } - console.log('Release sync script audit passed.'); + console.log("Release sync script audit passed."); } diff --git a/scripts/audit-release-sync-script.test.mjs b/scripts/audit-release-sync-script.test.mjs index 844eec603..4015cb062 100644 --- a/scripts/audit-release-sync-script.test.mjs +++ b/scripts/audit-release-sync-script.test.mjs @@ -1,44 +1,103 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; -import { auditGitAddBlocks } from './audit-release-sync-script.mjs'; +import { + auditGitAddBlocks, + toLogicalLines, +} from "./audit-release-sync-script.mjs"; -test('catches the dropped line continuation that broke a release', () => { - const broken = [ - 'git add \\', - ' packages/docs/public/llms.txt \\', - ' knowledge/_agent-context/context.md', - ' knowledge/_claude-context', - ].join('\n'); +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); - const failures = auditGitAddBlocks(broken); +const HEAD = ["git add \\", " packages/docs/public/llms.txt \\"]; + +function orphaned(...between) { + return [ + ...HEAD, + " knowledge/_agent-context/context.md", + ...between, + " knowledge/_claude-context", + ].join("\n"); +} + +test("catches the dropped continuation that broke a release", () => { + const failures = auditGitAddBlocks(orphaned()); assert.equal(failures.length, 1); - assert.match(failures[0], /would run it as a command/u); + assert.match(failures[0], /runs as a command/u); +}); + +test("catches an orphaned path separated by a blank line", () => { + assert.match(auditGitAddBlocks(orphaned(""))[0], /runs as a command/u); +}); + +test("catches an orphaned path separated by a comment", () => { + assert.match( + auditGitAddBlocks(orphaned(" # agent context symlink"))[0], + /runs as a command/u, + ); +}); + +test("treats a backslash followed by a space as the end of the command", () => { + // `\ ` is an escaped space in bash, not a continuation. + const source = ["git add \\ ", " knowledge/_claude-context"].join("\n"); + assert.ok(auditGitAddBlocks(source).length > 0); +}); + +test("accepts the list once every continuation is intact", () => { + const source = [ + ...HEAD, + " knowledge/_agent-context/context.md \\", + " knowledge/_claude-context", + ].join("\n"); + assert.deepEqual(auditGitAddBlocks(source), []); +}); + +test("allows running an executable script by path", () => { + assert.deepEqual( + auditGitAddBlocks(["./scripts/sync-versions.sh", "git add ."].join("\n")), + [], + ); }); -test('accepts the same list once the continuation is restored', () => { - const fixed = [ - 'git add \\', - ' packages/docs/public/llms.txt \\', - ' knowledge/_agent-context/context.md \\', - ' knowledge/_claude-context', - ].join('\n'); +test("reports a staged path that no longer exists", () => { + const source = ["git add \\", " knowledge/_removed-context/context.md"].join( + "\n", + ); + assert.match(auditGitAddBlocks(source)[0], /staged path does not exist/u); +}); - assert.deepEqual(auditGitAddBlocks(fixed), []); +test("joins continued lines into one logical command", () => { + const logical = toLogicalLines(["git add \\", " a \\", " b"].join("\n")); + assert.equal(logical.length, 1); + assert.deepEqual(logical[0].text.trim().split(/\s+/u), [ + "git", + "add", + "a", + "b", + ]); }); -test('reports a staged path that no longer exists', () => { - const stale = ['git add \\', ' knowledge/_removed-context/context.md'].join( - '\n', +test("keeps a token split across a continuation adjacent, as bash does", () => { + // bash deletes the backslash-newline pair: `llms.tx\t` is llms.txt. + const logical = toLogicalLines( + ["git add packages/docs/public/llms.tx\\", "t"].join("\n"), ); + assert.equal(logical.length, 1); + assert.equal(logical[0].text, "git add packages/docs/public/llms.txt"); + assert.deepEqual(auditGitAddBlocks(logical[0].text), []); +}); - const failures = auditGitAddBlocks(stale); - assert.equal(failures.length, 1); - assert.match(failures[0], /staged path does not exist/u); +test("rejects a staged path that escapes the repository", () => { + const source = ["git add \\", " ../../../etc/passwd"].join("\n"); + assert.match(auditGitAddBlocks(source)[0], /escapes the repository/u); }); -test('the committed script passes its own audit', () => { - const source = readFileSync('scripts/sync-release-generated.sh', 'utf8'); +test("the committed script passes its own audit", () => { + const source = readFileSync( + join(REPO_ROOT, "scripts/sync-release-generated.sh"), + "utf8", + ); assert.deepEqual(auditGitAddBlocks(source), []); });