From 836bf48adc97d68ce6ab1b0e9a9f006022ab4ef8 Mon Sep 17 00:00:00 2001 From: Leon Cheng Date: Tue, 25 Aug 2026 20:56:18 -0400 Subject: [PATCH 1/3] Add interactive PR preview deployments --- .github/workflows/ci.yml | 3 + .github/workflows/cleanup-pr-screenshots.yml | 35 ++- .github/workflows/pr-preview.yml | 237 +++++++++++++++++ .gitignore | 1 + AGENTS.md | 14 + README.md | 37 +++ client/components/app-shell.tsx | 10 + client/components/workspace-panels.tsx | 15 +- client/index.html | 6 +- client/lib/runtime.ts | 1 + client/lib/useNotifyWatcher.ts | 2 + client/lib/useSessionStream.ts | 2 + client/main.tsx | 61 +++-- client/simulator/publicSimulator.ts | 259 +++++++++++++++++++ package.json | 3 + playwright.preview.config.ts | 18 ++ scripts/pr-preview.ts | 138 ++++++++++ tests/pr-preview.test.ts | 53 ++++ tests/preview-e2e/public-simulator.spec.ts | 35 +++ tsconfig.tools.json | 2 +- vite.config.ts | 13 +- 21 files changed, 904 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/pr-preview.yml create mode 100644 client/lib/runtime.ts create mode 100644 client/simulator/publicSimulator.ts create mode 100644 playwright.preview.config.ts create mode 100644 scripts/pr-preview.ts create mode 100644 tests/pr-preview.test.ts create mode 100644 tests/preview-e2e/public-simulator.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00c2dfd9..5bcdce8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: E2E run: npm run test:e2e + - name: PR preview simulator + run: npm run test:preview + - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/.github/workflows/cleanup-pr-screenshots.yml b/.github/workflows/cleanup-pr-screenshots.yml index badd58c4..b9539d21 100644 --- a/.github/workflows/cleanup-pr-screenshots.yml +++ b/.github/workflows/cleanup-pr-screenshots.yml @@ -1,4 +1,4 @@ -name: Cleanup PR screenshots +name: Cleanup PR artifacts on: pull_request_target: @@ -12,17 +12,18 @@ concurrency: # executes pull request code. permissions: contents: write + deployments: write pull-requests: write jobs: cleanup: runs-on: ubuntu-latest steps: - - name: Remove only this PR publication + - name: Remove only this PR's published artifacts uses: actions/github-script@v7 with: script: | - const target = `pr-screenshots/pr-${context.issue.number}`; + const targets = [`pr-screenshots/pr-${context.issue.number}`, `pr-previews/pr-${context.issue.number}`]; let ref; try { ref = await github.rest.git.getRef({ ...context.repo, ref: 'heads/gh-pages' }); @@ -32,26 +33,42 @@ jobs: } const commit = await github.rest.git.getCommit({ ...context.repo, commit_sha: ref.data.object.sha }); const tree = await github.rest.git.getTree({ ...context.repo, tree_sha: commit.data.tree.sha, recursive: 'true' }); - if (!tree.data.tree.some((entry) => entry.path === target || entry.path?.startsWith(`${target}/`))) return; + const present = targets.filter((target) => tree.data.tree.some((entry) => entry.path === target || entry.path?.startsWith(`${target}/`))); + if (present.length === 0) return; const nextTree = await github.rest.git.createTree({ ...context.repo, base_tree: commit.data.tree.sha, - tree: [{ path: target, mode: '040000', type: 'tree', sha: null }], + tree: present.map((path) => ({ path, mode: '040000', type: 'tree', sha: null })), }); const nextCommit = await github.rest.git.createCommit({ ...context.repo, - message: `Remove screenshots for closed PR #${context.issue.number}`, + message: `Remove published artifacts for closed PR #${context.issue.number}`, tree: nextTree.data.sha, parents: [ref.data.object.sha], }); await github.rest.git.updateRef({ ...context.repo, ref: 'heads/gh-pages', sha: nextCommit.data.sha }); - - name: Remove only the screenshot bot comment + - name: Remove only artifact bot comments uses: actions/github-script@v7 with: script: | - const marker = ''; + const markers = ['', '']; const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: context.issue.number, per_page: 100 }); - for (const comment of comments.filter((item) => item.user?.login === 'github-actions[bot]' && item.body?.includes(marker))) { + for (const comment of comments.filter((item) => item.user?.login === 'github-actions[bot]' && markers.some((marker) => item.body?.includes(marker)))) { await github.rest.issues.deleteComment({ ...context.repo, comment_id: comment.id }); } + + - name: Mark the preview environment inactive + uses: actions/github-script@v7 + with: + script: | + const environment = `pr-preview-${context.issue.number}`; + const deployments = await github.rest.repos.listDeployments({ ...context.repo, environment, per_page: 100 }); + for (const deployment of deployments.data) { + await github.rest.repos.createDeploymentStatus({ + ...context.repo, + deployment_id: deployment.id, + state: 'inactive', + description: `PR #${context.issue.number} closed`, + }); + } diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 00000000..bba8230f --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,237 @@ +name: PR preview + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + build: + runs-on: ubuntu-latest + concurrency: + group: pr-preview-build-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: + contents: read + env: + PREVIEW_BASE_PATH: /custom-dca-opencode/pr-previews/pr-${{ github.event.pull_request.number }}/ + steps: + - name: Check out pull request + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Test public simulator + run: npm run test:preview + + - name: Build PR-specific simulator + run: npm run build:preview + + - name: Package bounded preview artifact + run: >- + npm run preview:package -- + --build dist/client + --bundle pr-preview-output + --pr-number "${{ github.event.pull_request.number }}" + --sha "${{ github.event.pull_request.head.sha }}" + --base-path "$PREVIEW_BASE_PATH" + + - name: Upload preview artifact + uses: actions/upload-artifact@v4 + with: + name: pr-preview-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: pr-preview-output/ + if-no-files-found: error + retention-days: 30 + + deploy: + needs: build + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + deployments: write + pull-requests: write + # Every Pages writer uses this lock and a non-force push. Per-PR builds may + # cancel each other above; shared-branch publications must never race. + concurrency: + group: pr-screenshot-publication + cancel-in-progress: false + env: + PREVIEW_URL: https://leoncheng.dev/custom-dca-opencode/pr-previews/pr-${{ github.event.pull_request.number }}/ + steps: + - name: Download exact preview artifact + uses: actions/download-artifact@v4 + with: + name: pr-preview-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: bundle + + - name: Revalidate artifact identity and inventory + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + node <<'NODE' + const crypto = require('node:crypto'); + const fs = require('node:fs'); + const path = require('node:path'); + const manifest = JSON.parse(fs.readFileSync('bundle/manifest.json', 'utf8')); + const expectedBase = `/custom-dca-opencode/pr-previews/pr-${process.env.PR_NUMBER}/`; + if (manifest.version !== 1 || String(manifest.prNumber) !== process.env.PR_NUMBER || manifest.sha !== process.env.HEAD_SHA || manifest.basePath !== expectedBase) { + throw new Error('artifact identity does not match this PR commit'); + } + if (!Array.isArray(manifest.files) || manifest.files.length < 1 || manifest.files.length > 500) throw new Error('invalid file inventory'); + const expected = new Map(manifest.files.map((file) => [file.path, file])); + if (expected.size !== manifest.files.length || !expected.has('index.html')) throw new Error('invalid or duplicate file inventory'); + let count = 0; + let total = 0; + const visit = (directory) => { + for (const name of fs.readdirSync(directory)) { + const absolute = path.join(directory, name); + const stat = fs.lstatSync(absolute); + if (stat.isSymbolicLink()) throw new Error('symbolic links are forbidden'); + if (stat.isDirectory()) { visit(absolute); continue; } + if (!stat.isFile()) throw new Error('non-file artifact entry'); + const relative = path.relative('bundle/site', absolute).split(path.sep).join('/'); + if (path.posix.normalize(relative) !== relative || relative.startsWith('../') || relative.includes('\\') || relative.startsWith('.git/')) throw new Error(`unsafe path: ${relative}`); + const declared = expected.get(relative); + const digest = crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex'); + if (!declared || declared.bytes !== stat.size || declared.sha256 !== digest) throw new Error(`inventory mismatch: ${relative}`); + if (stat.size > 8 * 1024 * 1024) throw new Error(`oversized file: ${relative}`); + count += 1; + total += stat.size; + } + }; + visit('bundle/site'); + if (count !== expected.size || total > 50 * 1024 * 1024) throw new Error('artifact bounds do not match'); + NODE + + - name: Register transient GitHub deployment + id: deployment + uses: actions/github-script@v7 + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PREVIEW_URL: ${{ env.PREVIEW_URL }} + with: + script: | + const deployment = await github.rest.repos.createDeployment({ + ...context.repo, + ref: process.env.HEAD_SHA, + environment: `pr-preview-${process.env.PR_NUMBER}`, + description: `Public simulator for PR #${process.env.PR_NUMBER}`, + auto_merge: false, + required_contexts: [], + transient_environment: true, + production_environment: false, + }); + await github.rest.repos.createDeploymentStatus({ + ...context.repo, + deployment_id: deployment.data.id, + state: 'in_progress', + environment_url: process.env.PREVIEW_URL, + description: 'Publishing the PR simulator', + }); + core.setOutput('id', deployment.data.id); + + - name: Publish only this PR directory + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: bundle/site + branch: gh-pages + target-folder: pr-previews/pr-${{ github.event.pull_request.number }} + clean: true + force: false + attempt-limit: 10 + + - name: Wait for GitHub Pages + run: curl --fail --location --retry 18 --retry-delay 5 --retry-all-errors "$PREVIEW_URL?sha=${{ github.event.pull_request.head.sha }}" >/dev/null + + - name: Mark deployment successful + uses: actions/github-script@v7 + env: + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + PREVIEW_URL: ${{ env.PREVIEW_URL }} + with: + script: | + await github.rest.repos.createDeploymentStatus({ + ...context.repo, + deployment_id: Number(process.env.DEPLOYMENT_ID), + state: 'success', + environment_url: process.env.PREVIEW_URL, + description: 'PR simulator is ready', + }); + + - name: Update sticky preview comment + uses: actions/github-script@v7 + env: + PREVIEW_URL: ${{ env.PREVIEW_URL }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const marker = ''; + const shortSha = process.env.HEAD_SHA.slice(0, 7); + const body = [ + marker, + '## Interactive PR preview', + '', + `[Open the public simulator](${process.env.PREVIEW_URL})`, + '', + `Deployed from \`${shortSha}\`. This preview refreshes on every commit and is removed when the pull request closes.`, + '', + '_The simulator contains deterministic fixture data only. It has no OpenCode process, AI provider key, GitHub token, repository secret, or access to a contributor filesystem._', + ].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: context.issue.number, per_page: 100 }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + if (existing) await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body }); + else await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body }); + + - name: Mark deployment failed + if: failure() && steps.deployment.outputs.id + uses: actions/github-script@v7 + env: + DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }} + PREVIEW_URL: ${{ env.PREVIEW_URL }} + with: + script: | + await github.rest.repos.createDeploymentStatus({ + ...context.repo, + deployment_id: Number(process.env.DEPLOYMENT_ID), + state: 'failure', + environment_url: process.env.PREVIEW_URL, + description: 'PR simulator publication failed', + }); + const marker = ''; + const body = [marker, '## Interactive PR preview', '', `Preview publication failed. [Open the workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: context.issue.number, per_page: 100 }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + if (existing) await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body }); + else await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body }); + + report-build-failure: + needs: build + if: always() && needs.build.result == 'failure' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Update sticky preview comment + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const body = [marker, '## Interactive PR preview', '', `Preview build failed. [Open the workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: context.issue.number, per_page: 100 }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + if (existing) await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body }); + else await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body }); diff --git a/.gitignore b/.gitignore index 34cb3735..a9e0502c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ playwright-report/ mcp-servers.json screenshots-out/ screenshot-output/ +pr-preview-output/ .agent-status.json diff --git a/AGENTS.md b/AGENTS.md index 0966fb4f..f86dc384 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -331,6 +331,20 @@ several decisions below. dialog states that prompt_async 204/202 means accepted, not completed. The managed-child form reuses decision #19's route with the same Build authorization checkbox and creates no task card and no automatic hand-back. +22. **PR previews are static simulators, never public agent servers.** GitHub Pages cannot + host the Express BFF or `opencode serve`, and putting either on a public endpoint would + require credentials and expose host-level agent authority. `VITE_PUBLIC_SIMULATOR=true` + therefore builds the real client with a browser-local `/api` fixture adapter, hash + routing, a visible simulator banner, and no service worker or PWA manifest. Mutations + are tab-local and reset on reload. Same-repository PRs build on every commit, publish + only `gh-pages:pr-previews/pr-/`, create a transient GitHub Deployment, and + maintain one `` comment; forks build an artifact but never publish + JavaScript on the repository's Pages origin. The artifact manifest is bound to PR, + full SHA, base path, file sizes, and SHA-256 digests and is revalidated before the + shared non-force Pages write. Preview, screenshot, and public-site writers all use the + `pr-screenshot-publication` concurrency group. Close cleanup removes only that PR's + preview and screenshot directories, deletes their marker-owned comments, and marks the + preview deployments inactive. ## Client conventions (inherited from the OpenHands runner, still enforced) diff --git a/README.md b/README.md index 16de9bba..ccee62a6 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,43 @@ npm run typecheck npm test npm run build npm run test:e2e +npm run test:preview +``` + +### Interactive PR previews + +Every same-repository pull request receives a public, interactive simulator at +`https://leoncheng.dev/custom-dca-opencode/pr-previews/pr-/`. The **PR preview** +workflow runs on `opened`, `reopened`, and every `synchronize` event, so each pushed commit +rebuilds the preview. It tests the production bundle in Chromium, publishes only that PR's +directory on `gh-pages`, creates a transient GitHub Deployment, and updates one +`` comment with the current commit and URL. Closing the PR removes the +directory and comment and marks its deployment environment inactive. + +The preview is the actual PR client bundle with an in-browser BFF simulator. It includes +projects, sessions, transcripts, Plan/Build controls, models, tasks, sub-agents, workspace +files and changes, tools, settings, notifications, docs, and planning fixtures. Mutating +controls update tab-local memory so reviewers can exercise flows without an OpenCode +process. Reloading restores the deterministic fixture. The simulator uses hash routing so +all client routes remain reload-safe below the PR-specific Pages path. + +No `.env` file, OpenCode password, AI provider key, GitHub token, repository secret, host +filesystem, or live conversation enters the bundle. The simulator does not register the +production service worker or publish a PWA manifest. Forks still run the read-only build +and retain the 30-day artifact, but are not published: executing a fork's JavaScript on the +repository's Pages origin is not an acceptable convenience tradeoff. + +The artifact carries a full SHA/size inventory bound to the PR number, source commit, and +base path. Publication revalidates that inventory, rejects links and unsafe paths, caps the +file count and total bytes, and writes through the same non-force `gh-pages` concurrency +lock as screenshots and the public website. GitHub Pages must remain configured to deploy +the `gh-pages` branch from `/(root)`. + +Run the same simulator smoke test locally with: + +```bash +npx playwright install chromium +npm run test:preview ``` ### PR screenshots diff --git a/client/components/app-shell.tsx b/client/components/app-shell.tsx index c2f3924b..495a7684 100644 --- a/client/components/app-shell.tsx +++ b/client/components/app-shell.tsx @@ -16,6 +16,7 @@ import { } from "../lib/palette.js"; import { selectPhoneUrl } from "../lib/phoneTransfer.js"; import { refreshApp } from "../lib/appRefresh.js"; +import { PUBLIC_SIMULATOR } from "../lib/runtime.js"; import { useNotifyWatcher } from "../lib/useNotifyWatcher.js"; import { getDoc } from "../lib/docs.js"; import { NavOverflowMenu } from "./nav-overflow-menu.js"; @@ -217,6 +218,15 @@ export function AppShell() { void openPhoneTransfer()} /> + {PUBLIC_SIMULATOR && ( +
+ PR simulator: fixture data only. Actions stay in this tab, use no credentials, and reset on reload. +
+ )}
diff --git a/client/components/workspace-panels.tsx b/client/components/workspace-panels.tsx index 30f7d99b..b3506b5b 100644 --- a/client/components/workspace-panels.tsx +++ b/client/components/workspace-panels.tsx @@ -5,6 +5,7 @@ import { Button } from "../ds/button.js"; import { WorkspaceFiles } from "./workspace-files.js"; import { api, type GitCommit, type VcsFileDiff } from "../lib/api.js"; import type { WorkspaceTarget } from "../lib/fileReferences.js"; +import { PUBLIC_SIMULATOR } from "../lib/runtime.js"; type Tab = "files" | "changes" | "preview"; @@ -86,8 +87,18 @@ export function WorkspacePanels({ -