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
57 changes: 57 additions & 0 deletions .github/workflows/cleanup-pr-screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Cleanup PR screenshots

on:
pull_request_target:
types: [closed]

concurrency:
group: pr-screenshot-publication
cancel-in-progress: false

# This event is used only for trusted Git API mutations. It never checks out or
# executes pull request code.
permissions:
contents: write
pull-requests: write

jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Remove only this PR publication
uses: actions/github-script@v7
with:
script: |
const target = `pr-screenshots/pr-${context.issue.number}`;
let ref;
try {
ref = await github.rest.git.getRef({ ...context.repo, ref: 'heads/gh-pages' });
} catch (error) {
if (error.status === 404) return;
throw error;
}
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 nextTree = await github.rest.git.createTree({
...context.repo,
base_tree: commit.data.tree.sha,
tree: [{ path: target, mode: '040000', type: 'tree', sha: null }],
});
const nextCommit = await github.rest.git.createCommit({
...context.repo,
message: `Remove screenshots 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
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- pr-screenshots -->';
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))) {
await github.rest.issues.deleteComment({ ...context.repo, comment_id: comment.id });
}
50 changes: 50 additions & 0 deletions .github/workflows/pr-screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: PR screenshots

on:
pull_request:
types: [opened, synchronize, reopened, edited]

concurrency:
group: pr-screenshots-${{ github.event.pull_request.number }}
cancel-in-progress: true

# This workflow executes the PR checkout. It deliberately has no write permission
# and receives no repository secrets, including for pull requests from forks.
permissions:
contents: read

jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci

- name: Read screenshot request from event
run: jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH" > "$RUNNER_TEMP/pr-body.txt"

- name: Install Chromium
if: contains(github.event.pull_request.body || '', '```screenshots')
run: npx playwright install --with-deps chromium

- name: Capture and validate screenshots
run: >-
npm run screenshots --
--body-file "$RUNNER_TEMP/pr-body.txt"
--output-dir screenshot-output
--pr-number "${{ github.event.pull_request.number }}"
--sha "${{ github.event.pull_request.head.sha }}"

- name: Upload validated bundle
uses: actions/upload-artifact@v4
with:
name: pr-screenshots-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
path: screenshot-output/
if-no-files-found: error
retention-days: 30
207 changes: 207 additions & 0 deletions .github/workflows/publish-pr-screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
name: Publish PR screenshots

on:
workflow_run:
workflows: [PR screenshots]
types: [completed]

# gh-pages is shared by every PR, so publications serialize even though captures
# use per-PR concurrency. The trusted publisher never executes artifact code.
concurrency:
group: pr-screenshot-publication
cancel-in-progress: false

permissions:
actions: read
contents: write
pull-requests: write

jobs:
publish:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Resolve trusted run identity
id: run
env:
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
REPOSITORY: ${{ github.repository }}
run: |
pr_number=$(jq -r '.workflow_run.pull_requests[0].number // empty' "$GITHUB_EVENT_PATH")
if [ -z "$pr_number" ]; then
pr_number=$(gh api --paginate "repos/$REPOSITORY/commits/$RUN_HEAD_SHA/pulls" --jq '.[0].number // empty')
fi
case "$pr_number" in (*[!0-9]*|'') echo "Could not resolve a numeric PR number" >&2; exit 1;; esac
PR_JSON=$(gh api "repos/$REPOSITORY/pulls/$pr_number")
PR_HEAD_SHA=$(jq -r '.head.sha' <<< "$PR_JSON")
PR_HEAD_REPOSITORY=$(jq -r '.head.repo.full_name' <<< "$PR_JSON")
case "$RUN_HEAD_SHA" in (*[!0-9a-f]*|'') echo "Invalid workflow run head SHA" >&2; exit 1;; esac
test "${#RUN_HEAD_SHA}" -eq 40
test "$PR_HEAD_SHA" = "$RUN_HEAD_SHA" || { echo "Workflow run SHA no longer matches PR head; refusing publication" >&2; exit 1; }
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
echo "head_sha=$RUN_HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "same_repository=$([ "$PR_HEAD_REPOSITORY" = "$REPOSITORY" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"

- name: Report failed capture
if: github.event.workflow_run.conclusion != 'success'
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.run.outputs.pr_number }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
with:
script: |
const marker = '<!-- pr-screenshots -->';
const body = [
marker,
'## PR screenshots',
'',
`Screenshot capture failed. [Open the workflow run](${process.env.RUN_URL}) for the parser, security validation, or capture error.`,
'',
'_Unsafe routes are rejected before a browser is started._',
].join('\n');
const issue_number = Number(process.env.PR_NUMBER);
const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, 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, body });

- name: Report fork artifact without publishing binaries
if: github.event.workflow_run.conclusion == 'success' && steps.run.outputs.same_repository != 'true'
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.run.outputs.pr_number }}
HEAD_SHA: ${{ steps.run.outputs.head_sha }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
with:
script: |
const marker = '<!-- pr-screenshots -->';
const issue_number = Number(process.env.PR_NUMBER);
const artifactName = `pr-screenshots-${issue_number}-${process.env.HEAD_SHA}`;
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ ...context.repo, run_id: Number(process.env.RUN_ID), per_page: 100 });
const artifact = artifacts.data.artifacts.find((item) => item.name === artifactName);
const artifactUrl = artifact ? `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.RUN_ID}/artifacts/${artifact.id}` : process.env.RUN_URL;
const body = [
marker,
'## PR screenshots',
'',
'This fork ran the read-only mock capture, but inline publication is disabled because untrusted fork code can craft arbitrary artifact bytes.',
'',
`[Inspect the workflow run](${process.env.RUN_URL}) | [Download the untrusted artifact](${artifactUrl})`,
].join('\n');
const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, 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, body });

