ci: say what the Worker size gate's reading is repeatable to, not only what it is accurate to #425
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. | |
| # | |
| # ## Accurate against the limit; NOT repeatable to better than a few KiB | |
| # | |
| # #277. Accuracy is this gate's job and the figure above is the right | |
| # claim for it. It says nothing about REPEATABILITY, and a reader who | |
| # meets 0.0033% at the point of use will reasonably assume a small | |
| # per-PR delta means something. Measured, it does not. | |
| # | |
| # The control is ONE commit run twice, with no tree difference at all — | |
| # not a workflow file, not a specifier line, nothing. `66cb0a4` is an | |
| # empty commit whose tree hash is byte-identical to `main`'s at | |
| # `50aacb9`; run 34252385192, attempts 1 and 2, landed on two different | |
| # `ubuntu-latest` runners: | |
| # attempt 1: Total Upload: 58549.04 KiB | |
| # attempt 2: Total Upload: 58553.46 KiB | |
| # 4.42 KiB apart. That is larger than the 1.96 KiB agreement above, so | |
| # the agreement cannot be read as sub-KiB precision either — it is one | |
| # comparison, taken inside this much noise. | |
| # | |
| # Holding the machine fixed shrinks the spread without closing it: four | |
| # cold rebuilds of that same tree in one container gave 58548.93, | |
| # 58548.93, 58548.94 and 58549.55 KiB — 0.62 KiB apart. | |
| # | |
| # So this number answers "is the bundle near the ceiling", not "did my | |
| # PR grow the bundle". A single-digit-KiB move between two commits is | |
| # inside the spread measured above and has not been shown to be a change | |
| # in the bundle at all; a real growth of a few KiB is equally invisible. | |
| # Attributing a small delta needs a same-tree control — the same commit | |
| # run twice — not the previous commit's reading. | |
| # | |
| # n is small (2 CI runs, 4 local builds) and 4.42 KiB is the largest | |
| # same-tree gap MEASURED, not a proven bound. Causes are NOT measured | |
| # here and none is claimed. | |
| # | |
| # ## 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 | |
| # #274. The 09-04 outage was found by a human looking at the live site. | |
| # #269 answered that with detection and recovery — the deploy is gated on | |
| # CI, the published artifact is the one CI tested, the live site is | |
| # smoke-checked after deploying, and a bad deploy auto-rolls back — but | |
| # the only environment in which a rendering defect is DETECTED is still | |
| # production, and the chain `deploy succeeds -> smoke fails -> rollback | |
| # fires` has never once executed end to end. A check that fires before | |
| # merge costs a red pull request; the same check firing after merge costs | |
| # a live outage plus a recovery path nobody has ever seen run. | |
| # | |
| # ## The same script, pointed at a different base | |
| # | |
| # `.github/scripts/smoke-docs.mjs` is the post-deploy check | |
| # `deploy-docs.yml` runs against `https://docs.objectos.ai`. It is | |
| # invoked here unmodified, with `--base` pointing at a local preview. | |
| # NOT a second implementation of "does the site render": two copies of | |
| # those rules drift, and the copy that drifts is the one nobody watches. | |
| # Its live negative control — a `/docs/` slug no page claims, which must | |
| # produce findings or the run fails on `negative-control-passed` — comes | |
| # along with it, which is what makes a green here worth reading. | |
| # | |
| # ## This is not a second build | |
| # | |
| # `opennextjs-cloudflare preview` does not build. It populates the | |
| # incremental cache (for this app, a copy of `.open-next/cache` into the | |
| # Workers static assets) and then runs `wrangler dev` on the `.open-next` | |
| # package the step above produced with `--skipNextBuild`. Since #262 | |
| # removed the `main`-only condition from that packaging step, that | |
| # package exists on every pull request, so this step adds a preview boot | |
| # and four fetches and nothing else. Measured in this repo's container, | |
| # against the package already sitting in the tree: `Ready on` at 41 s, | |
| # the smoke run itself 1 s. No `opennextjs-cloudflare build`, no | |
| # `next build`, no Cloudflare credentials — `wrangler dev` serves the | |
| # Worker locally under real workerd. | |
| # | |
| # ## Why it runs LAST, after the artifact upload | |
| # | |
| # `preview` copies `.open-next/cache` into `.open-next/assets/cdn-cgi`, | |
| # which for this app is 268 MB: measured, `.open-next` goes from 386 MB | |
| # to 653 MB the moment the preview boots. `opennextjs-cloudflare deploy` | |
| # makes that same copy in the deploy job from `.open-next/cache`, which | |
| # the artifact already carries — so running this before the upload would | |
| # add 268 MB to every `main` artifact, both ways across the wire, to | |
| # ship a copy the deploy remakes anyway. Placed here, the uploaded bundle | |
| # is byte-for-byte what it was before this step existed, and the gating | |
| # is unchanged: `deploy-docs` needs the whole `build` job, so a red here | |
| # keeps a bad render off production just as a red anywhere above it does. | |
| # | |
| # ## A dead server must not read as a pass | |
| # | |
| # "No findings" from a preview that never started is indistinguishable | |
| # from "the site renders", and this lane logged four probes of exactly | |
| # that shape in a single day. So readiness is asserted from wrangler's | |
| # own `Ready on` line before anything is judged, with the preview log | |
| # printed into the step summary when it does not arrive, and the step | |
| # exits 1 rather than reporting a measurement it never took. | |
| # | |
| # `setsid` is load-bearing, not tidiness. The preview is a chain of six | |
| # processes — pnpm, node, sh, pnpm, wrangler, workerd — and a SIGTERM to | |
| # the pnpm wrapper at the top leaves workerd running and holding the | |
| # port. Measured here: the cleanup `wait` never returned and the whole | |
| # thing hung. Starting it in its own process group lets the trap signal | |
| # the GROUP and take the chain with it; the `$$` comparison is there so | |
| # that a `setsid` which did not take effect can never turn that into the | |
| # step killing itself. | |
| - name: The docs site renders — smoke-check a local preview | |
| working-directory: apps/docs | |
| shell: bash | |
| env: | |
| PREVIEW_PORT: '8792' | |
| # Boot budget for the preview. Measured at 41 s in this repo's | |
| # container; the margin is for a cold runner, and overrunning it is | |
| # a finding (NOT MEASURED), never a skip. | |
| PREVIEW_READY_TIMEOUT_S: '180' | |
| WRANGLER_SEND_METRICS: 'false' | |
| run: | | |
| set -euo pipefail | |
| BASE="http://127.0.0.1:${PREVIEW_PORT}" | |
| PREVIEW_LOG="$RUNNER_TEMP/preview.log" | |
| SMOKE_LOG="$RUNNER_TEMP/smoke.log" | |
| : > "$PREVIEW_LOG" | |
| setsid pnpm exec opennextjs-cloudflare preview -- \ | |
| --port "$PREVIEW_PORT" --ip 127.0.0.1 > "$PREVIEW_LOG" 2>&1 & | |
| PREVIEW_PID=$! | |
| SELF_PGID="$(ps -o pgid= -p $$ | tr -d ' ')" | |
| # The preview's process group is read HERE, at kill time, and never | |
| # cached at launch. `setsid` only changes the group once the forked | |
| # child has exec'd it, so a `ps` issued straight after `&` is a race | |
| # that can still see the STEP's own group. Measured on a GitHub | |
| # runner, run 34250422860: the cached read lost that race, the guard | |
| # below fell back to signalling the pnpm wrapper alone, and the | |
| # runner's own orphan sweeper had to terminate esbuild and two | |
| # workerd processes after the job. The identical code cleaned up | |
| # correctly in this repo's container every time — which is exactly | |
| # how a race presents, and why the group is resolved at use. | |
| cleanup() { | |
| PREVIEW_PGID="$(ps -o pgid= -p "$PREVIEW_PID" 2>/dev/null | tr -d ' ' || true)" | |
| if [ -z "$PREVIEW_PGID" ]; then | |
| : # already gone — nothing to signal | |
| elif [ "$PREVIEW_PGID" != "$SELF_PGID" ]; then | |
| kill -TERM "-$PREVIEW_PGID" 2>/dev/null || true | |
| for _ in 1 2 3 4 5; do | |
| pgrep -g "$PREVIEW_PGID" >/dev/null 2>&1 || break | |
| sleep 1 | |
| done | |
| kill -KILL "-$PREVIEW_PGID" 2>/dev/null || true | |
| else | |
| # `setsid` did not take effect and the preview is sharing this | |
| # step's group, which must NEVER be signalled as a group or the | |
| # step kills itself. Signal the pid and say so out loud rather | |
| # than leaving a silent orphan for the runner to sweep. | |
| kill -TERM "$PREVIEW_PID" 2>/dev/null || true | |
| echo "::warning::preview pid $PREVIEW_PID is in this step's own process group — signalled the pid alone, its descendants may survive." | |
| fi | |
| } | |
| trap cleanup EXIT | |
| READY=0 | |
| for _ in $(seq 1 "$PREVIEW_READY_TIMEOUT_S"); do | |
| if grep -q 'Ready on http' "$PREVIEW_LOG"; then READY=1; break; fi | |
| if ! kill -0 "$PREVIEW_PID" 2>/dev/null; then break; fi | |
| sleep 1 | |
| done | |
| if [ "$READY" -ne 1 ]; then | |
| # Which of the two shapes it was. They call for different fixes — | |
| # a crashed preview is a broken bundle, an exhausted budget is a | |
| # slow runner — and the log below is the same either way, so the | |
| # sentence has to say which one the reader is looking at. | |
| if kill -0 "$PREVIEW_PID" 2>/dev/null; then | |
| WHY="the ${PREVIEW_READY_TIMEOUT_S}s boot budget ran out with the preview still starting" | |
| else | |
| WHY="the preview process exited before it was ready" | |
| fi | |
| { | |
| echo "### Pre-merge render check — NOT MEASURED" | |
| echo | |
| echo "The local preview never printed \`Ready on\`: ${WHY}. Nothing was checked." | |
| echo "Failing rather than passing: \"no findings\" from a server that never started" | |
| echo "is indistinguishable from a rendered site." | |
| echo | |
| echo '```' | |
| tail -n 40 "$PREVIEW_LOG" | |
| echo '```' | |
| } | tee -a "$GITHUB_STEP_SUMMARY" | |
| echo "::error::Local preview never became ready (${WHY}) — the render check measured nothing." | |
| exit 1 | |
| fi | |
| echo "preview ready: $(grep -m1 'Ready on http' "$PREVIEW_LOG" || true)" | |
| echo "preview pid $PREVIEW_PID in process group $(ps -o pgid= -p "$PREVIEW_PID" | tr -d ' '), step in $SELF_PGID" | |
| # Exit code captured before anything pipes it. `cmd | tee` hands back | |
| # tee's status, and this step's verdict is the script's. | |
| set +e | |
| node "$GITHUB_WORKSPACE/.github/scripts/smoke-docs.mjs" --base "$BASE" \ | |
| > "$SMOKE_LOG" 2>&1 | |
| SMOKE_EXIT=$? | |
| set -e | |
| cat "$SMOKE_LOG" | |
| { | |
| if [ "$SMOKE_EXIT" -eq 0 ]; then | |
| echo "### Pre-merge render check — the site renders" | |
| else | |
| echo "### Pre-merge render check — FINDINGS" | |
| fi | |
| echo | |
| echo "Ran \`.github/scripts/smoke-docs.mjs\` — the same script \`deploy-docs.yml\` runs" | |
| echo "against the live site — against a local \`opennextjs-cloudflare preview\` of the" | |
| echo "Worker this job packaged, at \`$BASE\`." | |
| echo | |
| echo '```' | |
| cat "$SMOKE_LOG" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit "$SMOKE_EXIT" | |
| # 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 |