qwen-code/.github/workflows/web-shell-visuals.yml
Shaojin Wen 96225b03c4
ci(web-shell): stop visual previews firing on SDK-only PRs (#6959)
The visual-preview capture workflow triggered on
packages/sdk-typescript/src/**, but the previews render against a mock
daemon — the SDK's transport/client layer is stubbed at the network
boundary and its changes can't move a pixel in the rendered scenarios.
So #6911 (a pure-backend PR that only added a DaemonClient data-layer
method) still got the five canned screenshots re-posted as noise.

Drop the SDK trigger. The web-shell client imports no runtime code from
the SDK root and only type-imports DaemonClient, so no real UI coverage
is lost: genuine web-shell UI PRs (#6881, #6951) still trigger via
packages/web-shell/client/**. Confirmed by simulating the path predicate
against each PR's changed-file list.

Co-authored-by: wenshao <wenshao@example.com>
2026-07-15 12:13:43 +00:00

193 lines
9.1 KiB
YAML

name: 'Web-shell Visuals'
# Auto-capture web-shell screenshots (light + dark) and short flow recordings
# for PRs that touch the web-shell UI, then hand the images to the companion
# `web-shell-visuals-publish.yml` (workflow_run) which posts them inline on the
# PR.
#
# Security model: this workflow BUILDS AND RENDERS untrusted PR code, so it runs
# on the `pull_request` trigger (fork PRs get a read-only token and NO secrets),
# on an ephemeral hosted runner, with `contents: read` and no secrets of its
# own. It produces only image/video bytes as an artifact. The privileged step
# that needs a write token — pushing the images and commenting on the PR — lives
# in the separate workflow_run workflow that never checks out PR code.
on:
pull_request:
branches:
- 'main'
- 'release/**'
# Matches the /tmux flow's web-shell surface: only the client UI, so
# doc/config-only PRs don't trigger a full build + render.
paths:
- 'packages/web-shell/client/**'
- 'packages/web-shell/package.json'
- 'packages/web-shell/vite.config.ts'
- 'packages/web-shell/playwright.visuals.config.ts'
# The visuals dev server aliases the shared web UI library into the
# rendered bundle (see the `resolve.alias` block in
# packages/web-shell/vite.config.ts), so a change to its components/hooks
# must also refresh the preview.
- 'packages/webui/src/**'
# NOTE: packages/sdk-typescript/src/** is deliberately NOT a trigger.
# It is aliased in too, but the visuals render against a *mock* daemon, so
# the SDK's transport/client layer is stubbed at the network boundary and
# its changes don't alter the canned scenarios — e.g. #6911 only added a
# DaemonClient data-layer method yet still re-posted identical screenshots
# on a pure-backend PR. The web-shell client imports no runtime code from
# the SDK root and only type-imports DaemonClient, so leaving the SDK out
# avoids spamming backend PRs. If an SDK-only, render-shaping change ever
# needs a preview, add the specific file (e.g. daemon/events.ts) here
# rather than the whole tree.
# The capture pipeline itself, so a workflow-only change is exercised.
- '.github/workflows/web-shell-visuals.yml'
permissions:
contents: 'read'
concurrency:
group: '${{ github.workflow }}-${{ github.event.pull_request.number }}'
cancel-in-progress: true
defaults:
run:
shell: 'bash'
jobs:
capture:
name: 'Capture web-shell visuals (ubuntu-latest, Node 22.x)'
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 20
steps:
- name: 'Checkout PR head'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.pull_request.head.sha }}'
fetch-depth: 1
persist-credentials: false
- name: 'Set up Node.js 22.x'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: 'npm ci --prefer-offline --no-audit --progress=false'
- name: 'Install Playwright Chromium'
run: 'npx playwright install --with-deps chromium'
- name: 'Choose web-shell Playwright port'
run: |-
port="$(node -e "const net=require('node:net');const server=net.createServer();server.listen(0,'127.0.0.1',()=>{console.log(server.address().port);server.close();});")"
echo "PLAYWRIGHT_PORT=${port}" >> "${GITHUB_ENV}"
echo "Using web-shell Playwright port ${port}"
- name: 'Capture screenshots and flow recordings'
env:
WEB_SHELL_VISUALS_OUTPUT_DIR: '${{ runner.temp }}/web-shell-visuals'
run: 'npm run test:e2e:visuals --workspace=packages/web-shell'
- name: 'Convert flow recordings to inline GIFs'
env:
OUT_DIR: '${{ runner.temp }}/web-shell-visuals'
run: |-
set -euo pipefail
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "::warning::ffmpeg not found on the runner; skipping GIF conversion (raw .webm is still uploaded)."
exit 0
fi
mkdir -p "${OUT_DIR}/gifs"
shopt -s nullglob
converted=0
for webm in "${OUT_DIR}"/video/*.webm; do
name="$(basename "${webm%.webm}")"
gif="${OUT_DIR}/gifs/${name}.gif"
# -ss 1 trims the ~1s blank while the page loads. Two-pass palette
# (generate + apply) keeps the GIF sharp at a fraction of naive size.
if err="$(ffmpeg -y -ss 1 -i "${webm}" \
-vf "fps=12,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3" \
"${gif}" 2>&1)"; then
echo "converted ${name}.webm -> gifs/${name}.gif ($(du -h "${gif}" | cut -f1))"
converted=$((converted + 1))
else
# Surface ffmpeg's own diagnostic (codec/container/filter error)
# instead of a bare "failed", so a future breakage is actionable.
detail="$(printf '%s' "${err}" | tr '\n' ' ' | tail -c 400)"
echo "::warning::ffmpeg failed to convert ${name}.webm; skipping its GIF. ${detail}"
rm -f "${gif}"
fi
done
echo "GIFs produced: ${converted}"
- name: 'Record PR metadata for the publish workflow'
env:
OUT_DIR: '${{ runner.temp }}/web-shell-visuals'
PR_NUMBER: '${{ github.event.pull_request.number }}'
run: |-
set -euo pipefail
# Ensure both dirs exist so the counts below are robust even when an
# upstream step produced none (e.g. no ffmpeg -> no gifs/). (The finds
# sit inside `echo "$(...)"`, so a missing dir wouldn't actually trip
# set -e — echo masks it — but create them for clarity all the same.)
mkdir -p "${OUT_DIR}/screenshots" "${OUT_DIR}/gifs"
# Bound artifact contents BEFORE upload: this job ran untrusted PR
# code, so drop oversized files and cap the count per directory —
# otherwise a hostile spec could bloat the published artifact (which
# the privileged publisher downloads) or the retained video artifact.
MAX_FILE_BYTES=$((6 * 1024 * 1024))
MAX_FILES=40
for d in screenshots gifs video; do
dir="${OUT_DIR}/${d}"
[ -d "${dir}" ] || continue
find "${dir}" -maxdepth 1 -type f -size "+${MAX_FILE_BYTES}c" \
-printf '::warning::dropping oversized artifact file %p\n' -delete || true
find "${dir}" -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort \
| tail -n "+$((MAX_FILES + 1))" \
| while IFS= read -r extra; do
echo "::warning::dropping excess artifact file ${d}/${extra}"
rm -f "${dir}/${extra}"
done
done
# PR number for the workflow_run publish job (which validates it).
# The head SHA is intentionally NOT shipped in the artifact: the
# publish job binds to the authenticated github.event.workflow_run
# .head_sha, and an artifact-sourced SHA would be untrusted.
printf '%s\n' "${PR_NUMBER}" > "${OUT_DIR}/pr.txt"
echo "Screenshots: $(find "${OUT_DIR}/screenshots" -name '*.png' | wc -l | tr -d ' ')"
echo "GIFs: $(find "${OUT_DIR}/gifs" -name '*.gif' | wc -l | tr -d ' ')"
# The privileged publish workflow downloads THIS artifact, so keep the raw
# videos out of it: an untrusted PR could drop a multi-GB file under
# video/ and exhaust the publisher's bandwidth/disk/timeout. Screenshots
# and GIFs (which the publisher hosts) are size-capped again on that side.
- name: 'Upload web-shell visuals artifact (published)'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'web-shell-visuals'
path: |-
${{ runner.temp }}/web-shell-visuals/screenshots
${{ runner.temp }}/web-shell-visuals/gifs
${{ runner.temp }}/web-shell-visuals/pr.txt
if-no-files-found: 'warn'
retention-days: 7
# Raw recordings live in a SEPARATE artifact the publish workflow never
# downloads — they're only the "full-resolution recordings" link target.
- name: 'Upload raw flow recordings'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'web-shell-visuals-video'
path: '${{ runner.temp }}/web-shell-visuals/video'
if-no-files-found: 'ignore'
retention-days: 7