Skip to content

Add interactive per-commit PR preview deployments - #184

Merged
leoncheng57 merged 4 commits into
mainfrom
feat/pr-preview-pipeline
Aug 26, 2026
Merged

Add interactive per-commit PR preview deployments#184
leoncheng57 merged 4 commits into
mainfrom
feat/pr-preview-pipeline

Conversation

@leoncheng57

@leoncheng57 leoncheng57 commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Reviewer summary

This PR turns every same-repository pull request into a public, interactive, credential-free simulator on GitHub Pages. It adapts the proven leoncheng57.github.io PR-preview pattern to this repository's browser -> Express BFF -> OpenCode architecture without publishing either backend process.

The core review questions are:

  1. Is the public simulator isolated from secrets, host files, and live agent state?
  2. Can parallel Pages writers update gh-pages without deleting each other's content?
  3. Is every artifact bound to the expected PR, full source SHA, base path, and exact file inventory?
  4. Does each new commit replace the same preview and GitHub Deployment cleanly?
  5. Does PR close remove only the artifacts and metadata owned by that PR?

Architecture at a glance

flowchart LR
  subgraph Local[Private/local production runtime]
    Browser[Browser SPA] --> BFF[Express BFF]
    BFF --> OpenCode[opencode serve]
    BFF --> Host[Git + host filesystem]
    BFF --> Secrets[Provider and forge credentials]
  end

  subgraph Public[Public PR preview]
    Pages[GitHub Pages] --> PRBundle[PR client bundle]
    PRBundle --> Simulator[In-browser BFF simulator]
    Simulator --> Fixture[Deterministic tab-local fixtures]
  end

  Public -. no network path .-> BFF
  Public -. no access .-> OpenCode
  Public -. no access .-> Host
  Public -. no secrets .-> Secrets
Loading

The preview uses the real PR client bundle. Only /api behavior is replaced, before React mounts, by client/simulator/publicSimulator.ts. Hash routing keeps nested client routes reload-safe under pr-previews/pr-<number>/. The preview build omits the production service worker and PWA manifest.

Per-commit deployment flow

sequenceDiagram
  autonumber
  participant Dev as Contributor push
  participant PR as pull_request workflow
  participant Chromium as Preview smoke test
  participant Artifact as Actions artifact
  participant Deploy as Deploy job
  participant GH as GitHub Deployments API
  participant Pages as gh-pages / Pages
  participant Comment as Sticky PR comment

  Dev->>PR: opened / reopened / synchronize
  PR->>PR: npm ci + build simulator at PR base path
  PR->>Chromium: Exercise session, send, workspace preview, planning, reload
  Chromium-->>PR: Pass with no page errors
  PR->>Artifact: Upload site + SHA-256 manifest
  Artifact->>Deploy: Download exact PR/SHA artifact
  Deploy->>Deploy: Revalidate identity, paths, bytes, digests, limits
  Deploy->>GH: Create transient pr-preview-N deployment
  Deploy->>Pages: Non-force publish pr-previews/pr-N only
  Deploy->>Pages: Wait until public URL responds
  Deploy->>GH: Mark deployment success/failure
  Deploy->>Comment: Create or update one <!-- pr-preview --> comment
Loading

Concurrency behavior

flowchart TD
  C1[Commit A build] --> D1[Commit A deploy]
  C2[Commit B build] --> D2[Commit B deploy]
  C3[Commit C build] --> D3[Commit C deploy]

  C1 -. newer build cancels only stale build .-> C2
  C2 -. newer build cancels only stale build .-> C3

  D1 --> Lock[Shared pr-screenshot-publication lock]
  D2 --> Lock
  D3 --> Lock
  Lock --> Branch[Non-force gh-pages writes serialize]
Loading

Builds are cancellable per PR. Deploys are not cancelled once they begin, because interrupting a shared-branch writer is riskier than briefly publishing an older commit before the queued newest commit replaces it.

Changed file and folder map

