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), []);
});