diff --git a/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml new file mode 100644 index 0000000000..83f673d68e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md +2026-08-20-preview-cloudflare-pages-deploy.md: 38b2834d612153d235d3b0aaffead3bd2b33eec7 +2026-08-20-preview-cloudflare-pages-deploy.zh.md: 3ebb8eba95666729ee1021ffe2c958dad40aed1c diff --git a/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md new file mode 100644 index 0000000000..38b2834d61 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md @@ -0,0 +1,27 @@ +# Agent Note: per-PR preview deployments on Cloudflare Pages + +Status: implemented + +English | [中文](2026-08-20-preview-cloudflare-pages-deploy.zh.md) + +## 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-`, 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-.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. diff --git a/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md new file mode 100644 index 0000000000..3ebb8eba95 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md @@ -0,0 +1,27 @@ +# Agent Note:每 PR 预览部署上 Cloudflare Pages + +状态:已实现 + +[English](2026-08-20-preview-cloudflare-pages-deploy.md) | 中文 + +## 问题 + +浏览器 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-` 下,置于 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-.dsh-build-preview.pages.dev`,访问要求 Cloudflare Access 登录;自动化用 service token 通行。部署平台不持有源码与 sourcemap,这也意味着在 sourcemap 处理被专门设计之前,预览无法把 bundle 映射回源码。镜像的字节通路——压缩存储、无传输再编码送达——在每次部署时被断言,平台行为变化会让运行失败而不是让 worker 启动失败。packer bin 在任何干净 checkout 上一次完整构建后即可用,constraints 表把 `bin.js` 钉进发布文件清单。 diff --git a/.github/workflows/build-preview-cloudflare.yml b/.github/workflows/build-preview-cloudflare.yml new file mode 100644 index 0000000000..1c9ef206c3 --- /dev/null +++ b/.github/workflows/build-preview-cloudflare.yml @@ -0,0 +1,166 @@ +name: Build PR preview + +# Every push to a pull request publishes that pull request's preview to +# Cloudflare Pages under its own branch alias, behind Cloudflare Access. The +# upload carries build products only: the workflow never grants the deployment +# platform access to this repository's sources. + +on: + pull_request: + types: [opened, synchronize, reopened] + +# Within one pull request the newest build wins. Across pull requests there is +# nothing to serialize: each uploads to its own branch alias, so two deployments +# never contend for the same URL. +concurrency: + group: build-preview-cloudflare-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +env: + PRIMARY_NODE_VERSION: '24' + # Cloudflare Pages project receiving the upload. Its preview deployments are + # the surface the Access application protects; the project's production branch + # is deliberately a name no deployment uses, so no unprotected URL exists. + CF_PROJECT: dsh-build-preview + # CI runs must never report to the production telemetry endpoint baked into + # apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + +jobs: + preview: + runs-on: dsh-ubuntu-24-04-16core + name: cloudflare pages preview + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Read-only: the preview lane consumes the default-branch cache without + # putting cache upload on its own path. + - uses: actions/cache/restore@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + # apps/web consumes workspace packages as built lib products, and + # build:preview packs the image through the packer's installed bin + # (lib/bin.js), so neither half exists before the full build runs. + - name: Build workspace + run: pnpm run build + + - name: Build the preview page and pack the VFS image + env: + DSH_CLIENT_TITLE: DSH preview pr-${{ github.event.pull_request.number }} + run: pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview + + # Sourcemaps carry complete sources and stay off the deployment platform. + # index.html is the served page, which cannot boot without a host + # injecting window.__DSH_BOOT__; replacing it with the worker page makes + # the deployment root the usable entry instead of a page that never boots. + - name: Shape the upload + run: | + find apps/web/dist -name '*.map' -delete + cp apps/web/dist/preview.html apps/web/dist/index.html + + - name: Upload to Cloudflare Pages + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + npx --yes wrangler@4 pages deploy apps/web/dist \ + --project-name "$CF_PROJECT" \ + --branch "pr-${{ github.event.pull_request.number }}" \ + --commit-dirty=true + + # The image is what a worker boot fails on first and least visibly, so the + # run only passes once the protected URL serves it as gzip bytes. Three + # facts are asserted, each with its own failure meaning: + # 200 Access admitted the request; a 302 means the + # Access policy is missing its Service Auth rule + # for this token + # no content-encoding the platform did not claim transport + # compression, which would make the browser + # decode the body and leave the worker's + # DecompressionStream inflating a plain tar + # gzip magic 1f 8b the bytes really are the gzip member the + # packer wrote + # Accept-Encoding is sent because a browser sends it; the assertion is + # about what the platform does with a body that is already compressed. + - name: Verify the protected deployment serves the image + env: + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + run: | + url="https://pr-${{ github.event.pull_request.number }}.${CF_PROJECT}.pages.dev" + image="$url/preview/vfs-image.tar.gz" + code=000 + for attempt in 1 2 3 4 5; do + code=$(curl -sS -o image.bin -D headers.txt -w '%{http_code}' \ + -H 'Accept-Encoding: gzip' \ + -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \ + "$image" || echo 000) + echo "attempt $attempt: HTTP $code" + if [ "$code" = "200" ]; then break; fi + sleep 10 + done + if [ "$code" != "200" ]; then + echo "the protected image URL answered $code, not 200" + head -20 headers.txt + exit 1 + fi + if grep -qi '^content-encoding:' headers.txt; then + echo "the platform declared transport compression on an already-compressed image:" + grep -i '^content-encoding:' headers.txt + exit 1 + fi + magic=$(head -c 2 image.bin | od -An -tx1 | tr -d ' \n') + if [ "$magic" != "1f8b" ]; then + echo "image does not start with the gzip magic number: $magic" + exit 1 + fi + echo "image served as $(wc -c < image.bin) gzip bytes" + + # The alias URL follows from the pull request number, so it is stable + # across redeploys and worth stating once. The marker makes the comment + # idempotent: a pull request opened before this workflow existed never + # sees an `opened` event, and every later push must not restate the URL. + - name: Comment the preview URL + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + marker='' + existing=$(gh pr view "$PR" --json comments \ + --jq "[.comments[] | select(.body | contains(\"$marker\")) | .url] | first // empty") + if [ -n "$existing" ]; then + echo "preview URL already commented: $existing" + exit 0 + fi + gh pr comment "$PR" --body \ + "$marker \n [Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" diff --git a/packages/experimental/webworker-packer/bin.js b/packages/experimental/webworker-packer/bin.js new file mode 100644 index 0000000000..36d1d22fb4 --- /dev/null +++ b/packages/experimental/webworker-packer/bin.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +/** + * Stable link target for the `dsh-pack-vfs-image` bin, forwarding to the build + * product. + * + * pnpm creates a workspace package's bin link only when the link target exists + * at install time. Pointing the bin straight at `lib/bin.js` — a build product — + * left the link uncreated on every clean checkout, so the command was missing + * from `node_modules/.bin` even after a build produced the file, and only an + * install that happened to follow a build brought it back. This file is + * committed, so the link is always created; the build product is resolved when + * the command actually runs. + * @module @deepseek-ai/dsh-experimental-webworker-packer/bin + */ +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const entry = new URL('./lib/bin.js', import.meta.url) +if (!existsSync(fileURLToPath(entry))) { + process.stderr.write('dsh-pack-vfs-image: lib/bin.js is missing — run `pnpm run build` before packing an image\n') + process.exit(1) +} +await import(entry.href) diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index 6e37359454..f61111d905 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -12,7 +12,7 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "bin": { - "dsh-pack-vfs-image": "./lib/bin.js" + "dsh-pack-vfs-image": "./bin.js" }, "exports": { ".": { @@ -30,6 +30,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", + "bin.js", "lib/repository-*.js", "lib/types/**/*.d.ts" ], diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 336589bd2d..e052a9a2b0 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -163,8 +163,9 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-session-persistence-sqlite': ['resources/sql/**/*.sql'], '@deepseek-ai/dsh-skill-badge': ['assets'], // tsdown shares the repository/pack code between the lib entry and the bin - // through a hashed chunk. - '@deepseek-ai/dsh-experimental-webworker-packer': ['lib/repository-*.js'], + // through a hashed chunk. The committed bin.js is the link target pnpm can + // resolve at install time, before the build produces lib/bin.js. + '@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'], '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], }