${homeHeroArt}
${tocHtml(toc, page.locale)}
diff --git a/scripts/docs-site/publication-stability.test.mjs b/scripts/docs-site/publication-stability.test.mjs
new file mode 100644
index 0000000000..d87360b17e
--- /dev/null
+++ b/scripts/docs-site/publication-stability.test.mjs
@@ -0,0 +1,81 @@
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+import test from "node:test";
+import { fixture, repo, run, write } from "./test-helpers/redirect-fixture.mjs";
+import { siteJs } from "./site-js.mjs";
+
+test("runtime identity hashes the executed source, including analytics and lazy dependencies", () => {
+ const js = siteJs();
+ const [header, ...lines] = js.split("\n");
+ const source = lines.join("\n");
+ const hash = createHash("sha256").update(source).digest("hex").slice(0, 12);
+ assert.equal(header, `const docsRuntimeRelease="js-${hash}";`);
+ assert.match(source, /function createDocsAnalytics\(/);
+ assert.match(source, /function initDocsAnalyticsConsent\(/);
+ assert.match(source, /web-vitals-[a-f0-9]{12}\.js/);
+ assert.match(source, /G-3SK7X2YLSJ/);
+});
+
+test("commit-only builds preserve artifacts; two body edits change only their pages and search", (t) => {
+ const f = fixture(t, [], {
+ "one.md": "# First\n\nOriginal first body.\n",
+ "two.md": "# Second\n\nOriginal second body.\n",
+ });
+ const site = path.join(f.root, "dist/docs-site");
+ function build(sha) {
+ const env = { DOCS_SITE_GA4_ENABLED: "1", GITHUB_SHA: sha };
+ for (const script of ["build.mjs", "search-index.mjs"]) {
+ const result = run(f.root, script, env);
+ assert.equal(result.status, 0, result.stderr);
+ }
+ const pagefind = spawnSync(path.join(repo, "node_modules/.bin/pagefind"), ["--site", site], {
+ env: { PATH: process.env.PATH }, encoding: "utf8",
+ });
+ assert.equal(pagefind.status, 0, pagefind.stderr);
+ for (const script of ["pagefind-normalize.mjs", "r2-prepare.mjs"]) {
+ const result = run(f.root, script, env);
+ assert.equal(result.status, 0, result.stderr);
+ }
+ return JSON.parse(fs.readFileSync(path.join(f.root, "dist/docs-r2-manifest.json"), "utf8"));
+ }
+ const first = build("a".repeat(40));
+ const second = build("b".repeat(40));
+ assert.equal(first.buildCommit, "a".repeat(40));
+ assert.equal(second.buildCommit, "b".repeat(40));
+ assert.deepEqual(second.entries, first.entries, "all HTML, search, assets and metadata are identical");
+ const home = fs.readFileSync(path.join(site, "index.html"), "utf8");
+ assert.match(home, /data-analytics-path="\/"/);
+ assert.doesNotMatch(home, /data-analytics-release/);
+
+ const dryUpload = (previous, scope = "all") => {
+ write(f.root, "dist/previous.json", JSON.stringify(previous));
+ const result = run(f.root, "r2-upload.mjs", {
+ R2_UPLOAD_DRY_RUN: "1", R2_UPLOAD_REMOTE_MANIFEST_PATH: "dist/previous.json",
+ R2_UPLOAD_SCOPE: scope,
+ });
+ assert.equal(result.status, 0, result.stderr);
+ return [...result.stdout.matchAll(/^r2 dry-run put: (\S+)/gm)].map(match => match[1])
+ .filter(key => key !== ".openclaw-docs-r2-manifest.json");
+ };
+ assert.deepEqual(dryUpload(first), [], "commit-only publication updates the catalog alone");
+ const olderSearch = { ...first, entries: first.entries.map(entry => entry.key === "docs-search.json"
+ ? { ...entry, sha256: "d".repeat(64) } : entry) };
+ assert.deepEqual(dryUpload(olderSearch, "shell"), []);
+ const merged = JSON.parse(fs.readFileSync(path.join(f.root, "dist/docs-r2-manifest.shell.merged.json"), "utf8"));
+ assert.equal(merged.buildCommit, second.buildCommit, "final partial catalog retains incoming build provenance");
+ assert.equal(merged.entries.find(entry => entry.key === "docs-search.json").sha256, "d".repeat(64),
+ "retained out-of-scope objects keep their actual older identity");
+ write(f.root, "docs/one.md", "# First\n\nChanged first body.\n");
+ write(f.root, "docs/two.md", "# Second\n\nChanged second body.\n");
+ const third = build("c".repeat(40));
+ assert.equal(third.buildCommit, "c".repeat(40));
+ const changed = dryUpload(second);
+ assert.deepEqual(changed.filter(key => !key.startsWith("pagefind/")), [
+ "docs-search.json", "one.md", "two.md", "one", "one/index.html", "two", "two/index.html",
+ ]);
+ assert.ok(changed.some(key => key.startsWith("pagefind/")), "the native search index reflects edited bodies");
+ assert.equal(fs.readFileSync(path.join(site, "index.html"), "utf8"), home);
+});
diff --git a/scripts/docs-site/r2-prepare.mjs b/scripts/docs-site/r2-prepare.mjs
index 8411a8f1ac..a2f535bba7 100644
--- a/scripts/docs-site/r2-prepare.mjs
+++ b/scripts/docs-site/r2-prepare.mjs
@@ -43,6 +43,10 @@ for (const entry of entries) {
const manifest = {
version: 1,
generatedAt: new Date().toISOString(),
+ // Build provenance belongs to the publication catalog, not every HTML page.
+ // A partial upload can retain older objects; their individual hashes remain
+ // authoritative. This is the commit that produced the incoming build.
+ buildCommit: /^[a-f0-9]{40}$/i.test(process.env.GITHUB_SHA ?? "") ? process.env.GITHUB_SHA : null,
sourceDir: "dist/docs-site",
outputDir: "dist/docs-site",
objectCount: entries.length,
diff --git a/scripts/docs-site/search-index.mjs b/scripts/docs-site/search-index.mjs
index dc7e721760..d35827bb71 100644
--- a/scripts/docs-site/search-index.mjs
+++ b/scripts/docs-site/search-index.mjs
@@ -48,7 +48,6 @@ entries.sort((a, b) => a.url.localeCompare(b.url));
const payload = {
version: 1,
- generatedAt: new Date().toISOString(),
count: entries.length,
entries,
};
diff --git a/scripts/docs-site/site-js.mjs b/scripts/docs-site/site-js.mjs
index 9b947e6139..ef7382611f 100644
--- a/scripts/docs-site/site-js.mjs
+++ b/scripts/docs-site/site-js.mjs
@@ -1,4 +1,5 @@
import fs from "node:fs";
+import { createHash } from "node:crypto";
import { initSidebarLevels } from "./sidebar-levels.mjs";
import { mountHomeHero } from "./home-hero.mjs";
import { createHomeGlimm } from "./home-glimm.mjs";
@@ -13,12 +14,12 @@ const heroRuntime = fs.readFileSync(new URL("./hero-art.mjs", import.meta.url),
import { resolveDocsFragment } from "../../.openclaw-sync/lib/docs-markdown.mjs";
export function siteJs() {
- return `
+ const source = `
${heroRuntime}
${initSidebarLevels.toString()}
${mountHomeHero.toString()}
${createHomeGlimm.toString()}
-const docsAnalytics=(${createDocsAnalytics.toString()})(()=>import(withBase(${JSON.stringify("/assets/" + webVitalsAssetName)})));
+const docsAnalytics=(${createDocsAnalytics.toString()})(()=>import(withBase(${JSON.stringify("/assets/" + webVitalsAssetName)})),docsRuntimeRelease);
const docsTelemetry=(${createDocsAnalyticsEvents.toString()})(docsAnalytics);
docsAnalytics.pageView();
queueMicrotask(()=>(${initDocsAnalyticsConsent.toString()})(docsAnalytics,()=>{try{if(docsAnalytics.publicPage())currentDocKey=location.pathname+location.search;syncCommunityInvite()}catch{}}));
@@ -399,4 +400,9 @@ moltySearch?.addEventListener("click",()=>askMoltyFromSearch());
document.addEventListener("click",e=>{const suggestion=e.target.closest("[data-search-suggestion]");if(!suggestion||!input)return;input.value=suggestion.dataset.searchSuggestion||"";input.focus();scheduleSearch(true)});
`;
+ // Hash the complete emitted runtime before adding its identity, avoiding a
+ // self-referential hash. Serialized analytics/config and hashed lazy module
+ // paths participate; a cached runtime keeps its own identity across PJAX.
+ const release = "js-" + createHash("sha256").update(source).digest("hex").slice(0, 12);
+ return `const docsRuntimeRelease=${JSON.stringify(release)};\n${source}`;
}
From 43926de0ee2f09e2b36f0d2f5b86e74c7a611e46 Mon Sep 17 00:00:00 2001
From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:06:55 -0600
Subject: [PATCH 2/4] fix: serve nested index URLs from canonical R2 objects
---
workers/docs-router-dotted.test.mjs | 71 ++++++++++++++++++++++++++++-
workers/docs-router.ts | 16 ++++++-
2 files changed, 85 insertions(+), 2 deletions(-)
diff --git a/workers/docs-router-dotted.test.mjs b/workers/docs-router-dotted.test.mjs
index 1952f8b143..0c5e59ea23 100644
--- a/workers/docs-router-dotted.test.mjs
+++ b/workers/docs-router-dotted.test.mjs
@@ -42,7 +42,7 @@ function harness(t, initial) {
if (!entry) return null;
return {
...(method === "GET" ? { body: entry.body } : {}),
- size: Buffer.byteLength(entry.body), httpEtag: '"fixture"',
+ size: Buffer.byteLength(entry.body), httpEtag: entry.etag ?? '"fixture"',
httpMetadata: { contentType: entry.headers["Content-Type"] },
customMetadata: entry.customMetadata,
writeHttpMetadata(headers) {
@@ -72,6 +72,75 @@ function policy(response, browser, edge) {
assert.equal(response.headers.get("Cloudflare-CDN-Cache-Control"), edge);
}
+for (const inventory of ["dual", "mixed", "canonical", "legacy-only"]) {
+ test(`nested index URLs preserve their response contract with ${inventory} objects`, async (t) => {
+ const keys = ["guide", "de/guide", "releases/2026.10.2", "docs/guide", "ümlaut/指南"];
+ const p = harness(t, {});
+ for (const key of keys) {
+ if (inventory !== "legacy-only") p.entries.set(key, {
+ ...object("Canonical", htmlType, "/guide.md"), etag: '"canonical"',
+ });
+ if (inventory !== "canonical") p.entries.set(`${key}/index.html`, {
+ ...object(inventory === "dual" ? "Canonical" : "Legacy", htmlType, "/guide.md"),
+ etag: inventory === "dual" ? '"canonical"' : '"legacy"',
+ });
+ const encoded = key.split("/").map(encodeURIComponent).join("/").replaceAll(".", "%2E");
+ for (const method of ["GET", "HEAD"]) for (const accept of ["text/html", ...markdownTypes]) {
+ p.reset();
+ const route = `/${encoded}/index.html?incoming=1`;
+ p.cache.set(`https://docs.openclaw.ai${route}`, new Response("Stale", { headers: { "Content-Type": htmlType } }));
+ const response = await p.request(route, method, accept);
+ assert.equal(response.status, 200);
+ assert.equal(response.headers.get("Content-Type"), htmlType);
+ assert.equal(response.headers.get("ETag"), inventory === "legacy-only" ? '"legacy"' : '"canonical"');
+ assert.equal(response.headers.get("Vary"), "Accept-Encoding", "explicit HTML does not negotiate Markdown");
+ assert.equal(response.headers.get("Link"), `${encoded}.md>; rel="alternate"; type="text/markdown"`);
+ assert.equal(response.headers.get("x-amz-meta-openclaw-markdown-target"), "/guide.md");
+ assert.equal(response.headers.get("Location"), null);
+ assert.equal(response.headers.get("X-OpenClaw-Docs-Cache"), "MISS");
+ policy(response, htmlBrowser, htmlEdge);
+ assert.equal(await response.text(), method === "HEAD" ? "" : inventory === "legacy-only" ? "Legacy" : "Canonical");
+ assert.deepEqual(p.calls, inventory === "legacy-only" ? [`${method} ${key}`, `${method} ${key}/index.html`] : [`${method} ${key}`]);
+ assert.deepEqual(p.puts, []);
+ }
+ const redirect = await p.request(`/${encoded}/?incoming=1`);
+ assert.equal(redirect.status, 308);
+ assert.equal(redirect.headers.get("Location"), `https://docs.openclaw.ai/${encoded}?incoming=1`);
+ }
+ });
+}
+
+test("index compatibility does not expose non-HTML collisions or hide storage errors", async (t) => {
+ const p = harness(t, {
+ guide: object("Private asset bytes", "application/json"),
+ "guide/index.html": object("Legacy", htmlType),
+ absent: object("Non-HTML", "application/octet-stream"),
+ broken: new Error("synthetic R2 failure"),
+ "broken/index.html": object("Stale", htmlType),
+ "index.html": object("Root", htmlType),
+ });
+ for (const method of ["GET", "HEAD"]) {
+ p.reset();
+ const legacy = await p.request("/guide/index.html", method);
+ assert.equal(legacy.status, 200);
+ assert.equal(await legacy.text(), method === "HEAD" ? "" : "Legacy");
+ for (const route of ["/absent/index.html", "/missing/index.html"]) {
+ const missing = await p.request(route, method);
+ assert.equal(missing.status, 404);
+ assert.equal(missing.headers.get("Content-Type"), htmlType);
+ assert.doesNotMatch(await missing.text(), /Non-HTML/);
+ }
+ await assert.rejects(p.request("/broken/index.html", method), /synthetic R2 failure/);
+ for (const route of ["/", "/index.html"]) {
+ p.reset();
+ const root = await p.request(route, method);
+ assert.equal(root.status, 200);
+ assert.equal(await root.text(), method === "HEAD" ? "" : "Root");
+ assert.deepEqual(p.calls, [`${method} index.html`]);
+ }
+ }
+});
+
async function representation(response, method, type, body, alternate = null) {
assert.equal(response.status, 200);
assert.equal(response.headers.get("Content-Type"), type);
diff --git a/workers/docs-router.ts b/workers/docs-router.ts
index cafdabaef3..54a9c321bb 100644
--- a/workers/docs-router.ts
+++ b/workers/docs-router.ts
@@ -507,7 +507,21 @@ async function r2Fetch(env: Env, method: string, key: string): Promise
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
- const object = method === "HEAD" ? await bucket.head(key) : await bucket.get(key);
+ const read = (objectKey: string) => method === "HEAD" ? bucket.head(objectKey) : bucket.get(objectKey);
+ let object;
+ if (key.endsWith("/index.html") && key.length > "/index.html".length) {
+ // Nested HTML copies can retire after this reader is deployed. Prefer the
+ // canonical slashless object, but keep serving older/partial inventories.
+ // Resolve only the storage key: URL, negotiation and cache policy stay put.
+ const canonical = await read(key.slice(0, -"/index.html".length));
+ if (canonical) {
+ const metadata = new Headers();
+ canonical.writeHttpMetadata(metadata);
+ if (/^text\/html(?:;|$)/i.test(metadata.get("Content-Type") ?? "")) object = canonical;
+ else if ("body" in canonical) await canonical.body.cancel?.();
+ }
+ }
+ object ??= await read(key);
if (!object) {
return new Response(method === "HEAD" ? null : "Not found\n", {
status: 404,
From a73528de794fd8bc96866eee528f3e99a1072dde Mon Sep 17 00:00:00 2001
From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:07:20 -0600
Subject: [PATCH 3/4] docs: record the staged R2 alias retirement and rollback
---
CLOUDFLARE.md | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/CLOUDFLARE.md b/CLOUDFLARE.md
index 93a6ba3700..a8ea20719d 100644
--- a/CLOUDFLARE.md
+++ b/CLOUDFLARE.md
@@ -179,6 +179,21 @@ no unused generation timestamp. Pagefind still rebuilds as described above.
### Router deployment
+Nested `/route/index.html` requests prefer the slashless HTML object and fall
+back to the legacy nested key if canonical HTML is unavailable. The current
+artifact still publishes both keys. Retiring duplicate objects is a two-stage
+operation: first deploy and verify this compatible Worker, then publish an
+artifact omitting the byte-identical nested copies. Record the authoritative
+Worker version ID, source commit and successful deployment run in the retirement
+receipt before the second stage; CI success alone does not establish that the
+compatible reader is served. Verify canonical and explicit index URLs, including
+GET/HEAD, locales, dotted paths, Markdown negotiation and asset responses.
+
+Retirement uses a normal full manifest publication and its normal orphan cleanup;
+a partial shell upload retains older keys. For rollback, restore the dual-key
+artifact inventory before deploying a Worker that requires those legacy keys.
+Keep the existing single-writer queue and stale-snapshot admission checks.
+
1. On a main push that changes `workers/**` or `wrangler.toml`, `r2-pages.yml` deploys the matching Worker after any required R2 upload, provided that snapshot passed admission before the build.
2. `pages.yml` pushes validate the Worker bundle with `wrangler deploy --dry-run`; they do not deploy it.
3. Manual `pages.yml` dispatch with `deploy_worker=true` deploys the router using the workflow's pinned Wrangler version.
From 6e1ecc8f8f24e43b7b1c6a0f1aaad1b79629a517 Mon Sep 17 00:00:00 2001
From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:07:58 -0600
Subject: [PATCH 4/4] test: wait for committed docs navigation before asserting
regrant
---
scripts/docs-site/analytics-behavior.test.mjs | 22 ++++++++++++++++---
1 file changed, 19 insertions(+), 3 deletions(-)
diff --git a/scripts/docs-site/analytics-behavior.test.mjs b/scripts/docs-site/analytics-behavior.test.mjs
index fcd52b70ce..23cfa0b513 100644
--- a/scripts/docs-site/analytics-behavior.test.mjs
+++ b/scripts/docs-site/analytics-behavior.test.mjs
@@ -264,9 +264,25 @@ test("regrant after an unmeasured return to the same route starts one current vi
await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "section_view" && entry[2].section_id === "install"));
await setSavedChoice(page, "denied");
await page.locator('.doc a[href="/"]').click(); await page.waitForURL(`${origin}/`);
- await page.goBack(); await page.waitForURL("**/guide**");
- assert.equal((await events("page_view")).length, 1);
- await setSavedChoice(page, "granted");
+ await page.locator('.main[data-analytics-path="/"]').waitFor();
+ // A history URL change precedes the asynchronous PJAX DOM commit. Hold that
+ // response to exercise the boundary instead of racing it on a fast runner.
+ let releaseReturn;
+ const heldReturn = new Promise(resolve => { releaseReturn = resolve; });
+ let returnRequested;
+ const requestStarted = new Promise(resolve => { returnRequested = resolve; });
+ await page.route(`${origin}/guide**`, async route => {
+ returnRequested(); await heldReturn; await route.fallback();
+ });
+ try {
+ await page.goBack(); await page.waitForURL("**/guide**"); await requestStarted;
+ assert.equal(await page.locator(".main").getAttribute("data-analytics-path"), "/");
+ assert.equal((await events("page_view")).length, 1);
+ await setSavedChoice(page, "granted");
+ assert.equal((await events("page_view")).length, 1, "an uncommitted URL cannot start a public view");
+ } finally { releaseReturn(); }
+ await page.locator('.main[data-analytics-path="/guide"]').waitFor();
+ await page.waitForFunction(() => window.dataLayer.filter(entry => entry[1] === "page_view").length === 2);
assert.equal((await events("page_view")).length, 2);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" }));
await page.waitForFunction(() => window.dataLayer.filter(entry => entry[1] === "section_view" && entry[2].section_id === "install").length === 2);