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
1 change: 1 addition & 0 deletions .github/workflows/docs-code-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions scripts/cloudflare-cutover-docs-hosts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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) : {};
Expand Down
105 changes: 105 additions & 0 deletions scripts/cloudflare-cutover-docs-hosts.test.mjs
Original file line number Diff line number Diff line change
@@ -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`);
});