From 8c2cb7ed7410a24f21a68891d7fd131821724758 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:02:52 -0600 Subject: [PATCH 1/4] fix: keep docs artifacts stable across content-only commits --- CLOUDFLARE.md | 13 +++ scripts/docs-site/analytics-behavior.test.mjs | 8 +- scripts/docs-site/analytics-vitals.test.mjs | 7 +- scripts/docs-site/analytics.mjs | 4 +- scripts/docs-site/build.mjs | 2 +- .../docs-site/publication-stability.test.mjs | 81 +++++++++++++++++++ scripts/docs-site/r2-prepare.mjs | 4 + scripts/docs-site/search-index.mjs | 1 - scripts/docs-site/site-js.mjs | 10 ++- 9 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 scripts/docs-site/publication-stability.test.mjs 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..fcd52b70ce 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; }); 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}`; } 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"), `; 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);