per-PR preview deployments on Cloudflare Pages
每 PR 预览部署上 Cloudflare Pages
The browser worker preview exists to observe a pull request's frontend and host code running, so it needs a static host per pull request that outsiders cannot reach. GitHub Pages publishes privately only on GitHub Enterprise Cloud, which this organization has not settled, and one Pages site per repository cannot isolate pull requests. The first deployment run also exposed a packaging defect: on a clean checkout `pnpm
English
Problem
The browser worker preview exists to observe a pull request's frontend and host code running, so it needs a static host per pull request that outsiders cannot reach. GitHub Pages publishes privately only on GitHub Enterprise Cloud, which this organization has not settled, and one Pages site per repository cannot isolate pull requests. The first deployment run also exposed a packaging defect: on a clean checkout pnpm install never creates the dsh-pack-vfs-image bin link, so build:preview fails with command not found anywhere but a working tree whose install ran after a build.
Decision
Deployment. Every push to a pull request publishes apps/web/dist to the Cloudflare Pages project dsh-build-preview under the branch alias pr-<number>, behind Cloudflare Access (.github/workflows/build-preview-cloudflare.yml). The upload carries build products only — the platform never holds repository sources, and sourcemaps are deleted before upload because they embed complete sources. preview.html replaces index.html as the deployment root: the served page cannot boot without a host injecting window.__DSH_BOOT__, so the root must be the page that boots. Per pull request the newest build wins; across pull requests each alias is its own URL, so nothing contends. The run passes only after a service-token request proves the protected URL serves the packed image: HTTP 200 (Access admitted the token; 302 means the Access policy lacks its Service Auth rule), no content-encoding (the platform must not claim transport compression over an already-compressed body, which would leave the worker's DecompressionStream inflating a plain tar), and the gzip magic 1f 8b. A marker-guarded comment states the stable alias URL once per pull request.
Bin link. pnpm creates a workspace bin link only when the link target exists at install time. A bin entry naming a build product (lib/bin.js) therefore never gets its link on a clean checkout — building later does not revisit linking. The packer commits a root bin.js as the stable link target; it forwards to lib/bin.js and, when the build product is missing, names pnpm run build and exits 1. Same pattern as dsh-subprocess-local's committed spawn-helper entry.
Alternatives considered
GitHub Pages, privately published. Enterprise-Cloud-only, and deploy-pages replaces the whole site, so pull requests would overwrite each other; per-branch subdirectories require the legacy branch-deploy path and its build-rate limits.
Actions artifact as the preview. Download permission aligns exactly with repository read access and costs nothing, but an artifact is a zip download, not a browsable site. Kept as the fallback if the Cloudflare surface goes away.
Documenting "install again after building" instead of committing a link target. Leaves every clean checkout broken in an order-dependent way the error message does not explain; CI is precisely such a checkout on every run.
Consequences
A pull request's preview lives at https://pr-<number>.dsh-build-preview.pages.dev and demands a Cloudflare Access sign-in; automation reaches it with a service token. The deployment platform holds no sources and no sourcemaps, which also means the preview cannot map its bundles back to source until sourcemap handling is designed deliberately. The image byte path — bytes stored compressed, served without transport re-encoding — is asserted on every deployment, so a platform behavior change fails the run instead of the worker boot. The packer bin works from any clean checkout after one full build, and the constraints table pins bin.js in the published file list.
中文
问题
浏览器 worker 预览的存在意义是观察某个 pull request 的前端与 host 代码运行态,因此需要一个外人无法访问的、按 pull request 隔离的静态托管。GitHub Pages 的私有发布只在 GitHub Enterprise Cloud 上可用,而本组织尚未定夺;且一个仓库一个 Pages 站点无法隔离多个 pull request。首次部署运行还暴露了一个打包缺陷:干净 checkout 上 pnpm install 永远不会创建 dsh-pack-vfs-image 的 bin 链接,build:preview 在任何「install 不是在 build 之后跑的」工作树上都以 command not found 失败。
决定
**部署。**pull request 的每次推送把 apps/web/dist 发布到 Cloudflare Pages 项目 dsh-build-preview 的分支别名 pr-<number> 下,置于 Cloudflare Access 之后(.github/workflows/build-preview-cloudflare.yml)。上传只携带构建产物——平台永远拿不到仓库源码,sourcemap 因内嵌完整源码在上传前删除。preview.html 顶替 index.html 成为部署根:served 页面没有 host 注入 window.__DSH_BOOT__ 就无法启动,所以根必须是能启动的那张页。同一 pull request 内最新构建胜出;不同 pull request 各占各的别名 URL,互不争抢。运行只有在 service token 请求证明受保护 URL 真的送达打包镜像后才算通过:HTTP 200(Access 放行了该 token;302 意味着 Access 策略缺 Service Auth 规则)、无 content-encoding(平台不得对已压缩的 body 声明传输压缩,否则 worker 的 DecompressionStream 会对着解开的裸 tar 充气)、gzip 魔数 1f 8b。带标记守卫的评论对每个 pull request 只报一次稳定别名 URL。
**bin 链接。**pnpm 只在链接目标于 install 时已存在的情况下创建 workspace bin 链接。bin 指向构建产物(lib/bin.js)因此在干净 checkout 上永远得不到链接——事后构建不会补建链接。packer 在包根提交 bin.js 作为稳定链接目标;它转发到 lib/bin.js,构建产物缺失时点名 pnpm run build 并以 1 退出。与 dsh-subprocess-local 提交 spawn-helper 入口是同一模式。
曾考虑的替代方案
**GitHub Pages 私有发布。**Enterprise Cloud 独占,且 deploy-pages 整站替换,多个 pull request 会互相覆盖;按分支子目录要走遗留的分支部署通道并吃其构建频率限制。
**用 Actions artifact 当预览。**下载权限与仓库 read 权限逐字对齐、零成本,但 artifact 是 zip 下载不是可浏览的站点。留作 Cloudflare 面失效时的兜底。
**用「build 之后再 install 一次」的文档说明代替提交链接目标。**让每个干净 checkout 都以一种错误信息解释不了的、依赖顺序的方式坏掉;CI 每次运行恰恰就是这样的 checkout。
后果
pull request 的预览位于 https://pr-<number>.dsh-build-preview.pages.dev,访问要求 Cloudflare Access 登录;自动化用 service token 通行。部署平台不持有源码与 sourcemap,这也意味着在 sourcemap 处理被专门设计之前,预览无法把 bundle 映射回源码。镜像的字节通路——压缩存储、无传输再编码送达——在每次部署时被断言,平台行为变化会让运行失败而不是让 worker 启动失败。packer bin 在任何干净 checkout 上一次完整构建后即可用,constraints 表把 bin.js 钉进发布文件清单。