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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 26 additions & 9 deletions .github/workflows/cleanup-pr-screenshots.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Cleanup PR screenshots
name: Cleanup PR artifacts

on:
pull_request_target:
Expand All @@ -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' });
Expand All @@ -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 = '<!-- pr-screenshots -->';
const markers = ['<!-- pr-screenshots -->', '<!-- pr-preview -->'];
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`,
});
}
242 changes: 242 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
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: Check out trusted base for the Pages writer
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}

- 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 = '<!-- pr-preview -->';
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 = '<!-- pr-preview -->';
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 = '<!-- pr-preview -->';
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 });
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ playwright-report/
mcp-servers.json
screenshots-out/
screenshot-output/
pr-preview-output/
.agent-status.json
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,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-<number>/`, create a transient GitHub Deployment, and
maintain one `<!-- pr-preview -->` 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)

Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ Pull requests run the full verification sequence and a full-history Gitleaks sca
not commit `.env`, credentials, local state, generated build output, Playwright reports,
or screenshot output.

Every same-repository pull request also receives a credential-free interactive simulator
that refreshes on each commit. See [Pull request previews](docs/pr-previews.md) for the
deployment flow, diagrams, BFF stub contract, trust boundaries, and troubleshooting.

### Request deterministic UI screenshots

For a UI change, add one fenced `screenshots` block to the pull request body with one
Expand Down
Loading
Loading