diff --git a/apps/api/scripts/lint-meta/rules/ci/engine-pin-parity.ts b/apps/api/scripts/lint-meta/rules/ci/engine-pin-parity.ts
index 5bd79493..4240aa29 100644
--- a/apps/api/scripts/lint-meta/rules/ci/engine-pin-parity.ts
+++ b/apps/api/scripts/lint-meta/rules/ci/engine-pin-parity.ts
@@ -31,7 +31,12 @@ function findParentPackageJsonDir(root: string): string | null {
}
}
-export function checkEnginePinParity(root: string): IViolation[] {
+const BUN_VERSION_PIN_REGEX = /bun-version:\s*["']?([\w.+-]+)["']?/gu;
+
+export function checkEnginePinParity(
+ root: string,
+ workflowFiles: readonly string[] = []
+): IViolation[] {
const violations: IViolation[] = [];
const pkg = readApiPackageJson(root);
const bunVersion = pkg?.engines?.bun;
@@ -78,17 +83,30 @@ export function checkEnginePinParity(root: string): IViolation[] {
}
}
- const ciWorkflow = join(root, ".github", "workflows", "ci.yml");
+ /*
+ * Every GitHub Actions workflow that pins a Bun version via
+ * `setup-bun` must match engines.bun. The monorepo keeps its workflows
+ * at the repo root, not under apps/api, and a single bump can leave one
+ * of a dozen `bun-version:` literals behind — so check every occurrence
+ * in every workflow, not just the first match.
+ */
+ for (const workflow of workflowFiles) {
+ if (!existsSync(workflow)) {
+ continue;
+ }
- if (existsSync(ciWorkflow)) {
- const content = readFileSync(ciWorkflow, "utf8");
+ const content = readFileSync(workflow, "utf8");
- if (!content.includes(`bun-version: ${bunVersion}`)) {
- violations.push({
- file: ciWorkflow,
- rule: RULE_ID,
- message: `CI workflow must pin bun-version: ${bunVersion} to match package.json engines.bun.`,
- });
+ for (const match of content.matchAll(BUN_VERSION_PIN_REGEX)) {
+ const pinned = match[1];
+
+ if (pinned !== undefined && pinned !== bunVersion) {
+ violations.push({
+ file: workflow,
+ rule: RULE_ID,
+ message: `CI workflow pins bun-version: ${pinned} but must match package.json engines.bun (${bunVersion}).`,
+ });
+ }
}
}
@@ -101,7 +119,7 @@ export const enginePinParityRule: IMetaRule = {
category: "ci",
description:
"Bun version pin must stay aligned across package.json, Docker, and CI.",
- run({ root }) {
- return checkEnginePinParity(root);
+ run({ root, workflowFiles }) {
+ return checkEnginePinParity(root, workflowFiles);
},
};
diff --git a/apps/api/tests/lint-meta/lint-meta.test.ts b/apps/api/tests/lint-meta/lint-meta.test.ts
index 3a34d7cf..62fb035e 100644
--- a/apps/api/tests/lint-meta/lint-meta.test.ts
+++ b/apps/api/tests/lint-meta/lint-meta.test.ts
@@ -1610,10 +1610,12 @@ describe("checkEnginePinParity", () => {
workflowBun: "1.2.0",
});
- const violations = checkEnginePinParity(root);
+ const violations = checkEnginePinParity(root, [
+ join(root, ".github", "workflows", "ci.yml"),
+ ]);
expect(
- violations.some((row) => row.message.includes("bun-version: 1.3.14"))
+ violations.some((row) => row.message.includes("bun-version: 1.2.0"))
).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
@@ -1630,13 +1632,51 @@ describe("checkEnginePinParity", () => {
workflowBun: "1.3.14",
});
- const violations = checkEnginePinParity(root);
+ const violations = checkEnginePinParity(root, [
+ join(root, ".github", "workflows", "ci.yml"),
+ ]);
expect(violations).toEqual([]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+
+ test("flags a drifted bun-version in any of several root workflows", () => {
+ const root = mkdtempSync(join(tmpdir(), "lint-meta-engine-multi-"));
+
+ try {
+ writeFileSync(
+ join(root, PKG_JSON),
+ JSON.stringify({ engines: { bun: "1.3.14" } })
+ );
+ writeFileSync(
+ join(root, "Dockerfile"),
+ "FROM oven/bun:1.3.14-alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n"
+ );
+ mkdirSync(join(root, ".github", "workflows"), { recursive: true });
+
+ const good = join(root, ".github", "workflows", "apps-ui-validate.yml");
+ const drifted = join(root, ".github", "workflows", "apps-api-ci.yml");
+
+ writeFileSync(
+ good,
+ "jobs:\n a:\n steps:\n - uses: oven-sh/setup-bun@abc\n with:\n bun-version: 1.3.14\n"
+ );
+ writeFileSync(
+ drifted,
+ "jobs:\n b:\n steps:\n - uses: oven-sh/setup-bun@abc\n with:\n bun-version: 1.2.0\n"
+ );
+
+ const violations = checkEnginePinParity(root, [good, drifted]);
+
+ expect(violations).toHaveLength(1);
+ expect(violations[0]?.file).toBe(drifted);
+ expect(violations[0]?.message).toContain("bun-version: 1.2.0");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
});
describe("checkEnvSchemaDrift", () => {
diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs
index 255e491b..c8f381d7 100644
--- a/apps/docs/astro.config.mjs
+++ b/apps/docs/astro.config.mjs
@@ -5,6 +5,7 @@ import sitemap from "@astrojs/sitemap";
import starlight from "@astrojs/starlight";
import tailwindcss from "@tailwindcss/vite";
import mermaid from "astro-mermaid";
+import remarkGfm from "remark-gfm";
import starlightLlmsTxt from "starlight-llms-txt";
// Wrap every
so wide content stays keyboard-scrollable in Safari/Firefox.
@@ -107,6 +108,11 @@ export default defineConfig({
},
markdown: {
+ // GFM is normally Astro's default, but Starlight's MDX pipeline was not
+ // emitting / for pipe tables and strikethrough — every table
+ // in the docs rendered as literal `| … |` text. Wiring remark-gfm
+ // explicitly restores GFM table/strikethrough/autolink parsing.
+ remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeAccessibleTables],
},
diff --git a/apps/docs/bun.lock b/apps/docs/bun.lock
index 5ff38605..95445e5e 100644
--- a/apps/docs/bun.lock
+++ b/apps/docs/bun.lock
@@ -16,6 +16,7 @@
"react": "19.2.7",
"react-dom": "19.2.7",
"reading-time": "1.5.0",
+ "remark-gfm": "4.0.1",
"sharp": "0.34.5",
"tailwindcss": "4.3.0",
"ws": "8.21.0",
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 71acdc00..eb9fcab9 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -20,10 +20,11 @@
"check:scripts-docs": "bun run scripts/generate-scripts-docs.mjs --check",
"check:docs-data": "bun run check:lint-meta-docs && bun run check:scripts-docs",
"check:fragments": "bun run scripts/check-fragments.mjs",
+ "check:rendered-markdown": "bun run scripts/check-rendered-markdown.mjs",
"check:components": "bun run scripts/check-unused-components.mjs",
"build:site": "bun run generate:og-image && astro build",
"build": "bun run generate:og-image && astro build",
- "build:ci": "bun run check:docs-data && bun run check:components && bun run generate:og-image && astro build && bun run check:fragments",
+ "build:ci": "bun run check:docs-data && bun run check:components && bun run generate:og-image && astro build && bun run check:fragments && bun run check:rendered-markdown",
"preview": "bun run build:site && wrangler dev",
"astro": "astro",
"deploy": "bun run build:ci && wrangler deploy",
@@ -42,6 +43,7 @@
"react": "19.2.7",
"react-dom": "19.2.7",
"reading-time": "1.5.0",
+ "remark-gfm": "4.0.1",
"sharp": "0.34.5",
"tailwindcss": "4.3.0",
"ws": "8.21.0"
diff --git a/apps/docs/scripts/check-rendered-markdown.mjs b/apps/docs/scripts/check-rendered-markdown.mjs
new file mode 100644
index 00000000..2b20f3b5
--- /dev/null
+++ b/apps/docs/scripts/check-rendered-markdown.mjs
@@ -0,0 +1,73 @@
+#!/usr/bin/env node
+/*
+ * Guard against GFM markdown that ships to readers as literal source text.
+ *
+ * A misconfigured remark pipeline (e.g. GFM not wired into Starlight's MDX
+ * processor) silently renders pipe tables as `| cell | cell |` and the
+ * `|---|---|` delimiter row as plain text instead of . The build
+ * still succeeds, so the breakage only surfaces in the browser. This check
+ * scans the built HTML for that residue and fails the build if it finds any.
+ *
+ * Detected residue (outside /, where such syntax is shown as a
+ * deliberate example):
+ * - GFM table delimiter rows: `|---|`, `| --- |`, `|:--|`, etc.
+ *
+ * Usage: node scripts/check-rendered-markdown.mjs (after `astro build`)
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const DIST = resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist");
+
+function walkHtml(dir) {
+ const out = [];
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) {
+ out.push(...walkHtml(full));
+ } else if (entry.endsWith(".html")) {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+/* Reduce a page to its visible prose so we only test real rendered text.
+ * Order matters: drop code blocks (where literal table syntax is a legitimate
+ * example) first, then strip every remaining tag — this also removes attribute
+ * payloads like Expressive Code's `data-code="…"` copy-button cache, which
+ * mirrors the code sample and would otherwise read as a false positive. */
+function toProse(html) {
+ return html
+ .replace(//gi, " ")
+ .replace(//gi, " ")
+ .replace(/<[^>]+>/g, " ");
+}
+
+/* A GFM table delimiter row: a pipe adjacent to a run of 2+ dashes. Real
+ * prose effectively never contains this; a rendered table never does either
+ * (the delimiter becomes structure). */
+const DELIMITER = /\|\s*:?-{2,}|-{2,}:?\s*\|/;
+
+const offenders = [];
+for (const file of walkHtml(DIST)) {
+ const text = toProse(readFileSync(file, "utf8"));
+ if (DELIMITER.test(text)) {
+ offenders.push(relative(DIST, file));
+ }
+}
+
+if (offenders.length > 0) {
+ console.error(
+ "✗ Unrendered GFM table syntax found in built HTML (GFM pipeline broken?):",
+ );
+ for (const f of offenders) console.error(` • ${f}`);
+ console.error(
+ "\nPipe tables are rendering as literal text. Ensure remark-gfm is wired\n" +
+ "into markdown.remarkPlugins in astro.config.mjs.",
+ );
+ process.exit(1);
+}
+
+console.log("✓ No unrendered markdown table syntax in built HTML");
diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts
index b3d39539..a3fd2460 100644
--- a/apps/ui/playwright.config.ts
+++ b/apps/ui/playwright.config.ts
@@ -1,6 +1,8 @@
import { defineConfig, devices } from "@playwright/test";
-const PORT = 7331;
+// Default dev/e2e port is 7331; override with PLAYWRIGHT_PORT when the UI
+// dev server is remapped (Docker port mapping, port conflicts on shared hosts).
+const PORT = Number(process.env.PLAYWRIGHT_PORT) || 7331;
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({