diff --git a/.github/workflows/docs-live-smoke.yml b/.github/workflows/docs-live-smoke.yml
index ffef3839b3..a8f2f2f9b1 100644
--- a/.github/workflows/docs-live-smoke.yml
+++ b/.github/workflows/docs-live-smoke.yml
@@ -2,27 +2,64 @@ name: Docs Live Smoke
on:
workflow_dispatch:
+ inputs:
+ commit_range:
+ description: "Optional ..
git range; docs pages added within it are asserted live in addition to the fixed probes."
+ type: string
+ required: false
+ default: ""
+ schedule:
+ - cron: "23 6 * * *"
permissions:
contents: read
concurrency:
- group: docs-live-smoke-${{ github.ref }}
+ group: docs-live-smoke-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
smoke:
+ if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
+ - name: Check out
+ uses: actions/checkout@v7.0.1
+ with:
+ fetch-depth: 1
+
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 22
+ - name: Derive newly added routes
+ id: added-routes
+ if: inputs.commit_range != ''
+ env:
+ COMMIT_RANGE: ${{ inputs.commit_range }}
+ run: |
+ set -euo pipefail
+ if [[ ! "${COMMIT_RANGE}" =~ ^[0-9a-f]{7,40}\.\.[0-9a-f]{7,40}$ ]]; then
+ echo "Invalid commit_range: expected .. with 7-40 lowercase hex characters per commit." >&2
+ exit 1
+ fi
+ base="${COMMIT_RANGE%%..*}"
+ head="${COMMIT_RANGE##*..}"
+ git fetch --quiet --no-tags --depth=1 origin "${base}" "${head}"
+ git diff --name-only --no-renames --diff-filter=A "${base}" "${head}" -- docs | node scripts/docs-site/added-routes.mjs > /tmp/openclaw-added-routes.txt
+ echo "Newly added routes:"
+ if [ -s /tmp/openclaw-added-routes.txt ]; then
+ cat /tmp/openclaw-added-routes.txt
+ else
+ echo "none"
+ fi
+
- name: Smoke live docs pages
env:
BASE_URL: https://docs.openclaw.ai
GITHUB_SHA: ${{ github.sha }}
+ EXTRA_PAGES_FILE: /tmp/openclaw-added-routes.txt
PAGES: |
/tools/reactions
/plugins/google-meet
@@ -36,6 +73,19 @@ jobs:
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
+ const fs = await import("node:fs");
+ const extraPagesFile = process.env.EXTRA_PAGES_FILE;
+ const fixedPageCount = pages.length;
+ if (extraPagesFile && fs.existsSync(extraPagesFile)) {
+ const extraPages = fs.readFileSync(extraPagesFile, "utf8")
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+ for (const page of extraPages) {
+ if (!pages.includes(page)) pages.push(page);
+ }
+ }
+ console.log(`Added ${pages.length - fixedPageCount} extra routes.`);
const poison = [
/\banalysis\s+to=functions\./iu,
/\b(?:commentary|final)\s+to=functions\./iu,
@@ -211,3 +261,96 @@ jobs:
}
throw lastError ?? new Error("Docs live smoke timed out.");
NODE
+
+ # docs_map.md covers English routes only; locale coverage comes from the per-dispatch added-route assertions.
+ sample:
+ if: github.event_name == 'schedule'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out main
+ uses: actions/checkout@v7.0.1
+ with:
+ ref: main
+ fetch-depth: 1
+
+ - name: Setup Node
+ uses: actions/setup-node@v7
+ with:
+ node-version: 22
+
+ - name: Sample live docs routes
+ env:
+ BASE_URL: https://docs.openclaw.ai
+ run: |
+ node - <<'NODE'
+ import fs from "node:fs";
+
+ const routes = [...new Set(fs.readFileSync("docs/docs_map.md", "utf8")
+ .split(/\r?\n/)
+ .map((line) => line.match(/^- Route: (\/\S*)$/)?.[1])
+ .filter(Boolean))].sort();
+ if (routes.length < 100) {
+ throw new Error(`Expected at least 100 docs_map.md routes, parsed ${routes.length}.`);
+ }
+ const runId = process.env.GITHUB_RUN_ID;
+ let seed = Number(BigInt(runId) & 0xffffffffn);
+ for (let index = routes.length - 1; index > 0; index -= 1) {
+ seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
+ const other = Math.floor(seed / 0x100000000 * (index + 1));
+ [routes[index], routes[other]] = [routes[other], routes[index]];
+ }
+ const pages = routes.slice(0, 25);
+ console.log(`Sampling ${pages.length} of ${routes.length} routes for run ${runId}:\n${pages.join("\n")}`);
+ const baseUrl = process.env.BASE_URL;
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+ const titleOf = (html) => {
+ const match = html.match(/]*>([\s\S]*?)<\/title>/i);
+ return match ? match[1].replace(/\s+/g, " ").trim() : "";
+ };
+ const decode = (value) =>
+ value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll(""", '"')
+ .replaceAll("'", "'")
+ .replaceAll("'", "'");
+ const assertPage = async (path, attempt) => {
+ const url = new URL(path, baseUrl);
+ url.searchParams.set("_openclaw_smoke", `${process.env.GITHUB_SHA}-${attempt}`);
+ const response = await fetch(url, {
+ headers: {
+ "cache-control": "no-cache",
+ pragma: "no-cache",
+ },
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!response.ok) {
+ throw new Error(`${path}: HTTP ${response.status}`);
+ }
+ const html = await response.text();
+ const title = decode(titleOf(html));
+ if (!title || title.includes("404") || title.includes("Not Found")) {
+ throw new Error(`${path}: bad title ${JSON.stringify(title)}`);
+ }
+ console.log(`${path}: ok (${title})`);
+ };
+ let pending = pages;
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
+ const failures = [];
+ for (const page of pending) {
+ try {
+ await assertPage(page, attempt);
+ } catch (error) {
+ failures.push({ page, message: error.message });
+ console.log(`Attempt ${attempt} failed: ${error.message}`);
+ }
+ }
+ if (failures.length === 0) break;
+ if (attempt === 3) {
+ throw new Error(`Docs route sample failed:\n${failures.map(({ message }) => message).join("\n")}`);
+ }
+ pending = failures.map(({ page }) => page);
+ await sleep(5_000);
+ }
+ NODE
diff --git a/.github/workflows/r2-pages.yml b/.github/workflows/r2-pages.yml
index a0ecea90b8..19e7de9a68 100644
--- a/.github/workflows/r2-pages.yml
+++ b/.github/workflows/r2-pages.yml
@@ -52,6 +52,11 @@ on:
required: false
type: string
default: ""
+ smoke_commit_range:
+ description: "Optional .. forwarded to docs-live-smoke for added-route assertions."
+ type: string
+ required: false
+ default: ""
permissions:
actions: write
@@ -221,6 +226,7 @@ jobs:
set -euo pipefail
git fetch --quiet origin main
latest="$(git rev-parse refs/remotes/origin/main)"
+ echo "latest=${latest}" >> "${GITHUB_OUTPUT}"
expected="${SCOPED_CONTENT_SHA:-${GITHUB_SHA}}"
if [ "${expected}" = "${latest}" ]; then
echo "stale=false" >> "${GITHUB_OUTPUT}"
@@ -254,7 +260,23 @@ jobs:
if: steps.current-main.outputs.dispatch == 'true'
env:
GH_TOKEN: ${{ github.token }}
- run: gh workflow run r2-pages.yml --ref main -f artifact_scope=full -f request_id="head-drift-${GITHUB_RUN_ID}"
+ EVENT_NAME: ${{ github.event_name }}
+ BEFORE_SHA: ${{ github.event.before || '' }}
+ SMOKE_COMMIT_RANGE: ${{ inputs.smoke_commit_range || '' }}
+ LATEST_SHA: ${{ steps.current-main.outputs.latest }}
+ run: |
+ set -euo pipefail
+ base=""
+ if [[ "${EVENT_NAME}" == "push" && "${BEFORE_SHA}" =~ ^[0-9a-f]{7,40}$ && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then
+ base="${BEFORE_SHA}"
+ elif [ -n "${SMOKE_COMMIT_RANGE}" ]; then
+ base="${SMOKE_COMMIT_RANGE%%..*}"
+ fi
+ range=""
+ if [ -n "${base}" ]; then
+ range="${base}..${LATEST_SHA}"
+ fi
+ gh workflow run r2-pages.yml --ref main -f artifact_scope=full -f request_id="head-drift-${GITHUB_RUN_ID}" -f smoke_commit_range="${range}"
- name: Fail stale scoped translation deploy
if: github.event_name == 'workflow_dispatch' && steps.current-main.outputs.stale == 'true' && (steps.artifact-scope.outputs.scope == 'locale' || steps.artifact-scope.outputs.scope == 'page')
@@ -394,4 +416,16 @@ jobs:
if: steps.current-main.outputs.stale == 'false' && (steps.artifact-scope.outputs.deploy_worker == '1' || (steps.artifact-scope.outputs.scope != 'none' && (steps.upload-r2.outputs.changed != '0' || steps.upload-r2.outputs.deleted != '0')))
env:
GH_TOKEN: ${{ github.token }}
- run: gh workflow run docs-live-smoke.yml --ref main
+ EVENT_NAME: ${{ github.event_name }}
+ BEFORE_SHA: ${{ github.event.before || '' }}
+ AFTER_SHA: ${{ github.sha }}
+ SMOKE_COMMIT_RANGE: ${{ inputs.smoke_commit_range || '' }}
+ run: |
+ set -euo pipefail
+ range=""
+ if [[ "${EVENT_NAME}" == "push" && "${BEFORE_SHA}" =~ ^[0-9a-f]{7,40}$ && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then
+ range="${BEFORE_SHA}..${AFTER_SHA}"
+ elif [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
+ range="${SMOKE_COMMIT_RANGE}"
+ fi
+ gh workflow run docs-live-smoke.yml --ref main -f commit_range="${range}"
diff --git a/scripts/docs-site/added-routes.mjs b/scripts/docs-site/added-routes.mjs
new file mode 100644
index 0000000000..cbf24f3b68
--- /dev/null
+++ b/scripts/docs-site/added-routes.mjs
@@ -0,0 +1,37 @@
+// Map added docs files to live routes; mapping must mirror build.mjs page collection.
+// The CLI caps large additions with a deterministic, evenly spaced sample.
+import fs from "node:fs";
+import { fileURLToPath } from "node:url";
+import { ignoredDocDirs, ignoredDocFiles } from "./config.mjs";
+
+export function routesFromAddedDocsPaths(paths) {
+ const routes = new Set();
+ for (const path of paths) {
+ if (!path.startsWith("docs/")) continue;
+ const rel = path.slice("docs/".length);
+ if (!/\.(md|mdx)$/.test(rel) || ignoredDocFiles.has(rel)) continue;
+ const segments = rel.split("/");
+ if (segments.some((segment) => segment.startsWith(".") || ignoredDocDirs.has(segment))) continue;
+ if (["AGENTS.md", "CLAUDE.md"].includes(segments.at(-1))) continue;
+ const slug = rel.replace(/\.(md|mdx)$/, "").replace(/\/index$/, "");
+ routes.add(slug === "index" ? "/" : `/${slug}`);
+ }
+ return [...routes].sort();
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ const value = process.env.MAX_ROUTES ?? "100";
+ const cap = Number(value);
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(cap)) {
+ throw new Error("MAX_ROUTES must be a non-negative safe integer (0 = unlimited).");
+ }
+ let routes = routesFromAddedDocsPaths(fs.readFileSync(0, "utf8").split(/\r?\n/));
+ if (cap > 0 && routes.length > cap) {
+ const total = routes.length;
+ routes = Array.from({ length: cap }, (_, index) =>
+ routes[cap === 1 ? 0 : Math.round(index * (total - 1) / (cap - 1))]
+ );
+ console.error(`Sampled ${routes.length} routes out of ${total}.`);
+ }
+ if (routes.length > 0) process.stdout.write(`${routes.join("\n")}\n`);
+}
diff --git a/scripts/docs-site/added-routes.test.mjs b/scripts/docs-site/added-routes.test.mjs
new file mode 100644
index 0000000000..e507d5ab46
--- /dev/null
+++ b/scripts/docs-site/added-routes.test.mjs
@@ -0,0 +1,132 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { routesFromAddedDocsPaths } from "./added-routes.mjs";
+
+const script = fileURLToPath(new URL("./added-routes.mjs", import.meta.url));
+
+const cases = [
+ ["docs/start/why-openclaw.md", "/start/why-openclaw"],
+ ["docs/tools/browser.mdx", "/tools/browser"],
+ ["docs/tools/browser/setup.md", "/tools/browser/setup"],
+ ["docs/index.md", "/"],
+ ["docs/de/index.md", "/de"],
+ ["docs/tools/index.md", "/tools"],
+ ["docs/tools/index.mdx", "/tools"],
+ ["docs/de/tools/foo.md", "/de/tools/foo"],
+ ["docs/reference/AGENTS.default.md", "/reference/AGENTS.default"],
+];
+
+for (const [path, route] of cases) {
+ test(`maps ${path} to ${route}`, () => {
+ assert.deepEqual(routesFromAddedDocsPaths([path]), [route]);
+ });
+}
+
+test("excludes paths that are not docs pages", () => {
+ assert.deepEqual(routesFromAddedDocsPaths([
+ "README.md",
+ "docs-extra/page.md",
+ "other/docs/page.md",
+ "docs/assets/page.md",
+ "docs/de/assets/page.md",
+ "docs/.i18n/page.md",
+ "docs/.generated/page.md",
+ "docs/.hidden/page.md",
+ "docs/tools/.hidden/page.mdx",
+ "docs/tools/.hidden.md",
+ "docs/AGENTS.md",
+ "docs/CLAUDE.md",
+ "docs/de/AGENTS.md",
+ "docs/de/tools/CLAUDE.md",
+ "docs/docs.json",
+ "docs/nav-tabs-underline.js",
+ "docs/style.css",
+ "docs/tools/image.png",
+ "docs/tools/page.md.bak",
+ "docs/tools/page.MD",
+ "",
+ ]), []);
+});
+
+test("deduplicates and sorts routes without changing the input", () => {
+ const paths = ["docs/z.md", "docs/tools/index.md", "docs/a.mdx", "docs/z.mdx", "docs/tools.md", "docs/index.md"];
+ const original = [...paths];
+ assert.deepEqual(routesFromAddedDocsPaths(paths), ["/", "/a", "/tools", "/z"]);
+ assert.deepEqual(paths, original);
+});
+
+test("CLI reads newline-separated stdin and prints routes with trailing newlines", () => {
+ assert.equal(execFileSync(process.execPath, [script], {
+ encoding: "utf8",
+ env: { ...process.env, MAX_ROUTES: "100" },
+ input: "docs/z.md\r\ndocs/index.md\r\ndocs/z.mdx\r\nREADME.md\r\n",
+ }), "/\n/z\n");
+});
+
+const sampleInput = Array.from({ length: 11 }, (_, index) =>
+ `docs/page-${String(index).padStart(2, "0")}.md`
+).join("\n");
+
+function runCli(input, cap) {
+ const env = { ...process.env };
+ if (cap === undefined) delete env.MAX_ROUTES;
+ else env.MAX_ROUTES = cap;
+ return spawnSync(process.execPath, [script], { encoding: "utf8", input, env });
+}
+
+test("CLI samples evenly and deterministically, including the first and last routes", () => {
+ const first = runCli(sampleInput, "4");
+ const second = runCli(sampleInput, "4");
+ assert.equal(first.status, 0);
+ assert.equal(second.status, 0);
+ assert.equal(first.stdout, "/page-00\n/page-03\n/page-07\n/page-10\n");
+ assert.equal(first.stderr, "Sampled 4 routes out of 11.\n");
+ assert.equal(second.stdout, first.stdout);
+ assert.equal(second.stderr, first.stderr);
+});
+
+test("CLI MAX_ROUTES=1 keeps only the first route", () => {
+ const result = runCli(sampleInput, "1");
+ assert.equal(result.status, 0);
+ assert.equal(result.stdout, "/page-00\n");
+ assert.equal(result.stderr, "Sampled 1 routes out of 11.\n");
+});
+
+test("CLI defaults to 100 routes", () => {
+ const input = Array.from({ length: 101 }, (_, index) => `docs/page-${String(index).padStart(3, "0")}.md`).join("\n");
+ const result = runCli(input);
+ assert.equal(result.status, 0);
+ const routes = result.stdout.trim().split("\n");
+ assert.equal(routes.length, 100);
+ assert.equal(new Set(routes).size, 100);
+ assert.equal(routes[0], "/page-000");
+ assert.equal(routes.at(-1), "/page-100");
+ assert.equal(result.stderr, "Sampled 100 routes out of 101.\n");
+});
+
+for (const cap of ["0", "11", "100"]) {
+ test(`CLI keeps all routes without a sampling notice for MAX_ROUTES=${cap}`, () => {
+ const result = runCli(sampleInput, cap);
+ assert.equal(result.status, 0);
+ assert.equal(result.stdout, `${routesFromAddedDocsPaths(sampleInput.split("\n")).join("\n")}\n`);
+ assert.equal(result.stderr, "");
+ });
+}
+
+test("CLI emits nothing when there are no added docs pages", () => {
+ const result = runCli("README.md\n", "1");
+ assert.equal(result.status, 0);
+ assert.equal(result.stdout, "");
+ assert.equal(result.stderr, "");
+});
+
+for (const cap of ["-1", "1.5", "nope", "", "9007199254740992"]) {
+ test(`CLI rejects invalid MAX_ROUTES=${JSON.stringify(cap)}`, () => {
+ const result = runCli(sampleInput, cap);
+ assert.equal(result.status, 1);
+ assert.equal(result.stdout, "");
+ assert.match(result.stderr, /MAX_ROUTES must be a non-negative safe integer/);
+ });
+}