diff --git a/CLOUDFLARE.md b/CLOUDFLARE.md index 569691babb..93a6ba3700 100644 --- a/CLOUDFLARE.md +++ b/CLOUDFLARE.md @@ -164,6 +164,19 @@ enabled here: retaining an old output directory without an exact current-file inventory would also retain obsolete search fragments. Publication order, search coverage and old-object deletion remain unchanged. +Content-only commits do not stamp their Git SHA into every HTML page. Analytics +`release` is `js-<12 hex>` from the complete emitted JavaScript before its identity +is prepended, including serialized analytics/config and hashed lazy-module paths. +It identifies the executing runtime even when PJAX loads newer page content. +During the first rollout, a tab still running the previous attribute-based +runtime falls back to `local` after PJAX loads HTML without that attribute; this +means unknown provenance, not the new runtime's identity. No reload is forced. +The final `.openclaw-docs-r2-manifest.json` records the full incoming `buildCommit` +(or null outside a GitHub build) and each object's hashes. +A partial publication may retain older objects; the incoming build commit does +not relabel those bytes. The search fallback JSON is also content-stable: it has +no unused generation timestamp. Pagefind still rebuilds as described above. + ### Router deployment 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. diff --git a/scripts/docs-site/analytics-behavior.test.mjs b/scripts/docs-site/analytics-behavior.test.mjs index 57c430a85e..23cfa0b513 100644 --- a/scripts/docs-site/analytics-behavior.test.mjs +++ b/scripts/docs-site/analytics-behavior.test.mjs @@ -69,7 +69,9 @@ async function openSite(t, { privacy, clock = false, diagram = false, showConsen await page.goto(`${origin}/guide?utm_source=chatgpt&utm_medium=referral&utm_campaign=docs_launch#intro`, { referer: "https://chatgpt.com/c/PRIVATE_REFERRER_VALUE?key=example" }); await page.waitForFunction(() => document.querySelector(".page-feedback")?.dataset.feedbackReady === "true"); const events = name => page.evaluate(name => (window.dataLayer || []).filter(entry => entry[0] === "event" && (!name || entry[1] === name)).map(entry => ({ name: entry[1], ...entry[2] })), name); - return { page, context, events, collected, googleRequests }; + const runtimeRelease = /const docsRuntimeRelease="(js-[a-f0-9]{12})";/.exec(fs.readFileSync(path.join(site, "assets/docs-site.js"), "utf8"))?.[1]; + assert.ok(runtimeRelease); + return { page, context, events, collected, googleRequests, runtimeRelease }; } async function setSavedChoice(page, analytics) { @@ -82,11 +84,11 @@ async function setSavedChoice(page, analytics) { } test("acquisition, actual copy outcomes, public identifiers and feedback launch stay useful and safe", async t => { - const { page, context, events, collected } = await openSite(t); + const { page, context, events, collected, runtimeRelease } = await openSite(t); const view = (await events("page_view"))[0]; assert.equal(view.page_location, `${origin}/guide?utm_source=chatgpt&utm_medium=referral&utm_campaign=docs_launch`); assert.equal(view.page_referrer, "https://chatgpt.com/"); - assert.equal(view.release, "1234567890ab"); + assert.equal(view.release, runtimeRelease); await page.locator("[data-code-copy]").click(); await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "copy_action")); await page.evaluate(() => { window.__copyFails = true; }); @@ -262,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); diff --git a/scripts/docs-site/analytics-vitals.test.mjs b/scripts/docs-site/analytics-vitals.test.mjs index fb51d3f730..5a8bb59ae1 100644 --- a/scripts/docs-site/analytics-vitals.test.mjs +++ b/scripts/docs-site/analytics-vitals.test.mjs @@ -22,7 +22,7 @@ function harness({ delayed = false, withEvents = false } = {}) { loadVitals: () => { loads++; return delayed ? pending : Promise.resolve(library); }, }; sandbox.window = sandbox; - vm.runInNewContext(`globalThis.analytics=(${createDocsAnalytics.toString()})(loadVitals);`, sandbox); + vm.runInNewContext(`globalThis.analytics=(${createDocsAnalytics.toString()})(loadVitals, "js-0123456789ab");`, sandbox); if (withEvents) vm.runInNewContext(`globalThis.telemetry=(${createDocsAnalyticsEvents.toString()})(analytics);`, sandbox); const vitals = () => JSON.parse(JSON.stringify((sandbox.dataLayer || []).filter(entry => entry[0] === "event" && entry[1] === "web_vital").map(entry => entry[2]))); const report = (name, id) => callbacks[name]?.({ name, id, value: name === "CLS" ? 0.04 : 100, rating: "good", navigationType: "navigate", entries: [{ startTime: 1500 }] }); @@ -35,14 +35,19 @@ test("allowed document vitals keep typed values and their original public docume await turn(); h.report("LCP", "first"); h.report("LCP", "first"); h.sandbox.location.pathname = "/next"; h.main.dataset.analyticsPath = "/next"; + h.main.dataset.analyticsRelease = "a-newer-html-deployment"; h.analytics.pageView(); h.report("INP", "second"); h.report("CLS", "third"); assert.equal(h.vitals().length, 3); for (const metric of h.vitals()) { assert.equal(metric.page_location, "https://docs.openclaw.ai/"); + assert.equal(metric.release, "js-0123456789ab", "PJAX content cannot relabel the executing runtime"); assert.equal(["lcp_ms", "inp_ms", "cls_score"].filter(key => Object.hasOwn(metric, key)).length, 1); assert.equal(metric.metric_value, undefined); } + const pageViews = h.sandbox.dataLayer.filter(entry => entry[0] === "event" && entry[1] === "page_view"); + assert.equal(pageViews.length, 2); + assert.ok(pageViews.every(entry => entry[2].release === "js-0123456789ab")); }); for (const boundary of ["private", "history", "denial"]) test(`${boundary} permanently invalidates document metrics after public recovery`, async () => { diff --git a/scripts/docs-site/analytics.mjs b/scripts/docs-site/analytics.mjs index 8e597a71d6..9e3d845db2 100644 --- a/scripts/docs-site/analytics.mjs +++ b/scripts/docs-site/analytics.mjs @@ -1,5 +1,5 @@ // Serialized into the browser shell. All page identity comes from the renderer. -export function createDocsAnalytics(loadVitals) { +export function createDocsAnalytics(loadVitals, runtimeRelease = "local") { const origin = "https://docs.openclaw.ai"; const measurementId = "G-3SK7X2YLSJ"; const listeners = []; @@ -26,7 +26,7 @@ export function createDocsAnalytics(loadVitals) { const main = document.querySelector(".main[data-analytics-path]"); const path = main?.dataset.analyticsPath; if (!path || !main.dataset.analyticsTitle || path !== (location.pathname.replace(/\/+$/, "") || "/")) return null; - return { main, path, title: main.dataset.analyticsTitle, release: main.dataset.analyticsRelease || "local" }; + return { main, path, title: main.dataset.analyticsTitle, release: runtimeRelease }; } function suspend() { collectionEpoch++; diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs index 83f38f2cea..1ebe9ca342 100644 --- a/scripts/docs-site/build.mjs +++ b/scripts/docs-site/build.mjs @@ -361,7 +361,7 @@ ${canonicalUrl ? ` ${siteHeader(page)}
${sidebar(page, nav, activeTab)} -
+
${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}`; }