flowchart TD
  Root[PR #184 changes]

  Root --> Workflows[.github/workflows]
  Workflows --> PreviewWF[pr-preview.yml<br/>build, validate, deploy, Deployment API, sticky comment]
  Workflows --> CleanupWF[cleanup-pr-screenshots.yml<br/>preview + screenshot cleanup, deployment inactive]
  Workflows --> CIWF[ci.yml<br/>runs simulator smoke in ordinary CI]

  Root --> Client[client]
  Client --> Main[main.tsx + lib/runtime.ts<br/>install simulator before mount, HashRouter]
  Client --> Simulator[simulator/publicSimulator.ts<br/>tab-local BFF fixture and mutations]
  Client --> Shell[components/app-shell.tsx<br/>visible simulator disclosure]
  Client --> Workspace[components/workspace-panels.tsx<br/>public fixture iframe instead of localhost]
  Client --> PWA[index.html + vite.config.ts<br/>nested asset base, omit SW/manifest]
  Client --> Streams[useSessionStream + useNotifyWatcher<br/>no public SSE connections]

  Root --> Validation[validation and tooling]
  Validation --> Packager[scripts/pr-preview.ts<br/>bounded manifest package and validator]
  Validation --> Unit[tests/pr-preview.test.ts<br/>identity, tamper, symlink, base-path tests]
  Validation --> BrowserTest[tests/preview-e2e + playwright.preview.config.ts<br/>real built bundle at nested Pages path]
  Validation --> Scripts[package.json + tsconfig.tools.json]

  Root --> Docs[review and operations docs]
  Docs --> Readme[README.md<br/>usage, security model, local verification]
  Docs --> Agents[AGENTS.md decision 22<br/>durable architectural contract]
  Docs --> Ignore[.gitignore<br/>generated preview artifact]
Loading

Equivalent filesystem view:

.github/workflows/
├── pr-preview.yml                  # New per-commit build/deploy pipeline
├── cleanup-pr-screenshots.yml      # Expanded to all PR artifacts
└── ci.yml                          # Adds simulator smoke
client/
├── simulator/
│   └── publicSimulator.ts          # Browser-local BFF and fixtures
├── lib/
│   ├── runtime.ts                  # Build-mode flag
│   ├── useSessionStream.ts         # Disable SSE in public mode
│   └── useNotifyWatcher.ts         # Disable global SSE in public mode
├── components/
│   ├── app-shell.tsx               # Simulator disclosure
│   └── workspace-panels.tsx        # Fixture preview frame
├── main.tsx                        # Install adapter + select HashRouter
└── index.html                      # Base-relative public assets
scripts/
└── pr-preview.ts                   # Package and validate artifact inventory
tests/
├── pr-preview.test.ts              # Artifact security unit tests
└── preview-e2e/
    └── public-simulator.spec.ts     # Interactive nested-path smoke
playwright.preview.config.ts
vite.config.ts
README.md
AGENTS.md

BFF simulator stubs

client/simulator/publicSimulator.ts replaces globalThis.fetch before React mounts. It intercepts only /api/*; document and asset requests continue through native browser fetch. Unknown API method/path pairs return an explicit JSON 404 rather than a guessed success.

flowchart TD
  Call[Client api call] --> Adapter[Simulator fetch adapter]
  Adapter -->|not /api/*| Native[Native browser fetch]
  Adapter -->|known read| Fixture[Deterministic fixture response]
  Adapter -->|known mutation| Memory[Update tab-local memory]
  Memory --> Shape[Return normal BFF response shape]
  Adapter -->|unknown| Missing[Explicit JSON 404]
  SSE[Session and notification streams] --> Guard[PUBLIC_SIMULATOR guard]
  Guard --> Disabled[No EventSource or reconnect loop]
  LocalPreview[Workspace localhost preview] --> SrcDoc[Sandboxed fixture srcDoc]
Loading

Stubbed endpoint families

Surface Stubbed responses Tab-local mutations
Bootstrap and discovery Health, app config, projects, project pins, model pins, model catalogue, recent sessions Save project/model pins
Sessions List/detail, messages, todos, model limit, turn diff, prompt, abort, share, delete Create/delete sessions, append simulated turns, stop, share/unshare
Delegation and questions Sub-agent ledger, managed-child creation, child abort/promotion, questions Add managed children; reply/reject questions
Agent controls Auto permissions, permission requests, reminders, workflows Toggle auto approval; resolve permission
Tools and policy MCP, catalogue, permissions, LSP Connect/disconnect MCP entries
Workspace Tree, file read, reference validation, changes, commits, worktrees Read-only fixture; references open the real viewer
App preview Production localhost proxy is replaced by sandboxed srcDoc Reload remounts fixture; no localhost request
Notifications Preferences, history, resolve/reopen, test result shapes Save preferences; resolve/reopen records
Planning Snapshot, labels, details/comments, label replacement, issue creation Regroup labels; add fixture issues
Forge review Summary, details, comments, reviews, checks, merge response Simulated merge success only

Representative fixture coverage

  • Two projects and root/child sessions covering idle, running, completed, and delegated UI.
  • Transcript prose, reasoning, read/bash tools, patch metadata, usage/cost, review URL, todos, permission, and question.
  • Anthropic/OpenAI model metadata, variants, image capability, MCP failures, LSP state, effective permissions, files, references, diffs, and commits.
  • Planning issues/PRs across priorities and states, item details/comments, and resolved/unresolved notification records.

Deliberately not stubbed

  • No Express process, OpenCode server, EventSource, filesystem, git command, shell, model provider, GitHub API, ntfy, Web Push, or localhost proxy call.
  • No production service worker, installable PWA, offline behavior, or persistence across reloads.
  • Simulated mutation success does not claim server authorization. Directory containment, permission policy, upstream contracts, SSE, and BFF failure behavior remain covered by the existing production-BFF suite using tests/e2e/mock-opencode.ts and tests/e2e/mock-preview.ts.

Trust boundaries

Boundary Guarantee Review location
PR build Runs PR code with contents: read only .github/workflows/pr-preview.yml, build job
Fork PR Builds artifact but never deploys JavaScript to repository Pages origin deploy.if same-repository check
Artifact identity Positive PR number, full lowercase SHA, exact PR base path scripts/pr-preview.ts
Artifact contents Regular files only; no links, traversal, .git, duplicates, oversized files, or undeclared bytes packager + inline deploy revalidation
Pages writes One PR directory, clean: true, force: false, shared publication lock deploy job
Runtime data Deterministic fixture; no BFF, OpenCode, filesystem, .env, token, or live transcript client/simulator/publicSimulator.ts
Browser persistence Tab-local mutable state; reload resets fixture simulator factory
PWA scope No preview service worker or manifest vite.config.ts, client/main.tsx
Close cleanup Removes only pr-previews/pr-N, pr-screenshots/pr-N, marker-owned comments, and marks deployments inactive cleanup workflow

Suggested review order

  1. Workflow authority and lifecycle: .github/workflows/pr-preview.yml, then .github/workflows/cleanup-pr-screenshots.yml.
  2. Artifact boundary: scripts/pr-preview.ts and tests/pr-preview.test.ts.
  3. Runtime isolation: client/main.tsx, client/lib/runtime.ts, then client/simulator/publicSimulator.ts.
  4. Pages-specific behavior: vite.config.ts, client/index.html, stream guards, workspace fixture frame.
  5. Behavioral proof: playwright.preview.config.ts and tests/preview-e2e/public-simulator.spec.ts.
  6. Operational contract: README and AGENTS.md decision chore: upgrade OpenCode supervision to 1.18.21 #22.

Live end-to-end evidence

This PR is the demonstration deployment:

  • Live simulator: https://leoncheng.dev/custom-dca-opencode/pr-previews/pr-184/
  • Commit 836bf48: build passed; deploy failed because the Pages action required a Git checkout. The deployment failure status and sticky failure comment both worked.
  • Commit fd4e6e5: synchronize retriggered the pipeline; build, manifest revalidation, Pages publication, URL wait, transient deployment, and sticky link all succeeded.
  • A browser check on the actual Pages origin exercised session navigation, a simulated send, planning navigation, and reload-safe hash routing with zero page errors.
  • The gh-pages tree retained every existing pr-screenshots/* directory and added only pr-previews/pr-184/*.

Deliberate tradeoffs

  • The simulator is broad enough for UI review, not a behavioral replacement for the Express BFF. Security-sensitive BFF logic remains covered by the existing production-mock E2E suite.
  • Mutations are intentionally non-durable. Reviewers can exercise flows without creating repository, model-provider, or agent side effects.
  • Fork previews are downloadable artifacts, not public deployments. Publishing untrusted fork JavaScript on the repository's Pages origin is explicitly declined.
  • Hash routing is preview-only. Normal production/local operation retains BrowserRouter URLs.
  • The shared lock name remains pr-screenshot-publication for compatibility with the existing screenshot and pending public-site publishers.

Verification

  • npm run typecheck
  • npm test — 559 passed
  • CI=1 PORT=3510 MOCK_OPENCODE_PORT=4699 MOCK_PREVIEW_PORT=4700 npm run test:e2e — 303 passed, 1 expected screenshot-runner skip
  • npm run test:preview
  • preview package/validate round trip at the production nested base path
  • workflow YAML parsed locally
  • GitHub CI, secret scan, screenshot capture, preview build, and preview deploy are green

CI: screenshots

/?directory=/tmp/mock-project
full:/sessions/ses_mock_done?directory=/tmp/mock-project
/planning

Closes #112
Closes #153

Related: #110, #119

@github-actions
github-actions Bot temporarily deployed to pr-preview-184 August 26, 2026 00:57 Destroyed
@github-actions
github-actions Bot temporarily deployed to pr-preview-184 August 26, 2026 00:59 Destroyed
github-actions Bot added a commit that referenced this pull request Aug 26, 2026
github-actions Bot added a commit that referenced this pull request Aug 26, 2026
@github-actions
github-actions Bot temporarily deployed to pr-preview-184 August 26, 2026 01:50 Destroyed
@leoncheng57
leoncheng57 merged commit 5d8602a into main Aug 26, 2026
6 checks passed
@leoncheng57
leoncheng57 deleted the feat/pr-preview-pipeline branch August 26, 2026 01:52
github-actions Bot added a commit that referenced this pull request Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use GitHub's deployment infra to build a public simulator Deploy secure pull-request previews and a public simulator

1 participant