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/); + }); +}