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

# The per-PR gallery (webviews/src/gallery): for the entries a PR touches
# (webviews/scripts/gallery/touched.ts), render entry x variant x theme at the PR's merge-base and
# at its head, the head twice (a state that differs from itself is nondeterministic, never a PR
# change), pixel-diff each pair (scripts/gallery-matrix/compare.ts) and post one sticky comment
# with the changed states and their before/after thumbnails.
#
# `render` runs the PR's code with a read-only token and no secrets, on Linux (never a Mac mini).
# `publish` holds the write token and runs only the base branch's scripts: it rebuilds the comment
# from the render's outcomes.json, uploads the comment's thumbnails to the pr-media branch and
# updates the comment. Same-repository PRs only.
on:
pull_request:
branches: [feat-cmux-next]
types: [opened, synchronize, reopened]
paths:
- "webviews/**"
- "scripts/gallery-matrix/**"
- "scripts/cmux-next/build-agent-pane-web.sh"
- "bun.lock"
- ".github/workflows/gallery-pr.yml"

permissions:
contents: read

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

env:
# The matrix: both default themes (dark and light), both engines, the pane at its normal width.
GALLERY_THEMES: pair
GALLERY_ENGINES: chromium,webkit

jobs:
render:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 30
outputs:
touched: ${{ steps.touched.outputs.count }}
base_sha: ${{ steps.base.outputs.sha }}
steps:
- name: Checkout the PR head
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false

- name: Find the merge-base and the changed files
id: base
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
sha=$(git merge-base "origin/$BASE_REF" HEAD)
echo "sha=$sha" >> "$GITHUB_OUTPUT"
git diff --name-only "$sha" HEAD > "$RUNNER_TEMP/changed.txt"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checkout fetches every repository branch

Medium Severity

The render job checks out the head SHA with fetch-depth: 0 so git merge-base origin/$BASE_REF HEAD can run. On this repo that fetch pulls every branch (thousands) and has already been measured at several minutes, which competes with two gallery builds and three Playwright passes inside a 30-minute timeout.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d7795dc. Configure here.

echo "merge-base $sha; $(wc -l < "$RUNNER_TEMP/changed.txt") changed files"

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.2"

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.11.1"

- name: Install webviews dependencies
working-directory: webviews
run: bun install --frozen-lockfile

- name: Test the diff
working-directory: scripts/gallery-matrix
run: |
set -euo pipefail
bun install --frozen-lockfile
bun test

- name: Find the touched entries
id: touched
working-directory: webviews
run: |
set -euo pipefail
bun scripts/gallery/touched.ts --changed "$RUNNER_TEMP/changed.txt" --out "$RUNNER_TEMP/touched.json"
ids=$(jq -r '.entries | join(",")' "$RUNNER_TEMP/touched.json")
echo "ids=$ids" >> "$GITHUB_OUTPUT"
echo "count=$(jq '.entries | length' "$RUNNER_TEMP/touched.json")" >> "$GITHUB_OUTPUT"
jq -r '.reasons | to_entries[] | "\(.key): \(.value | join(", "))"' "$RUNNER_TEMP/touched.json"

- name: Write the head manifest and build the head gallery
if: ${{ steps.touched.outputs.count != '0' }}
working-directory: webviews
env:
IDS: ${{ steps.touched.outputs.ids }}
run: |
set -euo pipefail
bun scripts/gallery/manifest.ts --entries "$IDS" --themes "$GALLERY_THEMES" --frame component --out "$RUNNER_TEMP/head.json"
CMUX_GALLERY_OUT_DIR="$RUNNER_TEMP/gallery-head" bun run gallery:build

# The merge-base renders with its own gallery code: a change to the gallery shows as a change.
# A base without the gallery (or without an entry) renders nothing for it: the head's states are new.
- name: Write the base manifest and build the base gallery
if: ${{ steps.touched.outputs.count != '0' }}
env:
IDS: ${{ steps.touched.outputs.ids }}
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
set -euo pipefail
git worktree add --detach "$RUNNER_TEMP/base" "$BASE_SHA"
cd "$RUNNER_TEMP/base/webviews"
if [ ! -f vite.config.gallery.ts ] || [ ! -f scripts/gallery/manifest.ts ]; then
echo "[]" > "$RUNNER_TEMP/base.json"
echo "The merge-base has no gallery; every state is new."
exit 0
fi
bun install --frozen-lockfile
bun scripts/gallery/manifest.ts --entries "$IDS" --themes "$GALLERY_THEMES" --frame component --out "$RUNNER_TEMP/base.json"
CMUX_GALLERY_OUT_DIR="$RUNNER_TEMP/gallery-base" bun run gallery:build

