diff --git a/.github/workflows/docs-code-ci.yml b/.github/workflows/docs-code-ci.yml index 558ab3f40..7b24026ee 100644 --- a/.github/workflows/docs-code-ci.yml +++ b/.github/workflows/docs-code-ci.yml @@ -11,6 +11,7 @@ on: - "package.json" - "package-lock.json" - "scripts/cloudflare-cutover-docs-hosts.mjs" + - "scripts/*.test.mjs" - "scripts/docs-site/**" - "workers/**" - "wrangler.toml" diff --git a/CHANGELOG.md b/CHANGELOG.md index 446df3b96..e0eb0f769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,5 +2,16 @@ ## Unreleased +**Highlights:** More reliable docs publishing and translation recovery, with bounded network requests and workflow jobs. + - Preserve published heading IDs, emit unambiguous Mintlify link aliases and component targets, and open nested accordions for fragment navigation using the source-owned shared parsing and redirect contract. - Fix redundant locale rendering by excluding locale-owned roots from English page collection, including accidental localized `AGENTS.md` pages and duplicate locale-root Markdown exports. +- Reject malformed remote R2 manifests before scoped uploads can replace the catalog and lose unrelated pages; thanks @SebTardif. +- Skip locale publication when the source metadata is missing, unreadable, or empty, while preserving publication for matching source snapshots; thanks @SebTardif. +- Abort stalled signed R2 requests so uploads can retry instead of hanging indefinitely; configure the per-request budget with `R2_UPLOAD_FETCH_TIMEOUT_MS`; thanks @SebTardif. +- Bound live docs smoke requests and jobs while preserving the dispatch retry window; thanks @SebTardif. +- Bound maintenance and translation workflow jobs, including reusable workflow callees and the incremental debounce; thanks @SebTardif. +- Abort stalled Cloudflare hostname cutover requests, with a configurable `CLOUDFLARE_API_TIMEOUT_MS` budget; thanks @SebTardif. +- Reject malformed and overflowing request timeout settings before network operations begin. +- Refresh syntax highlighting, icons, Markdown parsing, and diagrams with highlight.js 11.12.0, Lucide 1.39.0, markdown-it 15.0.1, and Mermaid 11.17.2. +- Update CodeQL actions to 4.37.9 for the current analysis bundle. diff --git a/README.md b/README.md index 249726478..26e99dc7e 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,15 @@ Source of truth lives in [`openclaw/openclaw`](https://github.com/openclaw/openc - Cloudflare deploys `workers/docs-router.ts`, which serves slashless page URLs, English markdown responses for `.md` paths or `Accept: text/markdown`, and `/api/search` through the `DOCS_BUCKET` R2 binding. - Cloudflare hosting details and limitations are documented in `CLOUDFLARE.md`. +Signed R2 requests and the hostname cutover helper default to a 30-second +per-request timeout. A stalled R2 request enters the existing retry loop; a +stalled cutover request fails the helper. Set `R2_UPLOAD_FETCH_TIMEOUT_MS` for +`scripts/docs-site/r2-upload.mjs`, or `CLOUDFLARE_API_TIMEOUT_MS` for +`scripts/cloudflare-cutover-docs-hosts.mjs` (including dry runs), to raise the +relevant budget. Use an integer from 1 to 2147483647 milliseconds (Node's maximum +timer delay). The workflow job timeout remains an outer limit even when a request +budget is raised. + ## Secrets - `OPENCLAW_DOCS_SYNC_TOKEN` lives in `openclaw/openclaw` and lets the source repo push into this repo. diff --git a/package.json b/package.json index f60f48ef4..6cff7f46f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "docs:visual": "node scripts/docs-site/visual-smoke.mjs", "docs:check": "npm run docs:build && npm run docs:smoke && npm run docs:visual && node --check scripts/docs-site/llms-full.mjs", "skills:install": "npx --yes skills@1.5.22 add openclaw/carapace --skill openclaw-design openclaw-brand openclaw-carapace openclaw-design-system openclaw-marketing-pages openclaw-design-audit --agent codex --copy --yes", - "test": "node --test scripts/docs-site/*.test.mjs workers/*.test.mjs" + "test": "node --test scripts/*.test.mjs scripts/docs-site/*.test.mjs workers/*.test.mjs" }, "devDependencies": { "@mdx-js/mdx": "3.1.1", diff --git a/scripts/cloudflare-cutover-docs-hosts.mjs b/scripts/cloudflare-cutover-docs-hosts.mjs index aa3b4c3a2..44b98be34 100644 --- a/scripts/cloudflare-cutover-docs-hosts.mjs +++ b/scripts/cloudflare-cutover-docs-hosts.mjs @@ -2,10 +2,15 @@ const apiToken = process.env.CLOUDFLARE_API_TOKEN; const zoneName = process.env.CLOUDFLARE_ZONE_NAME ?? "openclaw.ai"; const dryRun = process.argv.includes("--dry-run"); +const fetchTimeoutMs = Number(process.env.CLOUDFLARE_API_TIMEOUT_MS || "30000"); if (!apiToken) { throw new Error("CLOUDFLARE_API_TOKEN is required"); } +// Larger delays overflow Node timers and can become one-millisecond timeouts. +if (!Number.isInteger(fetchTimeoutMs) || fetchTimeoutMs < 1 || fetchTimeoutMs > 2_147_483_647) { + throw new Error("CLOUDFLARE_API_TIMEOUT_MS must be an integer between 1 and 2147483647 milliseconds"); +} const docsHost = `docs.${zoneName}`; const mintlifyHost = `docs2.${zoneName}`; @@ -162,6 +167,7 @@ async function cloudflare(path, init = {}) { "Content-Type": "application/json", }, body: init.body ? JSON.stringify(init.body) : undefined, + signal: AbortSignal.timeout(fetchTimeoutMs), }); const text = await response.text(); const data = text ? JSON.parse(text) : {}; diff --git a/scripts/cloudflare-cutover-docs-hosts.test.mjs b/scripts/cloudflare-cutover-docs-hosts.test.mjs new file mode 100644 index 000000000..6d424ce63 --- /dev/null +++ b/scripts/cloudflare-cutover-docs-hosts.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const script = fileURLToPath(new URL("./cloudflare-cutover-docs-hosts.mjs", import.meta.url)); + +function cutoverRoot(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "docs-cutover-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function runCutover(root, env = {}, extra = {}) { + return spawnSync(process.execPath, [ + "--import", pathToFileURL(path.join(root, "mock-fetch.mjs")).href, + script, + "--dry-run", + ], { + cwd: root, + env: { + PATH: process.env.PATH, + CLOUDFLARE_API_TOKEN: "test-token", + ...env, + }, + encoding: "utf8", + ...extra, + }); +} + +for (const value of ["30s", "1.5", "0", "-1", "Infinity", "2147483648"]) { + test(`cutover rejects invalid timeout ${value} before making a request`, (t) => { + const root = cutoverRoot(t); + fs.writeFileSync(path.join(root, "mock-fetch.mjs"), 'globalThis.fetch = () => { throw new Error("Unexpected network request"); };\n'); + const result = runCutover(root, { CLOUDFLARE_API_TIMEOUT_MS: value }); + assert.equal(result.status, 1); + assert.match(result.stderr, /CLOUDFLARE_API_TIMEOUT_MS must be an integer between/); + assert.doesNotMatch(result.stderr, /Unexpected network request/); + }); +} + +test("Cloudflare API fetch attaches an AbortSignal so a stalled cutover call can time out", (t) => { + const root = cutoverRoot(t); + fs.writeFileSync(path.join(root, "mock-fetch.mjs"), ` +import fs from "node:fs"; +const calls = []; +globalThis.fetch = async (input, init = {}) => { + const url = String(input); + calls.push({ + url, + method: init.method ?? "GET", + hasSignal: Boolean(init.signal), + signalAborted: Boolean(init.signal?.aborted), + signalName: init.signal?.constructor?.name ?? null, + }); + fs.writeFileSync("calls.json", JSON.stringify(calls)); + if (url.includes("/zones?") && !url.includes("/dns_records") && !url.includes("/workers")) { + return Response.json({ success: true, result: [{ id: "zone1", name: "openclaw.ai" }] }); + } + return Response.json({ success: true, result: [] }); +}; +`); + const result = runCutover(root); + assert.equal(result.status, 0, result.stderr); + const calls = JSON.parse(fs.readFileSync(path.join(root, "calls.json"), "utf8")); + assert.ok(calls.length > 0, "cutover must call fetch at least once"); + for (const call of calls) { + assert.match(call.url, /^https:\/\/api\.cloudflare\.com\/client\/v4\//); + assert.equal(call.hasSignal, true, `${call.method} ${call.url} missing AbortSignal`); + assert.equal(call.signalName, "AbortSignal", `${call.method} ${call.url}`); + assert.equal(call.signalAborted, false, `${call.method} ${call.url} started already aborted`); + } +}); + +test("Cloudflare API fetch aborts a hung socket so cutover is not stuck", (t) => { + const root = cutoverRoot(t); + fs.writeFileSync(path.join(root, "mock-fetch.mjs"), ` +globalThis.fetch = (_input, init = {}) => new Promise((_resolve, reject) => { + const signal = init.signal; + const keepAlive = setTimeout(() => {}, 60_000); + const abort = () => { + clearTimeout(keepAlive); + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + reject(error); + }; + if (!signal) return; + if (signal.aborted) { + abort(); + return; + } + signal.addEventListener("abort", abort, { once: true }); +}); +`); + const started = Date.now(); + const result = runCutover(root, { CLOUDFLARE_API_TIMEOUT_MS: "80" }, { timeout: 4000 }); + const elapsed = Date.now() - started; + assert.notEqual(result.status, null, `hung fetch was killed after ${elapsed}ms instead of aborting`); + assert.notEqual(result.status, 0, result.stdout); + assert.match(`${result.stderr}\n${result.stdout}`, /abort|timeout/i); + assert.ok(elapsed < 2000, `hung fetch ran ${elapsed}ms without aborting`); +});