ci: fail the build when the docs Worker exceeds a declared size budget #416
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. | |
| # | |
| # #262 removed the `push` + `refs/heads/main` condition this step used to | |
| # carry. It ran only where a deploy would follow, which meant a pull | |
| # request never packaged a Worker and therefore could never be told its | |
| # Worker was too big — the whole defect that card is about. It runs on | |
| # every event now so that the size gate below has something to weigh, and | |
| # the artifact upload stays `main`-only underneath it. | |
| # | |
| # This is NOT a second build, and that distinction is what makes the | |
| # price acceptable: `--skipNextBuild` re-packages the `.next` tree | |
| # `pnpm turbo run build` already produced. Measured on this branch: | |
| # `turbo run build` 101s, this packaging step 24s, the dry-run weigh-in | |
| # below 9s. A pull request pays ~33s more than before, not another 101s. | |
| - name: Package the Worker from the build this job tested | |
| working-directory: apps/docs | |
| run: pnpm exec opennextjs-cloudflare build --skipNextBuild | |
| # #262. Nothing in this repository ever weighed the Worker. The only | |
| # thing that checked it was the Cloudflare API, at upload time, on | |
| # `main`, AFTER merge — and the rejection lands on version CREATION, so | |
| # nothing 500s, no page changes, and the site silently stops moving. That | |
| # is how this repo ran 35 consecutive red deploys (runs #106-#140, | |
| # 2026-08-25 to 09-02) with the `build` job green for every one of them: | |
| # `build` compiles the Next app, it never bundled or weighed the Worker. | |
| # | |
| # ## Why `wrangler deploy --dry-run` and not `stat` | |
| # | |
| # The number Cloudflare enforces is wrangler's `Total Upload:` line, and | |
| # `--dry-run` prints it from the same code path a real deploy uses, | |
| # without calling the API and without credentials. Stat-ing files instead | |
| # would mean re-deriving WHICH files count, and that guess is the trap | |
| # #262 names: `handler.mjs` alone measures 48.48 MiB while the upload is | |
| # 58553.98 KiB, so a budget stated against it tracks nothing. | |
| # | |
| # Verified on this branch, at `0e26657f`, from `--dry-run --outdir`: | |
| # the uploaded set is `worker.js` (58383222 B) plus three sidecar | |
| # modules — resvg.wasm (1378357 B), Geist-Regular.ttf.bin (125956 B), | |
| # yoga.wasm (71736 B) = 59959271 B = 58553.98 KiB, which is the printed | |
| # line to the hundredth. `worker.js.map` (85819146 B, larger than the | |
| # whole budget) and the `.open-next/assets` + `.open-next/cache` trees | |
| # are NOT in it; assets upload separately and do not count here. | |
| # | |
| # ## Calibration against a number Cloudflare actually accepted | |
| # | |
| # Same commit `0e26657f`, run 33891143864, Worker version | |
| # 2170b929-5879-4b3f-b7a2-9eda750158dd, the upload Cloudflare ACCEPTED: | |
| # Total Upload: 58555.94 KiB | |
| # This step, on that commit: | |
| # Total Upload: 58553.98 KiB | |
| # 1.96 KiB low — 0.0033%. The reading tracks the enforced figure. | |
| # | |
| # ## The budget | |
| # | |
| # ONE constant, below, with the limit written beside it; every | |
| # percentage and headroom figure is COMPUTED from those two and never | |
| # typed. #262's own banner is why: it quoted `89.3%` (against 65536) and | |
| # `~5.3 MiB headroom` (against 64000) in the same paragraph, two ceilings | |
| # in one card, ~1.5 MiB of phantom room. The limit is 65536 KiB, taken | |
| # from run #140's own rejection text. | |
| # | |
| # 61440 KiB = 60 MiB = 93.75% of the limit. It has to clear two bars: | |
| # - above today's 58553.98 KiB (89.35%), or it is red on arrival — | |
| # 2886.02 KiB, 4.93% of growth room; | |
| # - below run #105's 62747.87 KiB (95.75%), the LAST SUCCESSFUL deploy | |
| # before the outage, or it would have watched that go by. The commit | |
| # that finally crossed the line was 15 lines and made its own output | |
| # smaller; at 95.7% anything landing that week would have done it. | |
| # This budget goes red 1307.87 KiB before that point. | |
| # | |
| # No warn tier on purpose. Every band you could draw between today's | |
| # 89.35% and this budget's 93.75% is under 4.5 points wide and the bundle | |
| # is already inside it, so a warning would be lit from its first run and | |
| # read as wallpaper. The reading is printed to the step summary on EVERY | |
| # run instead — visible before it is a problem, which is what a warn tier | |
| # was wanted for. | |
| # | |
| # `shell: bash` is load-bearing, not tidiness. The DEFAULT shell for a | |
| # `run:` step is `bash -e {0}` with no pipefail, so in `awk ... | 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. | |
| # | |
| # Ablated before it was trusted, per the pattern | |
| # `.github/scripts/smoke-docs.mjs` already sets here — a probe that | |
| # cannot fail is indistinguishable from one that passed. Padding the | |
| # bundle by 4 MiB took the reading to 62650.01 KiB (95.60%, within 98 KiB | |
| # of run #105's pre-outage figure) and this step exited 1; restoring the | |
| # bundle byte-for-byte returned it to exit 0. Deleting the `Total | |
| # Upload:` line from wrangler's output exits 1 as well, rather than | |
| # passing on a measurement it never took. | |
| - name: Worker bundle fits the size budget | |
| working-directory: apps/docs | |
| shell: bash | |
| env: | |
| # The budget, and the limit it is set below. Nothing else in this | |
| # repository states a Worker size; every other figure is derived. | |
| # Cloudflare's limit 65536 KiB (64 MiB, from run #140's rejection) | |
| # this budget 61440 KiB (60 MiB, 93.75% of the limit) | |
| WORKER_BUDGET_KIB: '61440' | |
| WORKER_LIMIT_KIB: '65536' | |
| WRANGLER_SEND_METRICS: 'false' | |
| run: | | |
| set -euo pipefail | |
| pnpm exec wrangler deploy --dry-run 2>&1 | tee "$RUNNER_TEMP/worker-size.log" | |
| SIZE_KIB="$(sed -n 's/^.*Total Upload: \([0-9][0-9.]*\) KiB.*$/\1/p' "$RUNNER_TEMP/worker-size.log" | tail -n 1)" | |
| # An unreadable measurement is a finding, never a skip. A size gate | |
| # that silently weighs nothing passes forever, which is the failure | |
| # mode this step exists to end rather than to reproduce. | |
| if [ -z "$SIZE_KIB" ]; then | |
| { | |
| echo "### Worker bundle size — NOT MEASURED" | |
| echo | |
| echo "\`wrangler deploy --dry-run\` printed no \`Total Upload:\` line, so this step" | |
| echo "weighed nothing. Failing rather than passing: an unmeasured bundle is the" | |
| echo "state this gate exists to end." | |
| } | tee -a "$GITHUB_STEP_SUMMARY" | |
| echo "::error::Worker bundle NOT measured — no 'Total Upload:' line in the wrangler dry-run output." | |
| exit 1 | |
| fi | |
| awk -v size="$SIZE_KIB" -v budget="$WORKER_BUDGET_KIB" -v limit="$WORKER_LIMIT_KIB" ' | |
| BEGIN { | |
| over = (size > budget) | |
| printf "### Worker bundle size — %s\n\n", (over ? "OVER BUDGET" : "within budget") | |
| printf "| | KiB | %% of limit |\n|:--|--:|--:|\n" | |
| printf "| measured | %.2f | %.2f %% |\n", size, size / limit * 100 | |
| printf "| budget | %d | %.2f %% |\n", budget, budget / limit * 100 | |
| printf "| Cloudflare limit | %d | 100 %% |\n", limit | |
| printf "\nHeadroom to budget: **%.2f KiB** · headroom to the limit: %.2f KiB\n", budget - size, limit - size | |
| if (over) { | |
| printf "\nThis change takes the Worker past the declared budget.\n\n" | |
| printf "Cloudflare rejects an over-limit upload at VERSION CREATION, on `main`, after\n" | |
| printf "merge: nothing 500s, no page changes, the site simply stops being updated.\n" | |
| printf "This repo ran 35 consecutive red deploys that way with `build` green for every\n" | |
| printf "one of them. Reduce the bundle, or raise the budget deliberately and say why.\n" | |
| } | |
| exit (over ? 1 : 0) | |
| }' | tee -a "$GITHUB_STEP_SUMMARY" | |
| # `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. | |
| # | |
| # Still `main`-only: a pull request now packages and weighs a Worker, but | |
| # it has nothing to deploy, so it uploads nothing. An `if:` carrying no | |
| # status function implies `success()`, so the size gate above also gates | |
| # this — an over-budget Worker never becomes an artifact and never | |
| # reaches `deploy-docs.yml`. | |
| - name: Upload the Worker bundle | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| 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 | |
| # 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 |