- name: Render the base, the head and the head again
if: ${{ steps.touched.outputs.count != '0' }}
working-directory: scripts/gallery-matrix
env:
CMUX_GALLERY_IN_VM: "1"
run: |
set -euo pipefail
./node_modules/.bin/playwright install --with-deps --only-shell chromium webkit
# Every case either side has: a variant only the base has is a removed state.
jq -s '.[0] as $head | $head + [.[1][] | select(.id as $id | any($head[]; .id == $id) | not)]' \
"$RUNNER_TEMP/head.json" "$RUNNER_TEMP/base.json" > "$RUNNER_TEMP/cases.json"
echo "$(jq length "$RUNNER_TEMP/cases.json") cases x $GALLERY_ENGINES"
render() {
# A stage that fails to mount makes the runner exit 1; compare.ts reports it, so only a
# run that wrote no results.json fails here.
bun runner.ts --manifest "$RUNNER_TEMP/cases.json" --gallery-dir "$1" --output-dir "$2" \
--engines "$GALLERY_ENGINES" || true
test -f "$2/results.json"
}
if [ -d "$RUNNER_TEMP/gallery-base" ]; then render "$RUNNER_TEMP/gallery-base" "$RUNNER_TEMP/runs/base"; fi
render "$RUNNER_TEMP/gallery-head" "$RUNNER_TEMP/runs/head"
render "$RUNNER_TEMP/gallery-head" "$RUNNER_TEMP/runs/repeat"

- name: Compare
if: ${{ steps.touched.outputs.count != '0' }}
working-directory: scripts/gallery-matrix
env:
PR: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ steps.base.outputs.sha }}
ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/runs/base"
bun pr.ts --base "$RUNNER_TEMP/runs/base" --head "$RUNNER_TEMP/runs/head" --repeat "$RUNNER_TEMP/runs/repeat" \
--base-manifest "$RUNNER_TEMP/base.json" --head-manifest "$RUNNER_TEMP/head.json" \
--out "$RUNNER_TEMP/gallery-pr/diff" --pr "$PR" --head-sha "$HEAD_SHA" --base-sha "$BASE_SHA" \
--artifact-url "$ARTIFACT_URL"
cp "$RUNNER_TEMP/touched.json" "$RUNNER_TEMP/gallery-pr/"
cp -r "$RUNNER_TEMP/gallery-head" "$RUNNER_TEMP/gallery-pr/gallery"
cp -r "$RUNNER_TEMP/runs/head" "$RUNNER_TEMP/gallery-pr/matrix"
cat "$RUNNER_TEMP/gallery-pr/diff/comment.md" >> "$GITHUB_STEP_SUMMARY"

- name: Upload the diff, the gallery and the matrix
if: ${{ steps.touched.outputs.count != '0' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-pr
path: ${{ runner.temp }}/gallery-pr
retention-days: 14

publish:
needs: render
if: ${{ needs.render.result == 'success' && needs.render.outputs.touched != '0' }}
runs-on: ubuntu-24.04 # github-hosted-required: trusted scripts only, with the contents and pull-request write token
timeout-minutes: 10
# contents: write reaches the pr-media branch; the protected branches have their rulesets.
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout the publisher from the base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.ref }}
persist-credentials: false
sparse-checkout: |
scripts/gallery-matrix
scripts/pr-media.py
sparse-checkout-cone-mode: false

- name: Check the base branch has the publisher
id: publisher
run: |
if [ -f scripts/gallery-matrix/pr.ts ]; then echo "ok=true" >> "$GITHUB_OUTPUT"
else echo "The base branch has no gallery publisher yet; the comment is in the render job's summary."; fi

- name: Setup Bun
if: ${{ steps.publisher.outputs.ok == 'true' }}
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.2"

- name: Download the render
if: ${{ steps.publisher.outputs.ok == 'true' }}
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: gallery-pr
path: ${{ runner.temp }}/render

