Repository navigation
fix(docs): serve prerendered pages from the static-assets incremental cache, and shrink the Worker under the 64 MiB limit #413
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| merge_group: | |
| jobs: | |
| # The four Node floor declarations (`engines.node` in the root, in `apps/docs` | |
| # and in `tools/ci-scripts`, plus `.node-version`) are read by nothing in the | |
| # install path: `.npmrc` sets no `engine-strict`, pnpm does not enforce | |
| # `engines` by default, and every workflow here pins `node-version` | |
| # explicitly instead of consulting them. This job is what makes them | |
| # mechanically checkable. It needs no install — the script is zero-dependency | |
| # and reads the lockfile as text — so it stays a seconds-long job that can | |
| # run alongside `build`. | |
| node-floor: | |
| name: Node floor | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22 | |
| # 裁决 (PR #74): a validator observed only green is indistinguishable | |
| # from one that cannot go red. The fixtures run before the real scan, so | |
| # a rule that stopped being able to fail fails the job on its own. | |
| - name: Self-test | |
| shell: bash | |
| run: node .github/scripts/check-node-floor.mjs --self-test | |
| # `shell: bash` is load-bearing here, not tidiness. The DEFAULT shell for | |
| # a `run:` step is `bash -e {0}` with no pipefail, so in `node ... | tee` | |
| # the step takes tee's exit status and a gate that exits 1 passes the job | |
| # silently. Naming the shell gets `bash --noprofile --norc -eo pipefail | |
| # {0}`, which propagates it. | |
| - name: Check | |
| shell: bash | |
| run: node .github/scripts/check-node-floor.mjs | tee -a "$GITHUB_STEP_SUMMARY" | |
| build: | |
| runs-on: ubuntu-latest | |
| # `NEXT_PRIVATE_STANDALONE` is what `@opennextjs/aws` sets before it runs | |
| # `next build` — its own comment reads "Equivalent to setting `output: | |
| # "standalone"` in next.config.js". Without it a plain `next build` | |
| # produces no `.next/standalone/`, and the packaging step below fails on a | |
| # missing `pages-manifest.json` three directories inside it. Measured on | |
| # this branch before it was set: `ENOENT ... .next/standalone/apps/docs/ | |
| # .next/server/pages-manifest.json`. | |
| # | |
| # Set for the whole job rather than for the deploy path only, so that what | |
| # a pull request builds is the same shape as what gets published. A build | |
| # that differs from the deploy build is a small instance of the defect this | |
| # card is about. | |
| # | |
| # It has to be declared in `turbo.json` as well: turbo 2 runs tasks in | |
| # strict env mode, so an undeclared variable never reaches `next build` — | |
| # and declaring it is also what puts it in the cache key, so a `.next` | |
| # cached from before this line cannot be replayed without the standalone | |
| # tree the packaging step needs. | |
| env: | |
| NEXT_PRIVATE_STANDALONE: 'true' | |
| 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 | |
| # `scripts/pm/check-half-states.mjs` is a verbatim upstream copy (#237) | |
| # whose 1551 cases were run by nothing here. It is a STEP and not a | |
| # registry entry because `tools/ci-scripts/run-self-tests.mjs` scans | |
| # `.github/scripts` top level only, and that is load-bearing — it is what | |
| # lets `.github/scripts/lib/` exist without tripping the | |
| # unregistered-self-test rule. Widening the scan to reach one script would | |
| # change this repo's gate topology; a step changes nothing. | |
| # | |
| # What it buys is a drift detector, not a hash-pin: #237's ablation | |
| # mutated `DEFAULT_SWEEP_REPO` in this copy and 2 of the 1551 went red, so | |
| # an edit here that changes BEHAVIOUR fails. One that changes bytes | |
| # without changing behaviour still passes — pinning this copy to upstream | |
| # byte for byte needs a cross-repo credential and is a separate decision. | |
| # | |
| # `--self-test` is the whole of it. Without the flag the script runs a | |
| # live sweep needing a transport prerequisite this job does not have; that | |
| # caller is `.github/workflows/half-state-patrol.yml`, on its own | |
| # schedule. Zero-dependency and about a second, so it runs before the | |
| # build rather than behind it. | |
| - name: Half-state sweeper self-test | |
| shell: bash | |
| run: node scripts/pm/check-half-states.mjs --self-test | |
| # `content/docs/**/*.zh-Hant.mdx` and `meta.zh-Hant.json` are generated | |
| # from the Simplified siblings by `apps/docs/scripts/gen-zh-hant.mjs` and | |
| # committed, because `lib/seo.ts` tells a real translation from an English | |
| # fallback by the presence of a locale-suffixed FILE — a conversion done | |
| # while rendering would leave the locale out of every sitemap entry and | |
| # hreflang cluster. Committed output needs a gate or it drifts: this | |
| # regenerates in memory and compares bytes, so a hand edit, a stale file | |
| # whose source was retired, and a converter upgrade nobody re-ran all fail | |
| # here with the same one-line fix. | |
| # | |
| # Before `type-check` on purpose: it needs no build, and a content drift | |
| # reported as a type error is a wrong first diagnosis. | |
| - name: Generated zh-Hant is current | |
| run: node apps/docs/scripts/gen-zh-hant.mjs --check | |
| - run: pnpm turbo run type-check --continue | |
| - run: pnpm turbo run build | |
| # Reads the BUILT sitemap and asserts its locale composition against an | |
| # oracle derived from `content/docs/`. It has to come after `build` — the | |
| # measurement is taken off the artifact, because importing `sitemap.ts` | |
| # pulls in the whole MDX collection and the `@/` alias. | |
| # | |
| # Not a turbo task on purpose: turbo would hash it against the ci-scripts | |
| # package's own inputs, which do not include `apps/docs/.next/`, so a | |
| # locale regression would replay a cached green. `pnpm turbo run test` | |
| # below still runs this script's `--self-test`, which is what keeps its | |
| # rules provably able to fail. | |
| # | |
| # `shell: bash` is load-bearing, not tidiness. The DEFAULT shell for a | |
| # `run:` step is `bash -e {0}` with no pipefail, so in `node ... | tee` | |
| # the step takes tee's exit status and a gate that exits 1 passes the job | |
| # silently. Naming the shell gets `bash --noprofile --norc -eo pipefail | |
| # {0}`, which propagates it. | |
| - name: Locale surface | |
| shell: bash | |
| run: node .github/scripts/check-locale-surface.mjs | tee -a "$GITHUB_STEP_SUMMARY" | |
| - run: pnpm turbo run test | |
| # Defect 2 of #269: the deploy used to run its own `pnpm install` and its | |
| # own `opennextjs-cloudflare build`, so CI built the site, threw it away, | |
| # and the deploy published a SECOND build that nothing here had checked. | |
| # The published artifact was unverified by construction. | |
| # | |
| # `--skipNextBuild` packages the `.next` output `pnpm turbo run build` | |
| # produced above — the same output `Locale surface` measured and the same | |
| # tree every step in this job passed — and `deploy-docs.yml` uploads THIS | |
| # bundle rather than making another one. | |
| # | |
| # 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' | |
| 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' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: docs-worker | |
| path: apps/docs/.open-next | |
| include-hidden-files: true | |
| 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 | |
| # no `workflow_run` anywhere. | |
| # | |
| # As a job here it cannot start until `node-floor` and `build` are green, and | |
| # the `if:` keeps it off pull requests and merge groups. `workflow_run` would | |
| # also have gated it, but it fires on a FAILED run too — the conclusion has | |
| # to be re-checked by hand inside the workflow — and it runs detached from | |
| # the run whose artifact it publishes, which is what the `with:` line here | |
| # depends on. | |
| # | |
| # ⚠️ This deploy is expected to FAIL while #261 is open: `main` builds a | |
| # Worker over Cloudflare's 64 MiB limit, so the upload is rejected at version | |
| # creation and the serving version cannot be displaced. That is the current | |
| # deliberate steady state, not a regression from this wiring. | |
| deploy-docs: | |
| name: Deploy docs | |
| needs: [node-floor, build] | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| # Preserves what `deploy-docs.yml` declared for itself before it became a | |
| # called workflow: one deploy at a time, and never cancel one in flight. | |
| concurrency: | |
| group: deploy-docs | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| actions: write # dispatch rollback-docs.yml when the smoke check fails | |
| issues: write # file or update the one deploy-failure card | |
| uses: ./.github/workflows/deploy-docs.yml | |
| with: | |
| artifact_name: docs-worker | |
| secrets: inherit |