Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .claude/rules/preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
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`가 붙은 요청은 서버처럼 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를 만든다. 배포 뒤 `/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`가 숨은 표식(`<!-- diffdeck-preview -->`)으로 찾아 고친다. `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다.

## 확인

- 로컬: `cd apps/viewer && bun run build`, 루트에서 `bun apps/preview/cli.ts`, 그다음 `bunx wrangler@<버전> dev --config apps/preview/wrangler.jsonc`. 로그인 없이 돈다.
- `cli.ts`는 커버리지 게이트 밖이다(`bunfig.toml`). 워크플로가 PR마다 실행한다.
143 changes: 143 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
name: Preview

# PR마다 그 브랜치의 뷰어를 Cloudflare Worker로 띄운다(preview.md).
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, closed]
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

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
timeout-minutes: 5
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
permissions:
contents: read
pull-requests: write
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 }}
# `--json`이어도 에셋 업로드 로그가 stdout에 섞이므로 결과는 이 파일에서 읽는다.
WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/wrangler.ndjson
PR: ${{ github.event.pull_request.number }}
run: |
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 }}
run: |
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
- 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
- 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이 닫혀 프리뷰를 지웠습니다."
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ scripts/parity/out/
docs/superpowers/
test-results/
playwright-report/
apps/preview/site/
.wrangler/
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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된 로컬 계획 문서다.
Expand Down Expand Up @@ -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 번역 동기화 |

## 이 문서를 고칠 때
Expand Down
140 changes: 140 additions & 0 deletions apps/preview/__tests__/record.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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"), "<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, 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 () => {
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("<html>");
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);
});

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$/,
);
});
});
Loading
Loading