From f35234f9fdca9a5bf17b216354e30303bda7a6e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:15:06 +0000 Subject: [PATCH 1/4] fix(docs): serve prerendered pages from the static-assets incremental cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs Worker has two independent defects. Only one of them was known. ## 1. Every page route is unservable, and has been since 2026-08-26 Every page lives under `app/[lang]/`, so every page route is a *dynamic* route prerendered through `generateStaticParams()` — 1139 paths. OpenNext runs Next in minimal mode, where Next does not read prerendered HTML off a filesystem: it asks the configured incremental cache. `defineCloudflareConfig()` with no arguments resolves `incrementalCache` to `"dummy"`, whose `get()` throws by design, so that lookup always misses. With `dynamicParams` unset the miss falls through to an on-demand render — wasteful, but the site works. That is what the live Worker version (`69c79ee3-...`, built from `8feb90db`) does, and it is why the site is up. `export const dynamicParams = false` was then added to `app/[lang]/layout.tsx`, `app/[lang]/docs/[[...slug]]/page.tsx` and `app/og/docs/[...slug]/route.tsx` on 2026-08-26, across five separate PRs about 404 semantics. Under that flag Next refuses the on-demand render and raises `NoFallbackError`, answered by the prerendered `_not-found` route: the page 404s. Every page, every locale. Cloudflare had already started rejecting the oversized upload the evening before, so the flag never reached production and nothing showed it. ## 2. `async: true` was blamed for that and is innocent PR #263 added `async: true`, the upload was accepted for the first time in nine days, the site 404'd, and the flag was reverted. Measured on this tree under real workerd, with the repository's own `.github/scripts/smoke-docs.mjs`: base `main`, no `async: true` -> 21 findings, all four pages 404 `main` + `async: true` only -> the same 21 findings this commit -> 4/4 pages render, control still red The size fix published a defect that was already merged. It did not make one. `async: true` is restored here for its own reason: 2.50 MiB of authored MDX was being inlined once per server entrypoint that touches `source`, five times over, and `handler.mjs` measures 100.91 MiB without it against 50.83 MiB with. ## The fix `staticAssetsIncrementalCache` reads prerendered entries out of the Workers static assets this Worker already binds as `ASSETS`, under `cdn-cgi/_next_cache` — a prefix only the Worker can reach (verified: that path 404s publicly). No R2 bucket, no KV namespace, no new binding, no spend. Its documented restriction, read-only and for apps that want no revalidation, is exactly this app: `revalidate = false` on every route handler and no ISR anywhere. All 1139 prerendered routes have a cache entry (cross-checked against the prerender manifest, 0 missing), and `x-nextjs-cache` goes MISS -> HIT. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx --- .../docs/app/[lang]/docs/[[...slug]]/page.tsx | 12 +++- apps/docs/open-next.config.ts | 71 ++++++++++++++++++- apps/docs/source.config.ts | 33 +++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) diff --git a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx index 3246679..8108eb5 100644 --- a/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/docs/[[...slug]]/page.tsx @@ -240,7 +240,15 @@ export default async function Page(props: { const page = source.getPage(params.slug ?? [], params.lang); if (!page) notFound(); - const MDX = page.data.body; + // `async: true` on the docs collection makes the compiled body and toc load + // on demand instead of being statically imported, so they are awaited here. + // Frontmatter (`title`, `description`, `full`) stays eager. + // + // Under `dynamicParams = false` this await runs at BUILD time, in Node, on + // every one of the 1139 prerendered paths — the Worker serves the prerendered + // result out of the static-assets incremental cache and does not re-render. + const loaded = await page.data.load(); + const MDX = loaded.body; // Resolved once and handed to both controls, so they cannot drift apart and // so a third control added below inherits the locale-independent URL instead @@ -283,7 +291,7 @@ export default async function Page(props: { dangerouslySetInnerHTML={{ __html: jsonLdHtml(item) }} /> ))} - + {page.data.title} {page.data.description}
diff --git a/apps/docs/open-next.config.ts b/apps/docs/open-next.config.ts index 7a3d171..315674e 100644 --- a/apps/docs/open-next.config.ts +++ b/apps/docs/open-next.config.ts @@ -1,3 +1,72 @@ import { defineCloudflareConfig } from '@opennextjs/cloudflare'; +import staticAssetsIncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache'; -export default defineCloudflareConfig(); +/** + * ## Why this app needs an incremental cache, and what happens without one + * + * Every page this site serves lives under `app/[lang]/`, so every page route is + * a **dynamic** route as far as Next is concerned, prerendered through + * `generateStaticParams()` — 1139 paths in the prerender manifest. + * + * OpenNext runs Next in *minimal mode*: Next does not read prerendered HTML off + * a filesystem, it asks the configured incremental cache for it. And + * `defineCloudflareConfig()` with no arguments resolves `incrementalCache` to + * `"dummy"`, whose `get()` throws on every call by design. So the lookup for a + * prerendered page always misses. + * + * What happens next depends on one route-segment flag: + * + * - `dynamicParams` unset (Next's default, `true`): the miss falls through to + * an on-demand render. Pages are re-rendered on every request, wastefully + * but correctly, and the site works. + * - `dynamicParams = false`: Next refuses the on-demand render and raises + * `NoFallbackError`, which OpenNext answers with the prerendered + * `_not-found` route. **The page 404s. Every page, every locale.** + * + * `content/docs/` is never re-read at runtime and nothing here revalidates, so + * the on-demand render was pure waste — but it was load-bearing waste, and + * nothing recorded that. + * + * ## The outage this comment exists to stop repeating + * + * `export const dynamicParams = false` was added to `app/[lang]/layout.tsx`, + * `app/[lang]/docs/[[...slug]]/page.tsx` and `app/og/docs/[...slug]/route.tsx` + * on 2026-08-26, across five separate PRs about 404 semantics, each correct in + * itself. The last deploy Cloudflare accepted was 2026-08-25 — the Worker went + * over the 64 MiB limit that evening and every upload after it was rejected, so + * the flag sat on `main` for nine days without ever reaching production. + * + * On 2026-09-04 the size fix landed (PR #263, `async: true` in + * `source.config.ts`), the upload was accepted for the first time in nine days, + * and the site 404'd. `async: true` was blamed, reverted, and is innocent: + * measured on this tree under real workerd, `main` WITHOUT it fails the + * repository's own `smoke-docs.mjs` with 21 findings — `/`, `/en/docs`, + * `/docs/quickstart` and `/docs/build/interface/views` all 404 — and `main` + * WITH it fails with the same 21. The size fix published a defect that was + * already merged; it did not introduce one. + * + * ## Why the static-assets cache specifically + * + * It reads prerendered entries straight out of the Workers static assets this + * Worker already binds as `ASSETS` (under `cdn-cgi/_next_cache`, a prefix only + * the Worker can reach). No R2 bucket, no KV namespace, no new binding, no + * spend — `opennextjs-cloudflare deploy` copies `.open-next/cache` into + * `.open-next/assets` before uploading, and `preview` does the same locally. + * + * Its one documented restriction — read-only, for apps that "do NOT want + * revalidation and ONLY want to serve prerendered data" — is exactly this app: + * `revalidate = false` on every route handler, no ISR anywhere, no on-demand + * revalidation, and content that only changes when the site is rebuilt. + * + * ⚠️ If a future page ever needs real revalidation, this override is the wrong + * one and its `set()` will log an error rather than cache anything. Move to + * `r2IncrementalCache` then — do not remove this line and go back to no cache + * at all, because that is the configuration that 404s every page. + * + * Measured after this change, under real workerd (`opennextjs-cloudflare + * preview`): `smoke-docs.mjs` passes all four pages with its negative control + * still going red. Before it: 21 findings. + */ +export default defineCloudflareConfig({ + incrementalCache: staticAssetsIncrementalCache, +}); diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts index a46822e..b57875b 100644 --- a/apps/docs/source.config.ts +++ b/apps/docs/source.config.ts @@ -5,6 +5,39 @@ import path from 'node:path'; export const docs = defineDocs({ dir: path.resolve(process.cwd(), '../../content/docs'), docs: { + /** + * Load each page's compiled body on demand instead of statically importing + * all of them into every server entrypoint. + * + * Without this, `fumadocs-mdx:collections/server` eagerly imports all 397 + * `.mdx` files, so every route that touches `source` — the docs page, but + * also `/llms.txt`, `/llms-full.txt`, `/llms.mdx/*`, `/og/*`, `/api/search` + * and `/sitemap.xml` — pulls the entire corpus into its own chunk, and the + * bundler then inlined the whole set five times over into one Worker. + * 2.50 MiB of authored MDX became a ~100 MiB `handler.mjs`, and Cloudflare + * rejects any Worker over 64 MiB uncompressed (`code: 10027`). + * + * The multiplier, not the corpus, is the problem: one probe sentence from a + * single English page appeared 15 times in the bundle before this flag and 6 + * times after. + * + * The cost is that `page.data.body` and `page.data.toc` become + * `page.data.load()`. Frontmatter stays eager, so `title`, `description`, + * `seoTitle` and `full` are unaffected, and `getText('processed')` — what + * the llms.txt routes call — is still a method on the entry. + * + * ## This flag did NOT break the site on 2026-09-04, and the record matters + * + * It shipped once (PR #263), the upload was accepted, the site 404'd, and it + * was reverted (PR #268) on the reasonable assumption that the new thing was + * the cause. It was not. Every page route on `main` was already unservable + * for an unrelated reason — see the long comment in `open-next.config.ts` — + * and had been since 2026-08-26, invisibly, because no deploy had been + * accepted since 2026-08-25 to publish it. Measured on this tree: base + * `main` with this flag ABSENT 404s on `/`, `/en/docs`, `/docs/quickstart` + * and `/docs/build/interface/views` under real workerd, identically. + */ + async: true, schema: pageSchema.extend({ /** * Optional SEO title: what the `` tag should say, when that is not From fb7259b2bf52592079184591d0ac2f1d11fe46bc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 4 Sep 2026 15:24:43 +0000 Subject: [PATCH 2/4] =?UTF-8?q?ci:=20TEMPORARY=20=E2=80=94=20exercise=20th?= =?UTF-8?q?e=20deploy's=20own=20inputs=20on=20a=20pull=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverted in the next commit on this branch. Pushed as its own commit so the evidence and its removal are both on the record. `Package the Worker` and `Upload the Worker bundle` are push-only, so `opennextjs-cloudflare build --skipNextBuild` has never once executed with an incremental cache configured — its first run would be the merge commit. If that path does not produce or does not preserve `.open-next/cache`, the Worker deploys with the cache CONFIGURED and EMPTY: every lookup misses, `dynamicParams = false` refuses the on-demand render, and every page 404s. That is the outage this branch diagnoses, reproduced by its own fix. So the two steps gain a `pull_request` clause — the only difference from what ships — and a temporary job downloads the artifact and runs `opennextjs-cloudflare populateCache local`, which is exactly what `opennextjs-cloudflare deploy` runs before `wrangler deploy` and, for the static-assets cache, a filesystem copy needing no credentials. It then weighs the bundle with `wrangler deploy --dry-run`, which gives #262 a CI-measured number instead of one scaled from a local ratio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx --- .github/workflows/ci.yml | 117 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3857d0a..9bee7d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,18 +150,55 @@ jobs: # Last in the job on purpose: the artifact then only exists for a commit # that cleared every gate above it. And only on a push to `main`, because # that is the only event that deploys, so a pull request pays nothing. + # TEMPORARY (#261 round 3, reverted in this branch before review): the + # `pull_request` clause below is the only difference from the shipped + # step. `--skipNextBuild` has never once executed with the incremental + # cache configured, because this step is push-only; its first run would + # otherwise be the merge commit. If that path does not produce + # `.open-next/cache`, the Worker deploys with the cache CONFIGURED and + # EMPTY, every lookup misses, `dynamicParams = false` refuses the + # on-demand render, and every page 404s — the outage this PR diagnoses, + # reproduced by its own fix. - name: Package the Worker from the build this job tested - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/main') + || github.event_name == 'pull_request' working-directory: apps/docs run: pnpm exec opennextjs-cloudflare build --skipNextBuild + # TEMPORARY (#261 round 3, reverted with the clause above). + - name: TEMP — the cache the deploy depends on, as CI's own build makes it + if: github.event_name == 'pull_request' + working-directory: apps/docs + shell: bash + run: | + set -euo pipefail + test -d .open-next/cache || { echo "::error::.open-next/cache absent after --skipNextBuild"; exit 1; } + echo "cache entries : $(find .open-next/cache -type f | wc -l)" + echo "cache bytes : $(du -sb .open-next/cache | cut -f1)" + node -e ' + const fs=require("fs"),path=require("path"); + const bid=fs.readFileSync(".open-next/assets/BUILD_ID","utf8").trim(); + const m=JSON.parse(fs.readFileSync(".open-next/server-functions/default/apps/docs/.next/prerender-manifest.json","utf8")); + const routes=Object.keys(m.routes||{}); + const root=path.join(".open-next/cache",bid); + const missing=routes.filter(r=>!fs.existsSync(path.join(root,(r==="/"?"/index":r).slice(1)+".cache"))); + console.log("prerendered routes:",routes.length); + console.log("missing entries :",missing.length); + if(missing.length){console.error("::error::"+missing.length+" prerendered route(s) have no cache entry");process.exit(1);} + ' + # `include-hidden-files` is load-bearing, not tidiness: the compiled # OpenNext config the deploy reads lives at `.open-next/.build/`, and # upload-artifact excludes dotted paths by default. Without it the # download succeeds, the deploy exits 1 on a missing config, and the # cause is three directories away from the message. + # TEMPORARY (#261 round 3, reverted in this branch): `pull_request` added + # so the artifact really round-trips, rather than being reasoned about. - name: Upload the Worker bundle - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/main') + || github.event_name == 'pull_request' uses: actions/upload-artifact@v7 with: name: docs-worker @@ -170,6 +207,82 @@ jobs: if-no-files-found: error retention-days: 3 + # ========================================================================== + # TEMPORARY JOB (#261 round 3). Reverted in this branch before review. + # + # It runs the deploy job's own inputs on the artifact CI actually produced: + # download it, then call `opennextjs-cloudflare populateCache local` — the + # exact step `opennextjs-cloudflare deploy` performs before `wrangler deploy`, + # and for the static-assets cache a pure filesystem copy needing no + # credentials. Then `wrangler deploy --dry-run` weighs what would be uploaded. + # + # This exists because "probably fine on a path that has never run" is the + # reasoning that cost this repo a production outage on 2026-09-04. + # ========================================================================== + verify-deploy-inputs: + name: TEMP — the artifact the deploy would publish + needs: [build] + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + + # Byte-for-byte what deploy-docs.yml does. + - name: Download the Worker CI built and tested + uses: actions/download-artifact@v8 + with: + name: docs-worker + path: apps/docs/.open-next + + - name: The cache survived the artifact round-trip + working-directory: apps/docs + shell: bash + run: | + set -euo pipefail + test -d .open-next/cache || { echo "::error::.open-next/cache did NOT survive the artifact"; exit 1; } + test -f .open-next/.build/open-next.config.mjs || { echo "::error::compiled config did not survive"; exit 1; } + echo "cache entries after download : $(find .open-next/cache -type f | wc -l)" + echo "assets before populate : $(find .open-next/assets -type f | wc -l)" + + - name: populateCache — the step the deploy runs before wrangler + working-directory: apps/docs + shell: bash + run: | + set -euo pipefail + pnpm exec opennextjs-cloudflare populateCache local + echo "assets after populate : $(find .open-next/assets -type f | wc -l)" + echo "cache entries in assets : $(find .open-next/assets/cdn-cgi/_next_cache -type f | wc -l)" + node -e ' + const fs=require("fs"),path=require("path"); + const bid=fs.readFileSync(".open-next/assets/BUILD_ID","utf8").trim(); + const m=JSON.parse(fs.readFileSync(".open-next/server-functions/default/apps/docs/.next/prerender-manifest.json","utf8")); + const routes=Object.keys(m.routes||{}); + const root=path.join(".open-next/assets/cdn-cgi/_next_cache",bid); + const missing=routes.filter(r=>!fs.existsSync(path.join(root,(r==="/"?"/index":r).slice(1)+".cache"))); + console.log("prerendered routes :",routes.length); + console.log("servable from static assets :",routes.length-missing.length); + console.log("missing :",missing.length); + if(missing.length){console.error("::error::"+missing.length+" route(s) would 404 in production");process.exit(1);} + ' + + # The number #262 needs, measured in CI on the bundle wrangler uploads + # rather than scaled from a local ratio. Cloudflare rejects over 65536 KiB. + - name: Weigh the bundle wrangler would upload + working-directory: apps/docs + shell: bash + run: | + set -euo pipefail + pnpm exec wrangler deploy --dry-run --outdir "$RUNNER_TEMP/dryrun" 2>&1 | tee "$RUNNER_TEMP/dryrun.log" + grep -E 'Total Upload|Read [0-9]+ files' "$RUNNER_TEMP/dryrun.log" | tee -a "$GITHUB_STEP_SUMMARY" + # Defect 1 of #269: `deploy-docs.yml` used to hang off `push: branches: # [main]` exactly as this workflow does, so the two ran in PARALLEL and a # commit that failed any gate above still deployed. There was no `needs:` and From 3e037d6fee0169288751e6fe55954dbd6a7b4a39 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 4 Sep 2026 15:29:25 +0000 Subject: [PATCH 3/4] ci: revert the TEMPORARY pull_request probe from the previous commit The evidence it existed to collect is on CI run 33889336935 and quoted in the PR body. `.github/workflows/ci.yml` is now byte-identical to f35234f, so this branch ships only the three `apps/docs/**` files. What the probe established, on CI's own `opennextjs-cloudflare build --skipNextBuild` output rather than a local combined build: cache present after --skipNextBuild yes (step exits non-zero if absent) artifact 93,597,106 B, sha256 7c354e53... cache survived the round-trip yes, with .open-next/.build/ intact populateCache local "Successfully populated static assets" prerendered routes 1139 servable from static assets 1139 missing 0 Total Upload (wrangler dry-run, CI) 58549.06 KiB = 89.34% of 65536 KiB Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx --- .github/workflows/ci.yml | 117 +-------------------------------------- 1 file changed, 2 insertions(+), 115 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bee7d2..3857d0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,55 +150,18 @@ jobs: # Last in the job on purpose: the artifact then only exists for a commit # that cleared every gate above it. And only on a push to `main`, because # that is the only event that deploys, so a pull request pays nothing. - # TEMPORARY (#261 round 3, reverted in this branch before review): the - # `pull_request` clause below is the only difference from the shipped - # step. `--skipNextBuild` has never once executed with the incremental - # cache configured, because this step is push-only; its first run would - # otherwise be the merge commit. If that path does not produce - # `.open-next/cache`, the Worker deploys with the cache CONFIGURED and - # EMPTY, every lookup misses, `dynamicParams = false` refuses the - # on-demand render, and every page 404s — the outage this PR diagnoses, - # reproduced by its own fix. - name: Package the Worker from the build this job tested - if: >- - (github.event_name == 'push' && github.ref == 'refs/heads/main') - || github.event_name == 'pull_request' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' working-directory: apps/docs run: pnpm exec opennextjs-cloudflare build --skipNextBuild - # TEMPORARY (#261 round 3, reverted with the clause above). - - name: TEMP — the cache the deploy depends on, as CI's own build makes it - if: github.event_name == 'pull_request' - working-directory: apps/docs - shell: bash - run: | - set -euo pipefail - test -d .open-next/cache || { echo "::error::.open-next/cache absent after --skipNextBuild"; exit 1; } - echo "cache entries : $(find .open-next/cache -type f | wc -l)" - echo "cache bytes : $(du -sb .open-next/cache | cut -f1)" - node -e ' - const fs=require("fs"),path=require("path"); - const bid=fs.readFileSync(".open-next/assets/BUILD_ID","utf8").trim(); - const m=JSON.parse(fs.readFileSync(".open-next/server-functions/default/apps/docs/.next/prerender-manifest.json","utf8")); - const routes=Object.keys(m.routes||{}); - const root=path.join(".open-next/cache",bid); - const missing=routes.filter(r=>!fs.existsSync(path.join(root,(r==="/"?"/index":r).slice(1)+".cache"))); - console.log("prerendered routes:",routes.length); - console.log("missing entries :",missing.length); - if(missing.length){console.error("::error::"+missing.length+" prerendered route(s) have no cache entry");process.exit(1);} - ' - # `include-hidden-files` is load-bearing, not tidiness: the compiled # OpenNext config the deploy reads lives at `.open-next/.build/`, and # upload-artifact excludes dotted paths by default. Without it the # download succeeds, the deploy exits 1 on a missing config, and the # cause is three directories away from the message. - # TEMPORARY (#261 round 3, reverted in this branch): `pull_request` added - # so the artifact really round-trips, rather than being reasoned about. - name: Upload the Worker bundle - if: >- - (github.event_name == 'push' && github.ref == 'refs/heads/main') - || github.event_name == 'pull_request' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@v7 with: name: docs-worker @@ -207,82 +170,6 @@ jobs: if-no-files-found: error retention-days: 3 - # ========================================================================== - # TEMPORARY JOB (#261 round 3). Reverted in this branch before review. - # - # It runs the deploy job's own inputs on the artifact CI actually produced: - # download it, then call `opennextjs-cloudflare populateCache local` — the - # exact step `opennextjs-cloudflare deploy` performs before `wrangler deploy`, - # and for the static-assets cache a pure filesystem copy needing no - # credentials. Then `wrangler deploy --dry-run` weighs what would be uploaded. - # - # This exists because "probably fine on a path that has never run" is the - # reasoning that cost this repo a production outage on 2026-09-04. - # ========================================================================== - verify-deploy-inputs: - name: TEMP — the artifact the deploy would publish - needs: [build] - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - # Byte-for-byte what deploy-docs.yml does. - - name: Download the Worker CI built and tested - uses: actions/download-artifact@v8 - with: - name: docs-worker - path: apps/docs/.open-next - - - name: The cache survived the artifact round-trip - working-directory: apps/docs - shell: bash - run: | - set -euo pipefail - test -d .open-next/cache || { echo "::error::.open-next/cache did NOT survive the artifact"; exit 1; } - test -f .open-next/.build/open-next.config.mjs || { echo "::error::compiled config did not survive"; exit 1; } - echo "cache entries after download : $(find .open-next/cache -type f | wc -l)" - echo "assets before populate : $(find .open-next/assets -type f | wc -l)" - - - name: populateCache — the step the deploy runs before wrangler - working-directory: apps/docs - shell: bash - run: | - set -euo pipefail - pnpm exec opennextjs-cloudflare populateCache local - echo "assets after populate : $(find .open-next/assets -type f | wc -l)" - echo "cache entries in assets : $(find .open-next/assets/cdn-cgi/_next_cache -type f | wc -l)" - node -e ' - const fs=require("fs"),path=require("path"); - const bid=fs.readFileSync(".open-next/assets/BUILD_ID","utf8").trim(); - const m=JSON.parse(fs.readFileSync(".open-next/server-functions/default/apps/docs/.next/prerender-manifest.json","utf8")); - const routes=Object.keys(m.routes||{}); - const root=path.join(".open-next/assets/cdn-cgi/_next_cache",bid); - const missing=routes.filter(r=>!fs.existsSync(path.join(root,(r==="/"?"/index":r).slice(1)+".cache"))); - console.log("prerendered routes :",routes.length); - console.log("servable from static assets :",routes.length-missing.length); - console.log("missing :",missing.length); - if(missing.length){console.error("::error::"+missing.length+" route(s) would 404 in production");process.exit(1);} - ' - - # The number #262 needs, measured in CI on the bundle wrangler uploads - # rather than scaled from a local ratio. Cloudflare rejects over 65536 KiB. - - name: Weigh the bundle wrangler would upload - working-directory: apps/docs - shell: bash - run: | - set -euo pipefail - pnpm exec wrangler deploy --dry-run --outdir "$RUNNER_TEMP/dryrun" 2>&1 | tee "$RUNNER_TEMP/dryrun.log" - grep -E 'Total Upload|Read [0-9]+ files' "$RUNNER_TEMP/dryrun.log" | tee -a "$GITHUB_STEP_SUMMARY" - # Defect 1 of #269: `deploy-docs.yml` used to hang off `push: branches: # [main]` exactly as this workflow does, so the two ran in PARALLEL and a # commit that failed any gate above still deployed. There was no `needs:` and From ee99ae5d977aa2539e895ccab308a942b76fbd68 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 4 Sep 2026 15:33:10 +0000 Subject: [PATCH 4/4] ci(docs): refuse to deploy a Worker whose prerender cache is incomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permanent home of the check the TEMPORARY probe two commits ago ran once. Verified once is not verified. ## What it guards `apps/docs` serves every page from a prerendered entry in `.open-next/cache`, which `opennextjs-cloudflare deploy` copies into the uploaded assets. If those entries are absent, the Worker publishes with its cache CONFIGURED and EMPTY: every lookup misses, `dynamicParams = false` refuses the on-demand render, and every page 404s — while the deploy step exits 0, because the upload succeeded. `check-deploy-version.mjs` does not catch it either: a new version really is serving. It is just serving 404s. The cache is produced by a build invocation no pull request runs, travels to the deploy job as an artifact, and is copied again by the deploy command. Three places to lose it, none of which turn a step red on their own. ## Where it runs In the `deploy` job, after the artifact is downloaded and BEFORE the deploy step, so a bad bundle is refused rather than published and then reported. It asserts against Next's own `prerender-manifest.json` from inside the bundle — not a number anyone wrote down — so a page added to the corpus is covered the day it is added, and the check cannot pass by comparing a stale expectation to itself. ## Demonstrated able to fail, twice Fixtures: 8 cases covering all 7 rules; `run-self-tests.mjs` asserts every rule has a fixture that trips it, so weakening one exits 1. Registered there, which that runner independently enforces — an unlisted script declaring a `--self-test` fails it by name. Live, against a real 1139-route bundle: intact 1139 servable, 0 missing EXIT 0 mutated 1138 servable, 1 missing EXIT 1, naming /en/docs/quickstart restored 1139 servable, 0 missing EXIT 0 (md5 f465ea19, byte-identical) `shell: bash` on the step is load-bearing for the reason the verdict step below it already documents: the default shell has no pipefail, so `node ... | tee` would take tee's status and a gate exiting 1 would pass silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx --- .github/scripts/check-prerender-cache.mjs | 325 ++++++++++++++++++++++ .github/workflows/deploy-docs.yml | 27 ++ tools/ci-scripts/run-self-tests.mjs | 8 + 3 files changed, 360 insertions(+) create mode 100644 .github/scripts/check-prerender-cache.mjs diff --git a/.github/scripts/check-prerender-cache.mjs b/.github/scripts/check-prerender-cache.mjs new file mode 100644 index 0000000..8ab81af --- /dev/null +++ b/.github/scripts/check-prerender-cache.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node +/** + * Refuses to publish a docs Worker whose prerender cache is missing entries. + * + * ## The failure this exists to catch + * + * Every page on this site lives under `app/[lang]/`, so every page route is a + * DYNAMIC route prerendered through `generateStaticParams()`. OpenNext runs + * Next in minimal mode, where Next does not read prerendered HTML off a + * filesystem — it asks the configured incremental cache. `apps/docs` uses + * `staticAssetsIncrementalCache`, and `opennextjs-cloudflare deploy` copies + * `.open-next/cache` into `.open-next/assets` just before uploading. + * + * If those entries are absent, the Worker deploys with the cache CONFIGURED and + * EMPTY. Every lookup misses, `dynamicParams = false` refuses the on-demand + * render, Next raises `NoFallbackError`, and the request is answered by the + * prerendered `_not-found` route: the page 404s. Every page, every locale, + * every request — and the deploy step still exits 0, because the upload + * succeeded. That is exactly how 2026-09-04 went (#261), by a different route. + * + * A missing cache is not hypothetical. `.open-next/cache` is produced by a + * separate build invocation from the one a pull request runs, travels to the + * deploy job as a CI artifact, and is copied again by the deploy command. Three + * places it can be lost, none of which make any step go red on their own. + * + * ## What it asserts, and against what + * + * The population it checks is Next's own `prerender-manifest.json` — every + * route Next says it prerendered — not a number anyone wrote down. So a page + * added to the corpus is covered the day it is added, and this cannot pass by + * comparing a stale expectation to itself. + * + * Run it in the deploy job AFTER the artifact is downloaded and BEFORE the + * deploy step. A check that reports a bad artifact once it is already serving + * is a post-mortem, not a gate. + * + * ## Usage + * + * node .github/scripts/check-prerender-cache.mjs # apps/docs + * node .github/scripts/check-prerender-cache.mjs --dir PATH # elsewhere + * node .github/scripts/check-prerender-cache.mjs --self-test # the rules + * + * Exit 0 only when every prerendered route has a cache entry. + */ + +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; + +/** Every rule this script enforces. The self-test asserts each one can fire. */ +const RULES = [ + 'no-open-next', + 'no-build-id', + 'no-prerender-manifest', + 'unreadable-prerender-manifest', + 'no-cache-dir', + 'no-routes', + 'missing-entries', +]; + +/** Where `.open-next` lives when nobody says otherwise. */ +const DEFAULT_DIR = 'apps/docs/.open-next'; + +/** + * The prerender manifest, as packaged inside the server function. + * + * Read from the bundle rather than from `apps/docs/.next/` on purpose: the + * deploy job downloads an artifact and never runs a Next build, so `.next/` is + * not there. Checking the copy that travels with the bundle is also the only + * way to be sure the manifest and the cache describe the same build. + */ +const MANIFEST_IN_BUNDLE = + 'server-functions/default/apps/docs/.next/prerender-manifest.json'; + +/** + * The cache file a route's entry is written to. + * + * `/` is stored as `index.cache`; every other route keeps its path. Mirrors + * `staticAssetsIncrementalCache.getAssetUrl`, which builds + * `CACHE_DIR/BUILD_ID/KEY.cache` from the same key. + */ +function entryPathFor(route, root) { + const key = route === '/' ? '/index' : route; + return join(root, `${key.slice(1)}.cache`); +} + +/** + * Judge one `.open-next` directory. Pure enough to drive from fixtures: it + * touches only the filesystem under `dir`. + */ +export function evaluate(dir) { + const findings = []; + const add = (rule, detail) => findings.push({ rule, detail }); + const measured = { dir }; + + if (!existsSync(dir)) { + add('no-open-next', `${dir} does not exist`); + return { findings, measured }; + } + + const buildIdPath = join(dir, 'assets/BUILD_ID'); + if (!existsSync(buildIdPath)) { + add('no-build-id', `${buildIdPath} does not exist`); + return { findings, measured }; + } + const buildId = readFileSync(buildIdPath, 'utf8').trim(); + measured.buildId = buildId; + + const manifestPath = join(dir, MANIFEST_IN_BUNDLE); + if (!existsSync(manifestPath)) { + add('no-prerender-manifest', `${manifestPath} does not exist`); + return { findings, measured }; + } + + let routes; + try { + routes = Object.keys(JSON.parse(readFileSync(manifestPath, 'utf8')).routes ?? {}); + } catch (error) { + add('unreadable-prerender-manifest', `${manifestPath}: ${error?.message ?? error}`); + return { findings, measured }; + } + measured.routes = routes.length; + + if (routes.length === 0) { + // A manifest with no prerendered routes would make every other rule below + // vacuous: zero routes, zero missing, a green that proves nothing. + add('no-routes', `${manifestPath} lists no prerendered routes`); + return { findings, measured }; + } + + const cacheRoot = join(dir, 'cache', buildId); + if (!existsSync(cacheRoot)) { + add('no-cache-dir', `${cacheRoot} does not exist — the Worker would 404 every page`); + measured.present = 0; + measured.missing = routes.length; + return { findings, measured }; + } + + const missing = routes.filter((route) => !existsSync(entryPathFor(route, cacheRoot))); + measured.present = routes.length - missing.length; + measured.missing = missing.length; + + if (missing.length > 0) { + const shown = missing.slice(0, 10).join(', '); + add( + 'missing-entries', + `${missing.length} of ${routes.length} prerendered route(s) have no cache entry ` + + `under ${cacheRoot} — they would 404 in production: ${shown}` + + (missing.length > 10 ? `, and ${missing.length - 10} more` : ''), + ); + } + + return { findings, measured }; +} + +/* ------------------------------------------------------------------ gate -- */ + +function gate(dir) { + const { findings, measured } = evaluate(dir); + + console.log(`prerender cache: ${measured.dir}`); + if (measured.buildId) console.log(` build id : ${measured.buildId}`); + if (measured.routes !== undefined) { + console.log( + ` routes : ${measured.routes} prerendered, ` + + `${measured.present ?? 0} servable, ${measured.missing ?? '?'} missing`, + ); + } + + if (findings.length === 0) { + console.log( + `\n✓ every one of the ${measured.routes} prerendered route(s) has a cache entry, ` + + 'so the Worker about to be uploaded can serve them', + ); + return 0; + } + + for (const f of findings) console.error(` [${f.rule}] ${f.detail}`); + console.error( + `\n✗ this bundle would publish a Worker that cannot serve its own pages — refusing to deploy`, + ); + return 1; +} + +/* ------------------------------------------------------------- self-test -- */ + +/** + * Fixtures, one per rule. The runner asserts that the set of rules a fixture + * trips is exactly the set declared here, AND that every rule in `RULES` has a + * fixture able to trip it — so weakening a rule fails this, which is what keeps + * a green from being decoration. + */ +const FIXTURES = [ + { + name: 'a complete bundle passes', + build: () => ({ routes: ['/', '/en/docs', '/zh-Hans/docs'], entries: ['/', '/en/docs', '/zh-Hans/docs'] }), + expect: [], + }, + { + name: 'no .open-next at all', + build: () => ({ absent: true }), + expect: ['no-open-next'], + }, + { + name: 'no BUILD_ID', + build: () => ({ routes: ['/en/docs'], entries: ['/en/docs'], noBuildId: true }), + expect: ['no-build-id'], + }, + { + name: 'no prerender manifest in the bundle', + build: () => ({ noManifest: true }), + expect: ['no-prerender-manifest'], + }, + { + name: 'a manifest that is not JSON', + build: () => ({ badManifest: true }), + expect: ['unreadable-prerender-manifest'], + }, + { + name: 'a manifest with no prerendered routes', + build: () => ({ routes: [], entries: [] }), + expect: ['no-routes'], + }, + { + name: 'the cache directory is missing entirely', + build: () => ({ routes: ['/en/docs'], entries: null }), + expect: ['no-cache-dir'], + }, + { + name: 'one route lost its cache entry', + build: () => ({ routes: ['/', '/en/docs', '/zh-Hans/docs'], entries: ['/', '/zh-Hans/docs'] }), + expect: ['missing-entries'], + }, +]; + +function materialise(spec) { + const dir = join(mkdtempSync(join(tmpdir(), 'os-prerender-cache-')), '.open-next'); + if (spec.absent) return dir; + + mkdirSync(join(dir, 'assets'), { recursive: true }); + const buildId = 'TESTBUILDID000000000'; + if (!spec.noBuildId) writeFileSync(join(dir, 'assets/BUILD_ID'), `${buildId}\n`); + + const manifestPath = join(dir, MANIFEST_IN_BUNDLE); + mkdirSync(dirname(manifestPath), { recursive: true }); + if (spec.badManifest) { + writeFileSync(manifestPath, 'not json {'); + return dir; + } + if (!spec.noManifest) { + const routes = Object.fromEntries((spec.routes ?? []).map((r) => [r, {}])); + writeFileSync(manifestPath, JSON.stringify({ routes })); + } else { + return dir; + } + + if (spec.entries === null) return dir; + const root = join(dir, 'cache', buildId); + for (const route of spec.entries ?? []) { + const p = entryPathFor(route, root); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, '{}'); + } + return dir; +} + +function selfTest() { + let failed = 0; + const fired = new Set(); + + for (const fixture of FIXTURES) { + const spec = fixture.build(); + const dir = materialise(spec); + let rules; + try { + rules = [...new Set(evaluate(dir).findings.map((f) => f.rule))].sort(); + } finally { + rmSync(resolve(dir, '..'), { recursive: true, force: true }); + } + for (const r of rules) fired.add(r); + + const want = [...fixture.expect].sort(); + if (rules.join('|') === want.join('|')) { + console.log(`✓ ${fixture.name.padEnd(42)} fired [${rules.join(' ') || 'nothing'}]`); + } else { + console.error( + `✗ ${fixture.name}\n expected [${want.join(' ') || 'nothing'}], got [${rules.join(' ') || 'nothing'}]`, + ); + failed += 1; + } + } + + const undemonstrated = RULES.filter((r) => !fired.has(r)); + if (undemonstrated.length) { + console.error( + `\n✗ ${undemonstrated.length} rule(s) have no fixture able to make them fire: ${undemonstrated.join(', ')}`, + ); + failed += 1; + } + + console.log(''); + if (failed) { + console.error(`✗ self-test: ${failed} failure(s)`); + return 1; + } + console.log( + `✓ self-test: ${FIXTURES.length} fixture(s) — all ${RULES.length} rules demonstrated able to fail`, + ); + return 0; +} + +/* -------------------------------------------------------------- dispatch -- */ + +const argv = process.argv.slice(2); +if (argv.includes('--self-test')) { + process.exit(selfTest()); +} else { + const at = argv.indexOf('--dir'); + const dir = at === -1 ? DEFAULT_DIR : argv[at + 1]; + if (!dir) { + console.error('--dir needs a path'); + process.exit(1); + } + process.exit(gate(resolve(dir))); +} diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index d40b182..488c383 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -117,6 +117,33 @@ jobs: name: ${{ inputs.artifact_name }} path: apps/docs/.open-next + # #261: the second defect that card turned up, and the one with no + # symptom until it is live. `apps/docs` serves every page from a + # prerendered entry in `.open-next/cache`, which + # `opennextjs-cloudflare deploy` copies into the uploaded assets. If those + # entries are absent, the Worker publishes with its cache CONFIGURED and + # EMPTY: every lookup misses, `dynamicParams = false` refuses the + # on-demand render, and every page 404s — while THIS JOB STILL EXITS 0, + # because the upload itself succeeded. `check-deploy-version.mjs` would + # not catch it either: a new version really would be serving. + # + # The cache is produced by a build invocation no pull request runs, + # travels here as an artifact, and is copied again by the deploy command. + # Three places to lose it, none of which turn a step red on their own. + # + # Placed BEFORE the deploy, deliberately: a check that reports a bad + # bundle once it is already serving is a post-mortem, not a gate. It + # asserts against Next's own `prerender-manifest.json` inside the bundle, + # so a page added to the corpus is covered the day it is added. + # + # `shell: bash` for the same reason as the verdict step below. + - name: Refuse a bundle that cannot serve its own pages + shell: bash + run: | + node .github/scripts/check-prerender-cache.mjs \ + --dir apps/docs/.open-next \ + | tee -a "$GITHUB_STEP_SUMMARY" + # Taken BEFORE the deploy and required to succeed: it is both half of the # "did anything actually change" comparison and the rollback target. A # deploy with no known-good version to fall back to is not one this diff --git a/tools/ci-scripts/run-self-tests.mjs b/tools/ci-scripts/run-self-tests.mjs index ee0d4cc..dad69c4 100644 --- a/tools/ci-scripts/run-self-tests.mjs +++ b/tools/ci-scripts/run-self-tests.mjs @@ -69,6 +69,13 @@ * Cloudflare, so on `main` they may not run for real either. What keeps them * from being decoration in the meantime is exactly this: their fixtures assert * that every rule they enforce still produces a red, and that runs on every PR. + * + * `check-prerender-cache.mjs` joins them on identical footing (#261). Its gate + * mode reads a `.open-next` bundle that only exists in the deploy job, so on a + * pull request it can never run for real; its fixtures are what prove it can + * still refuse a bundle whose prerender cache is incomplete — the bundle shape + * that publishes a Worker returning 404 for every page while the deploy step + * exits 0. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; @@ -88,6 +95,7 @@ const SELF_TESTED = [ 'check-locale-surface.mjs', 'check-deploy-version.mjs', 'smoke-docs.mjs', + 'check-prerender-cache.mjs', ]; /**