From 219d66a6f3e87110c86926b0bf7401a549fc16af Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 23 Oct 2026 14:14:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?ci:=20PR=EB=A7=88=EB=8B=A4=20=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=EB=A5=BC=20Cloudflare=20Workers=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EB=A1=9C=20=EB=9D=84=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 뷰어는 git을 부르는 로컬 서버가 있어야 그려진다. 그래서 CI가 PR 브랜치로 실제 서버를 띄워 첫 화면의 API 응답을 녹화하고(apps/preview/record.ts), Worker가 빌드된 뷰어와 함께 그 녹화본을 재생한다(apps/preview/worker.ts). preview.yml이 wrangler preview로 PR마다 프리뷰를 만들고 URL을 댓글로 단다. PR이 닫히면 지우고, main 푸시는 본 Worker를 갱신한다. Cloudflare 시크릿이 없거나 포크 PR이면 배포를 건너뛴다. 필수 체크가 아니다. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LnGBvUYgubedmy6AXgRzce --- .claude/rules/preview.md | 31 +++++ .github/workflows/preview.yml | 110 +++++++++++++++ .gitignore | 2 + CLAUDE.md | 2 + apps/preview/__tests__/record.test.ts | 118 +++++++++++++++++ apps/preview/__tests__/worker.test.ts | 184 ++++++++++++++++++++++++++ apps/preview/cli.ts | 20 +++ apps/preview/record.ts | 104 +++++++++++++++ apps/preview/snapshot.ts | 51 +++++++ apps/preview/tsconfig.json | 4 + apps/preview/worker.ts | 72 ++++++++++ apps/preview/wrangler.jsonc | 12 ++ bunfig.toml | 4 +- package.json | 2 +- 14 files changed, 714 insertions(+), 2 deletions(-) create mode 100644 .claude/rules/preview.md create mode 100644 .github/workflows/preview.yml create mode 100644 apps/preview/__tests__/record.test.ts create mode 100644 apps/preview/__tests__/worker.test.ts create mode 100644 apps/preview/cli.ts create mode 100644 apps/preview/record.ts create mode 100644 apps/preview/snapshot.ts create mode 100644 apps/preview/tsconfig.json create mode 100644 apps/preview/worker.ts create mode 100644 apps/preview/wrangler.jsonc diff --git a/.claude/rules/preview.md b/.claude/rules/preview.md new file mode 100644 index 0000000..6ca35b1 --- /dev/null +++ b/.claude/rules/preview.md @@ -0,0 +1,31 @@ +--- +paths: + - "apps/preview/**" + - ".github/workflows/preview.yml" +--- + +# PR 프리뷰 (`apps/preview/`, `preview.yml`) + +PR마다 그 브랜치의 뷰어를 Cloudflare Worker로 띄우고 URL을 PR 댓글로 단다. 뷰어는 `git`을 부르는 로컬 서버가 있어야 그려지므로, 서버 대신 녹화본을 재생한다. + +## 녹화와 재생 + +- 녹화(`record.ts`, 진입부 `cli.ts`): CI가 빌드한 뷰어와 체크아웃한 PR 브랜치로 `startDiffServer`를 임시 포트에 띄우고, 뷰어가 첫 화면에서 부르는 API(ping·refs·prs·summary·diff, 그리고 diff에 든 이미지의 blob)를 불러 `site/_snapshot/`에 담는다. 응답을 손으로 만들지 않는다 — 프리뷰가 그 커밋의 서버 동작을 보여야 한다. 실패 응답(400 등)도 그대로 담는다. +- 재생(`worker.ts`): `/api/*`는 `snapshotKey`(`snapshot.ts`)로 녹화본을 찾아 상태·헤더·본문을 돌려준다. 나머지는 정적 에셋이다. 맨 주소(`repo` 없음)는 녹화한 `repo`·`base`를 실은 뷰어 URL로 리디렉션한다. +- 스냅숏은 선택 하나만 담는다. 그래서 키에서 `repo`·`token`·`base`·`untracked`를 버린다(깨끗한 체크아웃이라 untracked가 결과를 바꾸지 않는다). `head`가 붙은 요청은 404다 — 피커로 고른 브랜치에 녹화본을 주면 고른 것과 다른 비교를 보여 준다. +- 녹화하는 쿼리는 뷰어(`main.ts`의 fetch, `imageDiff.ts`의 `blobUrl`)가 보내는 것과 같은 축이어야 한다. 뷰어가 새 API나 새 선택 축을 쓰게 되면 `record.ts`와 `snapshotKey`도 고친다. 어긋나면 프리뷰에서만 404가 난다. +- 녹화기는 토큰 파일을 임시 `XDG_CACHE_HOME`에 쓴다. 사용자의 캐시 디렉터리를 건드리지 않는다. + +## 워크플로 (`preview.yml`) + +- PR(같은 리포의 브랜치)은 `wrangler preview --name pr-<번호>`로 Workers Preview를 만들고 댓글 하나를 갱신한다(`gh pr comment --edit-last --create-if-none`). 닫히면 `wrangler preview delete`로 지운다. `main` 푸시와 `workflow_dispatch`(main)는 `wrangler deploy`로 본 Worker를 갱신한다. main에는 PR이 없어 base를 `HEAD~1`로 녹화한다(마지막 머지 커밋). +- 체크아웃은 머지 커밋이 아니라 브랜치(`head_ref`)다. 뷰어 라벨과 auto base(`gh pr view`)가 브랜치 이름을 읽는다. `fetch-depth: 0`이어야 `origin/main`이 생긴다. +- 시크릿 `CLOUDFLARE_API_TOKEN`(Workers Scripts 편집 권한)·`CLOUDFLARE_ACCOUNT_ID`가 없으면 `gate` 잡이 배포를 건너뛴다. 포크 PR도 시크릿이 오지 않아 건너뛴다. `pull_request_target`으로 우회하지 않는다 — 남의 코드를 토큰과 함께 돌리게 된다. +- 필수 체크가 아니다(룰셋에 넣지 않는다). Cloudflare 장애가 머지를 막으면 안 된다. +- wrangler는 devDep에 넣지 않고 `bunx wrangler@$WRANGLER_VERSION`(정확한 버전)으로 부른다. devDep이면 모든 잡의 `bun install`이 workerd까지 받는다. Workers Previews는 Wrangler 4.135.0 이상이고 open beta다. +- 정적 에셋은 파일 하나 25MiB가 상한이다. 빌드한 `main.js`가 가장 크다. + +## 확인 + +- 로컬: `cd apps/viewer && bun run build`, 루트에서 `bun apps/preview/cli.ts`, 그다음 `bunx wrangler@<버전> dev --config apps/preview/wrangler.jsonc`. 로그인 없이 돈다. +- `cli.ts`는 커버리지 게이트 밖이다(`bunfig.toml`). 워크플로가 PR마다 실행한다. diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..603d538 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,110 @@ +name: Preview + +# PR마다 그 브랜치의 뷰어를 Cloudflare Worker로 띄운다(preview.md). +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, closed] + push: + branches: [main] + # 시크릿을 처음 넣은 뒤 Worker를 만들 때 손으로 한 번 돌린다(preview.md). + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + WRANGLER_VERSION: 4.137.0 + +jobs: + # 포크 PR에는 시크릿이 오지 않고, 시크릿을 넣기 전에는 배포할 곳이 없다. + # 그때는 실패 대신 건너뛴다(필수 체크가 아니다). + gate: + runs-on: ubuntu-latest + outputs: + ok: ${{ steps.check.outputs.ok }} + steps: + - id: check + env: + TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + if [ -n "$TOKEN" ] && [ -n "$ACCOUNT" ]; then + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set; skipping the preview." + echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + deploy: + needs: gate + if: needs.gate.outputs.ok == 'true' && github.event.action != 'closed' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + # 머지 커밋이 아니라 브랜치를 받는다 — 뷰어 라벨과 auto base(`gh pr view`)가 + # 브랜치 이름을 읽는다. fetch-depth 0이어야 origin/main이 생긴다. + ref: ${{ github.head_ref || github.ref_name }} + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - run: bun install --frozen-lockfile + - run: bun run build + working-directory: apps/viewer + - name: Record the snapshot + env: + GH_TOKEN: ${{ github.token }} + # main에는 PR이 없어 auto base가 자기 자신이 된다. 마지막 머지 커밋을 보인다. + BASE: ${{ github.event_name == 'pull_request' && '@auto' || 'HEAD~1' }} + run: bun apps/preview/cli.ts --base "$BASE" + - name: Deploy the PR preview + id: preview + if: github.event_name == 'pull_request' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + PR: ${{ github.event.pull_request.number }} + run: | + set -o pipefail + out="$(bunx "wrangler@$WRANGLER_VERSION" preview --config apps/preview/wrangler.jsonc --name "pr-$PR" --json)" + url="$(printf '%s' "$out" | jq -er '.preview.urls[0]')" + echo "url=$url" >> "$GITHUB_OUTPUT" + - name: Comment the preview URL + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + URL: ${{ steps.preview.outputs.url }} + SHA: ${{ github.event.pull_request.head.sha }} + run: | + body="$(printf '프리뷰: %s\n\n커밋 %s의 뷰어입니다. 이 브랜치를 auto base와 견준 첫 화면의 API 응답을 녹화해 재생합니다. 피커로 다른 브랜치를 고르면 녹화하지 않은 diff라 열리지 않습니다.' "$URL" "$SHA")" + gh pr comment "$PR" --edit-last --create-if-none --body "$body" + - name: Deploy main + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: bunx "wrangler@$WRANGLER_VERSION" deploy --config apps/preview/wrangler.jsonc + + cleanup: + needs: gate + if: needs.gate.outputs.ok == 'true' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + PR: ${{ github.event.pull_request.number }} + run: bunx "wrangler@$WRANGLER_VERSION" preview delete --config apps/preview/wrangler.jsonc --name "pr-$PR" --skip-confirmation diff --git a/.gitignore b/.gitignore index 32fa005..1c93109 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ scripts/parity/out/ docs/superpowers/ test-results/ playwright-report/ +apps/preview/site/ +.wrangler/ diff --git a/CLAUDE.md b/CLAUDE.md index e7fd7a6..a31dcfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,7 @@ - `packages/` — vendored 포크(workspace로 쓰고 npm에서 받지 않는다): `path-store`(트리 순수 로직) · `theming`(테마 + shiki 테마 JSON) · `diffs`(CodeView diff 렌더 엔진) · `trees`(FileTree, vanilla 렌더) - `apps/viewer/` — 배포되는 유일한 패키지 `@say8425/diffdeck`: `server/`(데이터 API) · `browser/`(뷰어, `main.ts`가 배선) · `index.html` · `cli.ts` + `cli/` · `build.ts` · `fonts/` · `__tests__/` · `e2e/` +- `apps/preview/` — PR 프리뷰 Cloudflare Worker와 녹화기. 배포 패키지가 아니다. - `skills/diffdeck/SKILL.md` — 배포되는 에이전트 스킬. `build.ts`가 `dist/skills/`로 복사한다. 플러그인 매니페스트는 `.claude-plugin/`·`.codex-plugin/`·`.agents/plugins/`에 있다. - `scripts/` — 소스맵 복원 도구, css-inline Bun 플러그인, 렌더 패리티 하니스(`scripts/parity/`) - `docs/` — README 번역 4종과 스크린샷. `docs/superpowers/`는 gitignore된 로컬 계획 문서다. @@ -66,6 +67,7 @@ cd apps/viewer && bun run build && bun run start # 뷰어를 직접 띄워 본 | `vendored-packages.md` | 포크 원칙과 현재 예외, shiki 번프 | | `testing.md` · `e2e.md` | `bun test` 함정, Playwright 함정과 판별력 | | `ci-release.md` | 필수 체크, 릴리스, 배포 산출물 | +| `preview.md` | PR 프리뷰(녹화·재생 Worker, `preview.yml`) | | `docs-sync.md` | CLI 플래그·SKILL.md·README 번역 동기화 | ## 이 문서를 고칠 때 diff --git a/apps/preview/__tests__/record.test.ts b/apps/preview/__tests__/record.test.ts new file mode 100644 index 0000000..b5831f5 --- /dev/null +++ b/apps/preview/__tests__/record.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { recordSnapshot } from "../record.ts"; + +// git이 바이너리로 보도록 NUL을 넣는다. +const png = (n: number) => Buffer.from([0x89, 0x50, 0x00, n]); + +describe("recordSnapshot", () => { + let root: string; + let repo: string; + let viewerDir: string; + let outDir: string; + + beforeEach(async () => { + root = realpathSync(mkdtempSync(join(tmpdir(), "dd-record-"))); + repo = join(root, "repo"); + viewerDir = join(root, "viewer"); + outDir = join(root, "site"); + mkdirSync(viewerDir); + writeFileSync(join(viewerDir, "index.html"), ""); + await $`git init -q ${repo}`; + await $`git -C ${repo} config user.email t@t.co`; + await $`git -C ${repo} config user.name test`; + writeFileSync(join(repo, "a.txt"), "one\n"); + writeFileSync(join(repo, "logo.png"), png(1)); + await $`git -C ${repo} add -A`; + await $`git -C ${repo} commit -q -m init`; + // git init은 머신의 init.defaultBranch를 따르므로 이름을 고정한다. + await $`git -C ${repo} branch -M main`; + await $`git -C ${repo} checkout -q -b feat`; + writeFileSync(join(repo, "a.txt"), "two\n"); + writeFileSync(join(repo, "logo.png"), png(2)); + writeFileSync(join(repo, "new.png"), png(3)); + await $`git -C ${repo} add -A`; + await $`git -C ${repo} commit -q -m change`; + }); + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + const record = (base: string) => + recordSnapshot({ + repo, + viewerDir, + outDir, + base, + listPrs: () => Promise.resolve({}), + }); + + test("records the first-screen API calls against the real server", async () => { + const manifest = await record("main"); + expect(manifest.repo).toBe(repo); + expect(manifest.base).toBe("main"); + expect(Object.keys(manifest.entries).toSorted()).toEqual([ + "/api/blob:new:logo.png", + "/api/blob:new:new.png", + "/api/blob:old:logo.png", + "/api/diff", + "/api/ping", + "/api/prs", + "/api/refs", + "/api/summary", + ]); + expect(manifest.entries["/api/ping"]).toMatchObject({ + status: 204, + file: null, + }); + const diff = manifest.entries["/api/diff"]; + expect(diff.status).toBe(200); + expect(diff.headers["x-diff-base"]).toBe("main"); + expect(diff.headers.etag).toBeTruthy(); + const files = JSON.parse( + readFileSync(join(outDir, "_snapshot", diff.file ?? ""), "utf8"), + ) as { name: string }[]; + expect(files.map((f) => f.name).toSorted()).toEqual([ + "a.txt", + "logo.png", + "new.png", + ]); + const oldLogo = manifest.entries["/api/blob:old:logo.png"]; + expect(oldLogo.headers["content-type"]).toBe("image/png"); + expect(readFileSync(join(outDir, "_snapshot", oldLogo.file ?? ""))).toEqual( + png(1), + ); + }); + + test("lays the snapshot over a copy of the built viewer", async () => { + mkdirSync(outDir); + writeFileSync(join(outDir, "stale.txt"), "x"); + const manifest = await record("main"); + expect(readFileSync(join(outDir, "index.html"), "utf8")).toBe(""); + expect(existsSync(join(outDir, "stale.txt"))).toBe(false); + const onDisk = JSON.parse( + readFileSync(join(outDir, "_snapshot", "manifest.json"), "utf8"), + ) as unknown; + expect(onDisk).toEqual(manifest); + }); + + test("records a failed diff as is and fetches no blobs", async () => { + const manifest = await record("no-such-branch"); + const diff = manifest.entries["/api/diff"]; + expect(diff.status).toBe(400); + expect(diff.headers["x-diff-error"]).toBe("unknown-base"); + expect( + Object.keys(manifest.entries).some((k) => k.startsWith("/api/blob")), + ).toBe(false); + }); +}); diff --git a/apps/preview/__tests__/worker.test.ts b/apps/preview/__tests__/worker.test.ts new file mode 100644 index 0000000..009c667 --- /dev/null +++ b/apps/preview/__tests__/worker.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; +import { type SnapshotManifest, snapshotKey } from "../snapshot.ts"; +import worker, { type Env } from "../worker.ts"; + +const ORIGIN = "https://pr-1-diffdeck-preview.example.workers.dev"; + +const MANIFEST: SnapshotManifest = { + repo: "/home/runner/work/diffdeck/diffdeck", + base: "@auto", + entries: { + "/api/ping": { + status: 204, + headers: { "x-diffdeck": "1", "x-diffdeck-version": "1.7.1" }, + file: null, + }, + "/api/diff": { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + etag: '"abc"', + "x-diff-base": "main", + }, + file: "4.bin", + }, + "/api/summary": { + status: 200, + headers: { "content-type": "application/json; charset=utf-8" }, + file: "3.bin", + }, + "/api/blob:new:img/a.png": { + status: 200, + headers: { "content-type": "image/png" }, + file: "5.bin", + }, + }, +}; + +const fakeEnv = (files: Record): Env & { seen: string[] } => { + const seen: string[] = []; + return { + seen, + ASSETS: { + fetch: (request: Request) => { + const { pathname } = new URL(request.url); + seen.push(pathname); + if (pathname === "/_snapshot/manifest.json") { + return Promise.resolve(Response.json(MANIFEST)); + } + const body = files[pathname]; + return Promise.resolve( + body === undefined + ? new Response("asset not found", { status: 404 }) + : new Response(body), + ); + }, + }, + }; +}; + +const call = (path: string, env: Env, init?: RequestInit) => + worker.fetch(new Request(`${ORIGIN}${path}`, init), env); + +describe("snapshotKey", () => { + test("drops repo, token, base, and untracked from API routes", () => { + expect( + snapshotKey( + new URL(`${ORIGIN}/api/diff?repo=/r&token=t&base=main&untracked=1`), + ), + ).toBe("/api/diff"); + }); + test("keys a blob by side and path, defaulting to the new side", () => { + expect( + snapshotKey(new URL(`${ORIGIN}/api/blob?path=a.png&side=old&v=9`)), + ).toBe("/api/blob:old:a.png"); + expect(snapshotKey(new URL(`${ORIGIN}/api/blob?path=a.png`))).toBe( + "/api/blob:new:a.png", + ); + }); + test("has no key for a blob without a path or an unknown route", () => { + expect(snapshotKey(new URL(`${ORIGIN}/api/blob`))).toBeNull(); + expect(snapshotKey(new URL(`${ORIGIN}/api/nope`))).toBeNull(); + }); +}); + +describe("preview worker", () => { + test("sends a bare visit to the viewer URL for the recorded repo and base", async () => { + const res = await call("/", fakeEnv({})); + expect(res.status).toBe(302); + const to = new URL(res.headers.get("location") ?? ""); + expect(to.origin).toBe(ORIGIN); + expect(to.searchParams.get("repo")).toBe(MANIFEST.repo); + expect(to.searchParams.get("base")).toBe("@auto"); + expect(to.searchParams.get("token")).toBe("preview"); + }); + + test("serves static assets, including the viewer page once it has a repo", async () => { + const env = fakeEnv({ "/": "", "/main.js": "js" }); + expect(await (await call("/main.js", env)).text()).toBe("js"); + const page = await call(`/?repo=${encodeURIComponent(MANIFEST.repo)}`, env); + expect(page.status).toBe(200); + expect(await page.text()).toBe(""); + }); + + test("replays a recorded response with its headers and body", async () => { + const res = await call( + "/api/diff?repo=/r&token=preview&base=@auto&untracked=0", + fakeEnv({ "/_snapshot/4.bin": "[]" }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("etag")).toBe('"abc"'); + expect(res.headers.get("x-diff-base")).toBe("main"); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await res.text()).toBe("[]"); + }); + + test("answers a matching if-none-match with 304 and keeps the base header", async () => { + const env = fakeEnv({}); + const res = await call("/api/diff?base=@auto", env, { + headers: { "if-none-match": '"abc"' }, + }); + expect(res.status).toBe(304); + expect(res.headers.get("etag")).toBe('"abc"'); + expect(res.headers.get("x-diff-base")).toBe("main"); + expect(env.seen).not.toContain("/_snapshot/4.bin"); + }); + + test("a 304 for an entry without a base header carries only the etag", async () => { + const env = fakeEnv({}); + const original = MANIFEST.entries["/api/diff"].headers; + MANIFEST.entries["/api/diff"].headers = { etag: '"abc"' }; + try { + const res = await call("/api/diff", env, { + headers: { "if-none-match": '"abc"' }, + }); + expect(res.status).toBe(304); + expect(res.headers.has("x-diff-base")).toBe(false); + } finally { + MANIFEST.entries["/api/diff"].headers = original; + } + }); + + test("replays a bodiless entry without reading a file", async () => { + const env = fakeEnv({}); + const res = await call("/api/ping", env); + expect(res.status).toBe(204); + expect(res.headers.get("x-diffdeck-version")).toBe("1.7.1"); + expect(env.seen).toEqual(["/_snapshot/manifest.json"]); + }); + + test("replays an image blob by side and path", async () => { + const res = await call( + "/api/blob?path=img%2Fa.png&side=new&mode=base&v=1", + fakeEnv({ "/_snapshot/5.bin": "png" }), + ); + expect(res.headers.get("content-type")).toBe("image/png"); + expect(await res.text()).toBe("png"); + }); + + test("refuses a picked head instead of showing the recorded comparison", async () => { + const env = fakeEnv({}); + const res = await call("/api/diff?head=feat-x", env); + expect(res.status).toBe(404); + expect(env.seen).toEqual([]); + }); + + test("404s an API request the snapshot did not record", async () => { + const env = fakeEnv({}); + expect((await call("/api/blob?path=b.png", env)).status).toBe(404); + expect((await call("/api/refs", env)).status).toBe(404); + expect((await call("/api/nope", env)).status).toBe(404); + }); + + test("fails loudly when the manifest is missing", async () => { + const env: Env = { + ASSETS: { + fetch: () => Promise.resolve(new Response("no", { status: 404 })), + }, + }; + const error = await call("/", env).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("snapshot manifest: 404"); + }); +}); diff --git a/apps/preview/cli.ts b/apps/preview/cli.ts new file mode 100644 index 0000000..ca8dff9 --- /dev/null +++ b/apps/preview/cli.ts @@ -0,0 +1,20 @@ +import { parseArgs } from "node:util"; +import { recordSnapshot } from "./record.ts"; + +const { values } = parseArgs({ + options: { + repo: { type: "string", default: "." }, + viewer: { type: "string", default: "apps/viewer/dist/viewer" }, + out: { type: "string", default: "apps/preview/site" }, + base: { type: "string", default: "@auto" }, + }, +}); +const manifest = await recordSnapshot({ + repo: values.repo, + viewerDir: values.viewer, + outDir: values.out, + base: values.base, +}); +for (const [key, entry] of Object.entries(manifest.entries)) { + console.log(`${entry.status} ${key}`); +} diff --git a/apps/preview/record.ts b/apps/preview/record.ts new file mode 100644 index 0000000..cde7e95 --- /dev/null +++ b/apps/preview/record.ts @@ -0,0 +1,104 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { imageEntries } from "../viewer/browser/imageDiff.ts"; +import type { DiffFile } from "../viewer/server/diff.ts"; +import type { PrsByBranch } from "../viewer/server/prs.ts"; +import { startDiffServer } from "../viewer/server/server.ts"; +import { + REPLAYED_HEADERS, + SNAPSHOT_DIR, + type SnapshotEntry, + type SnapshotManifest, + snapshotKey, +} from "./snapshot.ts"; + +export interface RecordOptions { + repo: string; + /** 빌드된 뷰어(`apps/viewer/dist/viewer`). 사이트 루트로 복사한다. */ + viewerDir: string; + outDir: string; + base: string; + listPrs?: (repo: string) => Promise; +} + +// 실제 서버를 띄워 뷰어가 첫 화면에서 부르는 API를 그대로 불러 담는다. +// 응답을 손으로 만들지 않아야 프리뷰가 이 커밋의 서버 동작을 보인다. +export const recordSnapshot = async ( + opts: RecordOptions, +): Promise => { + const repo = resolve(opts.repo); + const snapshotDir = join(opts.outDir, SNAPSHOT_DIR); + rmSync(opts.outDir, { recursive: true, force: true }); + cpSync(opts.viewerDir, opts.outDir, { recursive: true }); + mkdirSync(snapshotDir, { recursive: true }); + + // 토큰 파일이 사용자의 캐시 디렉터리에 남지 않게 한다. + const cacheHome = mkdtempSync(join(tmpdir(), "dd-preview-")); + const handle = startDiffServer({ + port: 0, + viewerDir: resolve(opts.viewerDir), + env: { ...process.env, XDG_CACHE_HOME: cacheHome }, + ...(opts.listPrs ? { listPrs: opts.listPrs } : {}), + }); + const origin = `http://127.0.0.1:${handle.server.port}`; + const entries: Record = {}; + + const record = async (path: string, params: Record = {}) => { + const url = new URL(path, origin); + for (const [k, v] of Object.entries({ + repo, + token: handle.token, + ...params, + })) { + url.searchParams.set(k, v); + } + const res = await fetch(url); + const body = new Uint8Array(await res.arrayBuffer()); + const headers: Record = {}; + for (const name of REPLAYED_HEADERS) { + const value = res.headers.get(name); + if (value !== null) headers[name] = value; + } + const index = Object.keys(entries).length; + const file = res.status === 204 ? null : `${index}.bin`; + if (file) writeFileSync(join(snapshotDir, file), body); + const key = snapshotKey(url); + if (!key) throw new Error(`no snapshot key for ${url.pathname}`); + entries[key] = { status: res.status, headers, file }; + return { status: res.status, body }; + }; + + try { + await record("/api/ping"); + await record("/api/refs"); + await record("/api/prs"); + await record("/api/summary", { base: opts.base }); + const diff = await record("/api/diff", { + base: opts.base, + untracked: "0", + }); + if (diff.status === 200) { + const files = JSON.parse( + new TextDecoder().decode(diff.body), + ) as DiffFile[]; + const blobs: { path: string; side: "old" | "new" }[] = []; + for (const e of imageEntries(files)) { + if (e.showOld) blobs.push({ path: e.oldPath, side: "old" }); + if (e.showNew) blobs.push({ path: e.name, side: "new" }); + } + for (const blob of blobs) { + // 서버의 BUILD_CONCURRENCY를 겹쳐 쓰지 않게 하나씩 부른다(server.md). + // oxlint-disable-next-line no-await-in-loop + await record("/api/blob", { base: opts.base, mode: "base", ...blob }); + } + } + } finally { + handle.stop(); + rmSync(cacheHome, { recursive: true, force: true }); + } + + const manifest: SnapshotManifest = { repo, base: opts.base, entries }; + writeFileSync(join(snapshotDir, "manifest.json"), JSON.stringify(manifest)); + return manifest; +}; diff --git a/apps/preview/snapshot.ts b/apps/preview/snapshot.ts new file mode 100644 index 0000000..176257c --- /dev/null +++ b/apps/preview/snapshot.ts @@ -0,0 +1,51 @@ +// 녹화(record.ts)와 재생(worker.ts)이 공유하는 스냅숏 형식이다. + +export const SNAPSHOT_DIR = "_snapshot"; +export const MANIFEST_PATH = `/${SNAPSHOT_DIR}/manifest.json`; + +export interface SnapshotEntry { + status: number; + headers: Record; + /** `SNAPSHOT_DIR` 안의 본문 파일. 본문이 없는 응답(204·304)은 null이다. */ + file: string | null; +} + +export interface SnapshotManifest { + /** 녹화한 리포의 절대경로. 뷰어 URL의 `repo`로 그대로 쓴다. */ + repo: string; + base: string; + entries: Record; +} + +// 재생에 실리는 헤더만 남긴다. 뷰어가 읽는 것(etag·x-diff-*·x-diffdeck*)과 +// 본문 해석에 필요한 content-type이다. +export const REPLAYED_HEADERS = [ + "content-type", + "etag", + "x-diff-base", + "x-diff-error", + "x-diffdeck", + "x-diffdeck-version", +] as const; + +// 스냅숏은 선택 하나(녹화한 base, 워킹트리 head)만 담는다. 그래서 키에서 +// repo·token·base·untracked를 버린다. 깨끗한 체크아웃이라 untracked는 결과를 +// 바꾸지 않는다. blob만 파일과 side로 갈린다. +export const snapshotKey = (url: URL): string | null => { + switch (url.pathname) { + case "/api/ping": + case "/api/diff": + case "/api/summary": + case "/api/refs": + case "/api/prs": + return url.pathname; + case "/api/blob": { + const path = url.searchParams.get("path"); + if (!path) return null; + const side = url.searchParams.get("side") === "old" ? "old" : "new"; + return `/api/blob:${side}:${path}`; + } + default: + return null; + } +}; diff --git a/apps/preview/tsconfig.json b/apps/preview/tsconfig.json new file mode 100644 index 0000000..4dd459f --- /dev/null +++ b/apps/preview/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["*.ts", "__tests__/**/*.ts"] +} diff --git a/apps/preview/worker.ts b/apps/preview/worker.ts new file mode 100644 index 0000000..966e5cb --- /dev/null +++ b/apps/preview/worker.ts @@ -0,0 +1,72 @@ +import { + MANIFEST_PATH, + SNAPSHOT_DIR, + type SnapshotManifest, + snapshotKey, +} from "./snapshot.ts"; + +export interface Env { + ASSETS: { fetch(request: Request): Promise }; +} + +const notInSnapshot = (): Response => + new Response("not in this preview snapshot", { status: 404 }); + +const loadManifest = async ( + env: Env, + origin: string, +): Promise => { + const res = await env.ASSETS.fetch(new Request(`${origin}${MANIFEST_PATH}`)); + if (!res.ok) throw new Error(`snapshot manifest: ${res.status}`); + return (await res.json()) as SnapshotManifest; +}; + +const replay = async ( + request: Request, + url: URL, + env: Env, +): Promise => { + // 피커로 다른 브랜치를 고르면 녹화하지 않은 diff다. 녹화본을 대신 주면 + // 고른 것과 다른 비교를 보여 준다. + if (url.searchParams.get("head")) return notInSnapshot(); + const key = snapshotKey(url); + if (!key) return notInSnapshot(); + const manifest = await loadManifest(env, url.origin); + const entry = manifest.entries[key]; + if (!entry) return notInSnapshot(); + const etag = entry.headers.etag; + if (etag && request.headers.get("if-none-match") === etag) { + const headers: Record = { etag }; + if (entry.headers["x-diff-base"] !== undefined) { + headers["x-diff-base"] = entry.headers["x-diff-base"]; + } + return new Response(null, { status: 304, headers }); + } + const headers = { ...entry.headers, "cache-control": "no-store" }; + if (entry.file === null) { + return new Response(null, { status: entry.status, headers }); + } + const body = await env.ASSETS.fetch( + new Request(`${url.origin}/${SNAPSHOT_DIR}/${entry.file}`), + ); + return new Response(body.body, { status: entry.status, headers }); +}; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.pathname.startsWith("/api/")) return replay(request, url, env); + // 맨 주소로 들어오면 녹화한 리포와 base를 실은 뷰어 URL로 보낸다. 뷰어는 + // `repo` 없이 뜨면 어느 리포도 고르지 못한다. + if (url.pathname === "/" && !url.searchParams.has("repo")) { + const manifest = await loadManifest(env, url.origin); + const query = new URLSearchParams({ + repo: manifest.repo, + token: "preview", + base: manifest.base, + }); + return Response.redirect(`${url.origin}/?${query.toString()}`, 302); + } + return env.ASSETS.fetch(request); + }, +}; diff --git a/apps/preview/wrangler.jsonc b/apps/preview/wrangler.jsonc new file mode 100644 index 0000000..d0ed2c2 --- /dev/null +++ b/apps/preview/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "diffdeck-preview", + "main": "worker.ts", + "compatibility_date": "2026-09-01", + "assets": { + // record.ts가 만든다(빌드된 뷰어 + _snapshot/). + "directory": "./site", + "binding": "ASSETS", + // 맨 주소의 리디렉션과 /api/* 재생을 워커가 맡는다. + "run_worker_first": true, + }, +} diff --git a/bunfig.toml b/bunfig.toml index d73ccc9..9f186c5 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -7,12 +7,14 @@ root = "." preload = ["./scripts/parity/preload.ts"] # 100% gate on owned runtime code (apps/viewer/{browser,cli,server}); applies only with -# `--coverage` (`bun run test:coverage`). main.ts is covered by e2e instead. +# `--coverage` (`bun run test:coverage`). main.ts is covered by e2e instead; the preview +# recorder entry (apps/preview/cli.ts) runs in the preview workflow on every PR. coverageThreshold = { lines = 1, functions = 1, statements = 1 } coveragePathIgnorePatterns = [ "packages/**", "scripts/**", "apps/viewer/browser/main.ts", + "apps/preview/cli.ts", "apps/viewer/build.ts", "apps/viewer/e2e/**", "**/*.test.ts", diff --git a/package.json b/package.json index 0c7f445..574d033 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ ], "type": "module", "scripts": { - "typecheck": "tsc --noEmit -p packages/path-store/tsconfig.json && tsc --noEmit -p packages/theming/tsconfig.json && tsc --noEmit -p packages/diffs/tsconfig.json && tsc --noEmit -p packages/trees/tsconfig.json && tsc --noEmit -p apps/viewer/tsconfig.json && tsc --noEmit -p apps/viewer/e2e/tsconfig.json", + "typecheck": "tsc --noEmit -p packages/path-store/tsconfig.json && tsc --noEmit -p packages/theming/tsconfig.json && tsc --noEmit -p packages/diffs/tsconfig.json && tsc --noEmit -p packages/trees/tsconfig.json && tsc --noEmit -p apps/viewer/tsconfig.json && tsc --noEmit -p apps/viewer/e2e/tsconfig.json && tsc --noEmit -p apps/preview/tsconfig.json", "lint": "oxlint apps/", "format": "oxfmt apps/", "format:check": "oxfmt --check apps/", From e23e4c80c5746dad9a47b05b1246b30c6a7b8612 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 23 Oct 2026 14:25:07 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=ED=94=84=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EA=B0=80=20=EC=8B=A4=EC=A0=9C=20wrangler?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8F=8C=EB=8F=84=EB=A1=9D=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=98=EA=B3=A0=20=EB=A6=AC=EB=B7=B0=20=EC=A7=80=EC=A0=81?= =?UTF-8?q?=EC=9D=84=20=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wrangler.jsonc에 빈 previews 블록을 둔다. 없으면 wrangler preview가 업로드 전에 실패한다. - --json이어도 업로드 로그가 stdout에 섞이므로 URL은 WRANGLER_OUTPUT_FILE_PATH의 NDJSON에서 읽는다. - 피커로 고른 head는 서버처럼 400 unknown-head로 답한다. 404면 라벨만 바뀌고 화면은 녹화본에 남은 채 이유가 HTTP 404로만 보인다. - 배포 뒤 /api/diff와 main.js를 curl로 확인하고, 댓글은 숨은 표식으로 찾아 고친다(comment.sh). 닫히면 댓글을 고치고, cleanup은 main 설정을 쓴다. - 녹화기가 25MiB 에셋 상한을 넘는 응답을 밝혀 실패하고, 효과 없던 untracked=0을 뺀다. 캐시 디렉터리와 크기 상한에 회귀 테스트를 더한다. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LnGBvUYgubedmy6AXgRzce --- .claude/rules/preview.md | 10 +++-- .github/workflows/preview.yml | 61 +++++++++++++++++++++------ apps/preview/__tests__/record.test.ts | 24 ++++++++++- apps/preview/__tests__/worker.test.ts | 5 ++- apps/preview/comment.sh | 17 ++++++++ apps/preview/record.ts | 16 +++++-- apps/preview/worker.ts | 11 ++++- apps/preview/wrangler.jsonc | 2 + 8 files changed, 120 insertions(+), 26 deletions(-) create mode 100755 apps/preview/comment.sh diff --git a/.claude/rules/preview.md b/.claude/rules/preview.md index 6ca35b1..fd4c836 100644 --- a/.claude/rules/preview.md +++ b/.claude/rules/preview.md @@ -12,18 +12,22 @@ PR마다 그 브랜치의 뷰어를 Cloudflare Worker로 띄우고 URL을 PR 댓 - 녹화(`record.ts`, 진입부 `cli.ts`): CI가 빌드한 뷰어와 체크아웃한 PR 브랜치로 `startDiffServer`를 임시 포트에 띄우고, 뷰어가 첫 화면에서 부르는 API(ping·refs·prs·summary·diff, 그리고 diff에 든 이미지의 blob)를 불러 `site/_snapshot/`에 담는다. 응답을 손으로 만들지 않는다 — 프리뷰가 그 커밋의 서버 동작을 보여야 한다. 실패 응답(400 등)도 그대로 담는다. - 재생(`worker.ts`): `/api/*`는 `snapshotKey`(`snapshot.ts`)로 녹화본을 찾아 상태·헤더·본문을 돌려준다. 나머지는 정적 에셋이다. 맨 주소(`repo` 없음)는 녹화한 `repo`·`base`를 실은 뷰어 URL로 리디렉션한다. -- 스냅숏은 선택 하나만 담는다. 그래서 키에서 `repo`·`token`·`base`·`untracked`를 버린다(깨끗한 체크아웃이라 untracked가 결과를 바꾸지 않는다). `head`가 붙은 요청은 404다 — 피커로 고른 브랜치에 녹화본을 주면 고른 것과 다른 비교를 보여 준다. +- 스냅숏은 선택 하나만 담는다. 그래서 키에서 `repo`·`token`·`base`·`untracked`를 버린다(깨끗한 체크아웃이라 untracked가 결과를 바꾸지 않는다). `head`가 붙은 요청은 서버처럼 400 + `x-diff-error: unknown-head`로 답한다. 피커로 고른 브랜치에 녹화본을 주면 고른 것과 다른 비교를 보여 준다. 404로 답하면 뷰어가 이유를 `HTTP 404`로만 적고, 새로 열면 다시 시도해도 소용없는 실패 카드가 뜬다. `unknown-head`면 상태 줄이 `branch not found`라고 말하고, 실패 카드는 워킹트리(곧 녹화본)로 돌아갈 버튼을 준다. - 녹화하는 쿼리는 뷰어(`main.ts`의 fetch, `imageDiff.ts`의 `blobUrl`)가 보내는 것과 같은 축이어야 한다. 뷰어가 새 API나 새 선택 축을 쓰게 되면 `record.ts`와 `snapshotKey`도 고친다. 어긋나면 프리뷰에서만 404가 난다. - 녹화기는 토큰 파일을 임시 `XDG_CACHE_HOME`에 쓴다. 사용자의 캐시 디렉터리를 건드리지 않는다. +- 정적 에셋은 파일 하나 25MiB가 상한이다. `/api/diff` 본문은 바뀐 파일의 old·new 전문을 싣는다. 그래서 녹화기가 응답 크기를 재고, 넘으면 어느 응답인지 밝혀 실패한다. ## 워크플로 (`preview.yml`) -- PR(같은 리포의 브랜치)은 `wrangler preview --name pr-<번호>`로 Workers Preview를 만들고 댓글 하나를 갱신한다(`gh pr comment --edit-last --create-if-none`). 닫히면 `wrangler preview delete`로 지운다. `main` 푸시와 `workflow_dispatch`(main)는 `wrangler deploy`로 본 Worker를 갱신한다. main에는 PR이 없어 base를 `HEAD~1`로 녹화한다(마지막 머지 커밋). +- PR(같은 리포의 브랜치)은 `wrangler preview --name pr-<번호>`로 Workers Preview를 만든다. 배포 뒤 `/api/diff`와 `main.js`를 curl로 확인하고, 댓글 하나를 갱신한다. 닫히면 `wrangler preview delete`로 지우고 댓글을 고친다. `main` 푸시와 `workflow_dispatch`(main)는 `wrangler deploy`로 본 Worker를 갱신한다. main에는 PR이 없어 base를 `HEAD~1`로 녹화한다(마지막 머지 커밋). 릴리스 PR을 머지한 직후에는 버전 범프만 보인다. +- `wrangler.jsonc`의 `"previews": {}`는 비어 있어도 지우지 않는다. 없으면 `wrangler preview`가 업로드 전에 실패한다. Worker가 아직 없으면 `wrangler preview`가 비대화형에서 만든다. +- `--json`을 줘도 에셋 업로드 로그가 stdout에 섞인다. URL은 `WRANGLER_OUTPUT_FILE_PATH`의 NDJSON에서 읽는다(`type: "preview"`의 `preview_urls`). +- 댓글은 `comment.sh`가 숨은 표식(``)으로 찾아 고친다. `gh pr comment --edit-last`는 같은 봇이 남긴 다른 댓글을 덮어쓴다. +- cleanup은 닫힌 PR이 아니라 main의 `apps/preview`를 sparse checkout한다. - 체크아웃은 머지 커밋이 아니라 브랜치(`head_ref`)다. 뷰어 라벨과 auto base(`gh pr view`)가 브랜치 이름을 읽는다. `fetch-depth: 0`이어야 `origin/main`이 생긴다. - 시크릿 `CLOUDFLARE_API_TOKEN`(Workers Scripts 편집 권한)·`CLOUDFLARE_ACCOUNT_ID`가 없으면 `gate` 잡이 배포를 건너뛴다. 포크 PR도 시크릿이 오지 않아 건너뛴다. `pull_request_target`으로 우회하지 않는다 — 남의 코드를 토큰과 함께 돌리게 된다. - 필수 체크가 아니다(룰셋에 넣지 않는다). Cloudflare 장애가 머지를 막으면 안 된다. - wrangler는 devDep에 넣지 않고 `bunx wrangler@$WRANGLER_VERSION`(정확한 버전)으로 부른다. devDep이면 모든 잡의 `bun install`이 workerd까지 받는다. Workers Previews는 Wrangler 4.135.0 이상이고 open beta다. -- 정적 에셋은 파일 하나 25MiB가 상한이다. 빌드한 `main.js`가 가장 크다. ## 확인 diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 603d538..5c2e1cd 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -7,12 +7,10 @@ on: types: [opened, synchronize, reopened, closed] push: branches: [main] - # 시크릿을 처음 넣은 뒤 Worker를 만들 때 손으로 한 번 돌린다(preview.md). workflow_dispatch: permissions: contents: read - pull-requests: write concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -26,6 +24,7 @@ jobs: # 그때는 실패 대신 건너뛴다(필수 체크가 아니다). gate: runs-on: ubuntu-latest + timeout-minutes: 5 outputs: ok: ${{ steps.check.outputs.ok }} steps: @@ -46,6 +45,9 @@ jobs: if: needs.gate.outputs.ok == 'true' && github.event.action != 'closed' runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@v7 with: @@ -71,35 +73,60 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # `--json`이어도 에셋 업로드 로그가 stdout에 섞이므로 결과는 이 파일에서 읽는다. + WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/wrangler.ndjson PR: ${{ github.event.pull_request.number }} run: | - set -o pipefail - out="$(bunx "wrangler@$WRANGLER_VERSION" preview --config apps/preview/wrangler.jsonc --name "pr-$PR" --json)" - url="$(printf '%s' "$out" | jq -er '.preview.urls[0]')" + bunx "wrangler@$WRANGLER_VERSION" preview --config apps/preview/wrangler.jsonc --name "pr-$PR" + url="$(jq -er 'select(.type == "preview") | .preview_urls[0]' "$WRANGLER_OUTPUT_FILE_PATH")" echo "url=$url" >> "$GITHUB_OUTPUT" + - name: Deploy main + id: main + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/wrangler.ndjson + run: | + bunx "wrangler@$WRANGLER_VERSION" deploy --config apps/preview/wrangler.jsonc + url="$(jq -er 'select(.type == "deploy") | .targets[0]' "$WRANGLER_OUTPUT_FILE_PATH")" + case "$url" in http*) ;; *) url="https://$url" ;; esac + echo "url=$url" >> "$GITHUB_OUTPUT" + # 죽은 링크를 댓글로 달지 않도록 재생과 정적 에셋이 실제로 답하는지 본다. + - name: Smoke-check the deployment + if: steps.preview.outputs.url || steps.main.outputs.url + env: + URL: ${{ steps.preview.outputs.url || steps.main.outputs.url }} + run: | + curl -fsS --retry 5 --retry-all-errors --retry-delay 3 -o /dev/null "$URL/api/diff" + curl -fsS --retry 5 --retry-all-errors --retry-delay 3 -o /dev/null "$URL/main.js" - name: Comment the preview URL if: github.event_name == 'pull_request' env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} URL: ${{ steps.preview.outputs.url }} - SHA: ${{ github.event.pull_request.head.sha }} run: | - body="$(printf '프리뷰: %s\n\n커밋 %s의 뷰어입니다. 이 브랜치를 auto base와 견준 첫 화면의 API 응답을 녹화해 재생합니다. 피커로 다른 브랜치를 고르면 녹화하지 않은 diff라 열리지 않습니다.' "$URL" "$SHA")" - gh pr comment "$PR" --edit-last --create-if-none --body "$body" - - name: Deploy main - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: bunx "wrangler@$WRANGLER_VERSION" deploy --config apps/preview/wrangler.jsonc + sha="$(git rev-parse HEAD)" + body="$(printf '프리뷰: %s\n\n커밋 %s의 뷰어입니다. 이 브랜치를 auto base와 견준 첫 화면의 API 응답을 녹화해 재생합니다. 피커로 다른 브랜치를 고르면 녹화하지 않은 diff라 화면은 녹화본에 머물고, 상태 줄에 branch not found가 뜹니다.' "$URL" "$sha")" + apps/preview/comment.sh "$PR" "$body" cleanup: needs: gate if: needs.gate.outputs.ok == 'true' && github.event.action == 'closed' runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write steps: + # 닫힌 PR의 머지 ref가 아니라 main의 설정을 쓴다. - uses: actions/checkout@v7 + with: + ref: main + sparse-checkout: | + .bun-version + apps/preview - uses: oven-sh/setup-bun@v2 with: bun-version-file: .bun-version @@ -108,3 +135,9 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} PR: ${{ github.event.pull_request.number }} run: bunx "wrangler@$WRANGLER_VERSION" preview delete --config apps/preview/wrangler.jsonc --name "pr-$PR" --skip-confirmation + - name: Mark the comment as deleted + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + ONLY_EXISTING: "1" + run: apps/preview/comment.sh "$PR" "PR이 닫혀 프리뷰를 지웠습니다." diff --git a/apps/preview/__tests__/record.test.ts b/apps/preview/__tests__/record.test.ts index b5831f5..9337ca4 100644 --- a/apps/preview/__tests__/record.test.ts +++ b/apps/preview/__tests__/record.test.ts @@ -48,13 +48,14 @@ describe("recordSnapshot", () => { afterEach(() => rmSync(root, { recursive: true, force: true })); - const record = (base: string) => + const record = (base: string, maxAssetBytes?: number) => recordSnapshot({ repo, viewerDir, outDir, base, listPrs: () => Promise.resolve({}), + ...(maxAssetBytes === undefined ? {} : { maxAssetBytes }), }); test("records the first-screen API calls against the real server", async () => { @@ -115,4 +116,25 @@ describe("recordSnapshot", () => { Object.keys(manifest.entries).some((k) => k.startsWith("/api/blob")), ).toBe(false); }); + + test("keeps the server token out of the user's cache directory", async () => { + const userCache = join(root, "user-cache"); + const saved = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = userCache; + try { + await record("main"); + } finally { + if (saved === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = saved; + } + expect(existsSync(join(userCache, "diffdeck"))).toBe(false); + }); + + test("fails with a clear message when a response is over the asset limit", async () => { + const error = await record("main", 64).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch( + /^\/api\/\w+ response is \d+ bytes, over the 64-byte asset limit$/, + ); + }); }); diff --git a/apps/preview/__tests__/worker.test.ts b/apps/preview/__tests__/worker.test.ts index 009c667..22f980a 100644 --- a/apps/preview/__tests__/worker.test.ts +++ b/apps/preview/__tests__/worker.test.ts @@ -157,10 +157,11 @@ describe("preview worker", () => { expect(await res.text()).toBe("png"); }); - test("refuses a picked head instead of showing the recorded comparison", async () => { + test("refuses a picked head the way the server refuses an unknown one", async () => { const env = fakeEnv({}); const res = await call("/api/diff?head=feat-x", env); - expect(res.status).toBe(404); + expect(res.status).toBe(400); + expect(res.headers.get("x-diff-error")).toBe("unknown-head"); expect(env.seen).toEqual([]); }); diff --git a/apps/preview/comment.sh b/apps/preview/comment.sh new file mode 100755 index 0000000..3ece1f6 --- /dev/null +++ b/apps/preview/comment.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# PR의 프리뷰 댓글을 하나로 유지한다. 숨은 표식으로 찾는다 — `gh pr comment +# --edit-last`는 같은 봇이 남긴 다른 댓글을 덮어쓴다. +# 사용법: comment.sh <본문>. ONLY_EXISTING=1이면 댓글이 없을 때 새로 달지 않는다. +set -euo pipefail +pr="$1" +body="$2" +marker='' +id="$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"$marker\"))) | .id" | sed -n 1p)" +full="$marker +$body" +if [ -n "$id" ]; then + gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$full" >/dev/null +elif [ "${ONLY_EXISTING:-}" != "1" ]; then + gh pr comment "$pr" --body "$full" +fi diff --git a/apps/preview/record.ts b/apps/preview/record.ts index cde7e95..b76acee 100644 --- a/apps/preview/record.ts +++ b/apps/preview/record.ts @@ -13,6 +13,10 @@ import { snapshotKey, } from "./snapshot.ts"; +// Workers 정적 에셋의 파일 하나 상한이다. /api/diff 본문은 바뀐 파일의 old·new +// 전문을 싣으므로 큰 파일을 건드린 PR에서 넘을 수 있다. +export const MAX_ASSET_BYTES = 25 * 1024 * 1024; + export interface RecordOptions { repo: string; /** 빌드된 뷰어(`apps/viewer/dist/viewer`). 사이트 루트로 복사한다. */ @@ -20,6 +24,7 @@ export interface RecordOptions { outDir: string; base: string; listPrs?: (repo: string) => Promise; + maxAssetBytes?: number; } // 실제 서버를 띄워 뷰어가 첫 화면에서 부르는 API를 그대로 불러 담는다. @@ -62,6 +67,12 @@ export const recordSnapshot = async ( } const index = Object.keys(entries).length; const file = res.status === 204 ? null : `${index}.bin`; + const limit = opts.maxAssetBytes ?? MAX_ASSET_BYTES; + if (body.byteLength > limit) { + throw new Error( + `${url.pathname} response is ${body.byteLength} bytes, over the ${limit}-byte asset limit`, + ); + } if (file) writeFileSync(join(snapshotDir, file), body); const key = snapshotKey(url); if (!key) throw new Error(`no snapshot key for ${url.pathname}`); @@ -74,10 +85,7 @@ export const recordSnapshot = async ( await record("/api/refs"); await record("/api/prs"); await record("/api/summary", { base: opts.base }); - const diff = await record("/api/diff", { - base: opts.base, - untracked: "0", - }); + const diff = await record("/api/diff", { base: opts.base }); if (diff.status === 200) { const files = JSON.parse( new TextDecoder().decode(diff.body), diff --git a/apps/preview/worker.ts b/apps/preview/worker.ts index 966e5cb..71be728 100644 --- a/apps/preview/worker.ts +++ b/apps/preview/worker.ts @@ -27,8 +27,15 @@ const replay = async ( env: Env, ): Promise => { // 피커로 다른 브랜치를 고르면 녹화하지 않은 diff다. 녹화본을 대신 주면 - // 고른 것과 다른 비교를 보여 준다. - if (url.searchParams.get("head")) return notInSnapshot(); + // 고른 것과 다른 비교를 보여 준다. 서버의 unknown-head로 답해야 뷰어가 + // 상태 줄에 이유를 쓰고, 새로 열면 워킹트리(녹화본)로 돌아갈 길을 준다. + const head = url.searchParams.get("head"); + if (head) { + return new Response(`not in this preview snapshot: ${head}`, { + status: 400, + headers: { "x-diff-error": "unknown-head" }, + }); + } const key = snapshotKey(url); if (!key) return notInSnapshot(); const manifest = await loadManifest(env, url.origin); diff --git a/apps/preview/wrangler.jsonc b/apps/preview/wrangler.jsonc index d0ed2c2..66f8660 100644 --- a/apps/preview/wrangler.jsonc +++ b/apps/preview/wrangler.jsonc @@ -2,6 +2,8 @@ "name": "diffdeck-preview", "main": "worker.ts", "compatibility_date": "2026-09-01", + // 비어 있어도 있어야 한다. 없으면 `wrangler preview`가 업로드 전에 실패한다. + "previews": {}, "assets": { // record.ts가 만든다(빌드된 뷰어 + _snapshot/). "directory": "./site",