- name: Check out trusted publisher
if: github.event.workflow_run.conclusion == 'success' && steps.run.outputs.same_repository == 'true'
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
path: source

- name: Prepare gh-pages worktree
if: github.event.workflow_run.conclusion == 'success' && steps.run.outputs.same_repository == 'true'
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
git clone --no-checkout "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" site
cd site
if git show-ref --verify --quiet refs/remotes/origin/gh-pages; then
git switch --track origin/gh-pages
else
git switch --orphan gh-pages
git rm -rf . || true
fi

- name: Set up Node.js
if: github.event.workflow_run.conclusion == 'success' && steps.run.outputs.same_repository == 'true'
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: source/package-lock.json

- name: Install trusted publisher dependencies
if: github.event.workflow_run.conclusion == 'success' && steps.run.outputs.same_repository == 'true'
working-directory: source
run: npm ci

- name: Download exact workflow artifact
if: github.event.workflow_run.conclusion == 'success' && steps.run.outputs.same_repository == 'true'
uses: actions/download-artifact@v4
with:
name: pr-screenshots-${{ steps.run.outputs.pr_number }}-${{ steps.run.outputs.head_sha }}
path: bundle
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}

- name: Validate and stage publication
if: github.event.workflow_run.conclusion == 'success' && steps.run.outputs.same_repository == 'true'
id: bundle
env:
PR_NUMBER: ${{ steps.run.outputs.pr_number }}
HEAD_SHA: ${{ steps.run.outputs.head_sha }}
run: |
result=$(cd source && npx tsx scripts/publish-pr-screenshots.ts \
--bundle ../bundle \
--destination "../site/pr-screenshots/pr-$PR_NUMBER" \
--pr-number "$PR_NUMBER" \
--sha "$HEAD_SHA")
echo "count=$(jq -r '.count' <<< "$result")" >> "$GITHUB_OUTPUT"

- name: Publish only this PR directory
if: github.event.workflow_run.conclusion == 'success' && steps.run.outputs.same_repository == 'true'
working-directory: site
env:
PR_NUMBER: ${{ steps.run.outputs.pr_number }}
HEAD_SHA: ${{ steps.run.outputs.head_sha }}
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git add --all
if git diff --cached --quiet; then exit 0; fi
git commit -m "Publish screenshots for PR #$PR_NUMBER at $HEAD_SHA"
git push origin gh-pages