- name: Upload the thumbnails and update the comment
if: ${{ steps.publisher.outputs.ok == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ needs.render.outputs.base_sha }}
ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts
run: |
set -euo pipefail
prefix="gallery-${HEAD_SHA:0:11}-"
thumb_base="https://raw.githubusercontent.com/$REPOSITORY/pr-media/$PR/$prefix"
report="$RUNNER_TEMP/report"
(cd scripts/gallery-matrix && bun install --frozen-lockfile --ignore-scripts && \
bun pr.ts --outcomes "$RUNNER_TEMP/render/diff/outcomes.json" --out "$report" --pr "$PR" \
--head-sha "$HEAD_SHA" --base-sha "$BASE_SHA" --artifact-url "$ARTIFACT_URL" --thumb-base "$thumb_base")
upload="$RUNNER_TEMP/upload"
mkdir -p "$upload"
while IFS= read -r key; do
# thumbs.txt names plain file names (report.ts SAFE_KEY); take only PNGs from the render.
src="$RUNNER_TEMP/render/diff/thumbs/$key"
if [ -f "$src" ] && [ "$(head -c 8 "$src" | od -An -tx1 | tr -d ' \n')" = "89504e470d0a1a0a" ]; then
cp "$src" "$upload/$prefix$key"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thumbnail URLs miss sanitized filenames

High Severity

Comment thumbnail URLs use the raw outcome key, but pr-media.py always runs sanitize on the uploaded name and collapses -- to -. Manifest case ids join parts with --, so every uploaded PNG lands under a different name than the comment's img src and the sticky before/after images 404.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d7795dc. Configure here.

fi
done < "$report/thumbs.txt"
if compgen -G "$upload/*" > /dev/null; then
python3 scripts/pr-media.py --pr "$PR" --repo "$REPOSITORY" --no-gif --force "$upload"/* > /dev/null
fi
body="$report/comment.md"
marker='<!-- cmux-gallery-pr -->'
existing=$(gh api --paginate "repos/$REPOSITORY/issues/$PR/comments" \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id" | head -1)
if [ -n "$existing" ]; then
gh api -X PATCH "repos/$REPOSITORY/issues/comments/$existing" -F "body=@$body" > /dev/null
else
gh api -X POST "repos/$REPOSITORY/issues/$PR/comments" -F "body=@$body" > /dev/null
fi
14 changes: 14 additions & 0 deletions scripts/gallery-matrix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ bun runner.ts --manifest manifest.json --gallery-dir dist/gallery --output-dir .

The Freestyle path installs the declared Bun dependencies and Playwright browsers in each VM, runs each shard in parallel, records every exact VM id in `.cmux-scratch/pane-protocol/gallery/freestyle-ledger.json`, and PAUSES only those recorded ids in a `finally` block (also on failure and signals). It never lists the account to decide what to pause or delete. A run refuses to start while the ledger holds ids an earlier run neither paused nor deleted; `--freestyle-cleanup` pauses exactly those. Each VM also pauses itself after 300 s of network idleness. The key is read from its file and only sent to the Freestyle API.

## Per-PR diff

`.github/workflows/gallery-pr.yml` runs on each PR to feat-cmux-next that touches the webviews. `webviews/scripts/gallery/touched.ts` picks the entries the change reaches: an entry's own file, anything its `covers` import, and every entry of a host whose stylesheet changed. A gallery or build-config change picks every entry. The job renders those entries x variants x both default themes x both engines on Linux at the merge-base and at the head, and at the head a second time.

`pr.ts` (with `compare.ts` and `report.ts`) compares each state and writes `diff/`:

- `index.html`: changed states first, each as a highlight overlay (changed regions boxed), a before/after slider and an onion skin; then new, removed, broken (the head stage did not mount) and nondeterministic states; unchanged states folded.
- `comment.md`: the sticky PR comment ("7 states changed: agent-pane.composer/streaming, ...") with before/after thumbnails of the changed regions.
- `summary.json`: the same summary for the team feed's PR card.

A state whose head render differs from a second head render is nondeterministic (a clock or fixture leak). It is listed apart and never counted as a PR change.

The render job runs the PR's code with no secrets. The publish job runs only the base branch's scripts: it rebuilds the comment from `outcomes.json`, puts the comment's thumbnails on the pr-media branch and updates the comment. The diff page, the PR's gallery and the matrix are in the run's `gallery-pr` artifact.

Tests:

```sh
Expand Down
Loading
Loading