Skip to content
Merged
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
42 changes: 30 additions & 12 deletions apps/api/scripts/lint-meta/rules/ci/engine-pin-parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}).`,
});
}
}
}

Expand All @@ -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);
},
};
46 changes: 43 additions & 3 deletions apps/api/tests/lint-meta/lint-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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", () => {
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <table> so wide content stays keyboard-scrollable in Safari/Firefox.
Expand Down Expand Up @@ -107,6 +108,11 @@ export default defineConfig({
},

markdown: {
// GFM is normally Astro's default, but Starlight's MDX pipeline was not
// emitting <table>/<del> 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],
},

Expand Down
1 change: 1 addition & 0 deletions apps/docs/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
73 changes: 73 additions & 0 deletions apps/docs/scripts/check-rendered-markdown.mjs
Original file line number Diff line number Diff line change
@@ -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 <table>. 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 <pre>/<code>, 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(/<pre[\s\S]*?<\/pre>/gi, " ")
.replace(/<code[\s\S]*?<\/code>/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 <table> 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");
4 changes: 3 additions & 1 deletion apps/ui/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
Loading