- name: Update sticky screenshot comment
if: github.event.workflow_run.conclusion == 'success' && steps.run.outputs.same_repository == 'true'
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.run.outputs.pr_number }}
HEAD_SHA: ${{ steps.run.outputs.head_sha }}
RUN_ID: ${{ github.event.workflow_run.id }}
with:
script: |
const fs = require('fs');
const marker = '<!-- pr-screenshots -->';
const issue_number = Number(process.env.PR_NUMBER);
const sha = process.env.HEAD_SHA;
const shortSha = sha.slice(0, 7);
const manifest = JSON.parse(fs.readFileSync('bundle/manifest.json', 'utf8'));
const base = `https://raw.githubusercontent.com/${context.repo.owner}/${context.repo.repo}/gh-pages/pr-screenshots/pr-${issue_number}`;
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ ...context.repo, run_id: Number(process.env.RUN_ID), per_page: 100 });
const artifact = artifacts.data.artifacts.find((item) => item.name === `pr-screenshots-${issue_number}-${sha}`);
const artifactUrl = artifact ? `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.RUN_ID}/artifacts/${artifact.id}` : context.payload.workflow_run.html_url;
const escapeHtml = (value) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
const lines = [marker, '## PR screenshots', ''];
if (manifest.screenshots.length === 0) {
lines.push('No screenshots requested. Add this to the PR description:', '', '```md', '```screenshots', '/?directory=/tmp/mock-project', 'full:/sessions/ses_mock_done?directory=/tmp/mock-project', '```', '```');
} else {
lines.push('| Route | Preview |', '| --- | --- |');
for (const shot of manifest.screenshots) {
const url = `${base}/${encodeURIComponent(shot.filename)}?sha=${shortSha}`;
const route = escapeHtml(shot.requestedRoute).replaceAll('|', '&#124;');
const alt = escapeHtml(shot.requestedRoute);
lines.push(`| <code>${route}</code>${shot.fullPage ? ' (full page)' : ''}<br>[Open full size](${url}) | [<img src="${url}" width="640" alt="${alt}">](${url}) |`);
}
lines.push('', `Source: \`${shortSha}\` | [Download artifact](${artifactUrl})`);
}
lines.push('', '_Captured from the production app against deterministic mock OpenCode fixtures only._');
const body = lines.join('\n');
const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, 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, body });
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ playwright-report/
.DS_Store
mcp-servers.json
screenshots-out/
screenshot-output/
.agent-status.json
45 changes: 43 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ several decisions below.
than the 1.18.21 server and its event union is stale, so `server/opencode/client.ts`
owns a small typed fetch seam instead of casting around the SDK.
- Tests: `npm test` (vitest, `tests/*.test.ts`, node environment, import with `.js`
suffixes). `npm run typecheck` runs both tsconfigs. Playwright starts deterministic
mock OpenCode and preview servers, so `npm run test:e2e` needs no live stack or keys.
suffixes). `npm run typecheck` runs the client, server, and screenshot-tool tsconfigs.
Playwright starts deterministic mock OpenCode and preview servers, so
`npm run test:e2e` needs no live stack or keys.
- PR screenshot requests are routes inside one fenced `screenshots` block in the PR
body; the exact schema and local command are in README. Capture is an
unprivileged fork-safe workflow. Only the default-branch publisher may validate
its artifact, write `gh-pages`, or update the marker-owned bot comment. Never run
artifact code or publish files not declared by its validated manifest.
- `reminders/<id>/SKILL.md` is read at runtime, not emitted by `tsc`. Keep the root
catalogue beside `dist/` in deployments. Per-message injection accepts an ID only;
the BFF resolves the body and appends the `<reminder name="id">` sentinel.
Expand Down Expand Up @@ -121,3 +127,38 @@ several decisions below.
must never touch raw OpenCode `Part` shapes — that mapping lives in exactly one place
(`client/lib/events.ts`), which is what made this migration a ~363-line adapter
rewrite instead of a full rebuild. **Keep that seam.**

## Automated PR screenshots

Request deterministic screenshots with one root-relative route per line in the PR body:

````md
```screenshots
/?directory=/tmp/mock-project
full:/sessions/ses_mock_done?directory=/tmp/mock-project
```
````

Blank lines and lines beginning with `#` are ignored. `full:` captures the full page;
ordinary routes use a 1280x800 viewport. Requests are limited to 10 known UI routes and
reject whitespace, controls, schemes, hosts, backslashes, malformed encoding, and path
traversal.

The read-only `pull_request` workflow runs the production SPA and BFF against only the
fixed Playwright OpenCode and forge mocks. A separate default-branch `workflow_run`
publisher treats the artifact as untrusted, validates its manifest and PNGs, writes only
`gh-pages:pr-screenshots/pr-<number>/`, and maintains one `<!-- pr-screenshots -->`
comment with public raw links and an artifact fallback. The close workflow uses
`pull_request_target` only for trusted GitHub API cleanup; it never checks out or runs PR
code. The publisher also requires the PR head repository to equal this repository and
binds the artifact to the workflow run SHA. Fork artifacts may be linked but their bytes
are never published. Never combine write permissions with execution of a fork checkout.

Fork capture is safe but may require a maintainer to approve the read-only Actions run.
If Actions are unavailable, capture locally and attach images manually. Local capture:

```bash
npm run screenshots:local
```

Output is written to the ignored `screenshot-output/` directory.
Loading
Loading