Run visual tests only on relevant pull requests
Run visual tests only on pull requests that can change the UI, without missing a change - a path filter, or a filter action that keeps a required check green.
Most pull requests to an app touch backend code, config, or docs that cannot move a pixel. Rendering the visual suite on those is wasted CI time - and for the archive-replay paths (Playwright and Vitest), which always render in full, a path filter is the only cost lever you have. The goal is to skip those PRs without ever skipping one that could change the UI, because a missed render silently ships an unverified baseline.
Option 1: a plain path filter
The simplest filter is GitHub's built-in paths: on the trigger. List every directory the UI is built from; the workflow runs only when the PR touches one.
on:
pull_request:
paths:
- "src/components/**"
- "src/app/**"
- "packages/ui/**" # shared UI packages
- "**/*.css"
- ".github/workflows/visual.yml" # the workflow itselfpaths: skips the whole job, so it never reports a status. If your UI Verify check is a required status check, a PR that touches none of these paths leaves it stuck on "Expected" and blocks the merge - the same wedge a fork PR hits. Use Option 2 whenever the check is required.Option 2: a filter action that keeps a required check green
To gate the *work* but still report the *check*, split it in two: a first job computes whether UI files changed with `dorny/paths-filter`, and the upload job always runs but guards each step on that result. On a backend-only PR every step is skipped, the job still finishes green, and the required check reports success - no wedge.
on:
push:
branches: [main] # advance the baseline as PRs merge
pull_request:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.filter.outputs.ui }}
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
ui:
- 'src/components/**'
- 'src/app/**'
- 'packages/ui/**'
- '**/*.css'
- '.github/workflows/visual.yml'
visual: # always runs, so its check is always reported
needs: changes
runs-on: ubuntu-latest
steps:
- if: ${{ needs.changes.outputs.ui == 'true' }}
uses: actions/checkout@v7
with: { fetch-depth: 0 }
- if: ${{ needs.changes.outputs.ui == 'true' }}
run: npm ci
- if: ${{ needs.changes.outputs.ui == 'true' }}
run: npx playwright install --with-deps
- if: ${{ needs.changes.outputs.ui == 'true' }}
run: npx vitest run
- if: ${{ needs.changes.outputs.ui == 'true' }}
run: |
npx -y uiverify@1.0.2 upload --static-dir ./uiverify-archive \
${{ github.ref == 'refs/heads/main' && '--auto-accept-changes' || '' }}
env:
UIVERIFY_API_KEY: ${{ secrets.UIVERIFY_API_KEY }}Point branch protection at the visual job. Because it always runs, the check is always present; only its steps are conditional. On main the upload adds --auto-accept-changes, so the post-merge render becomes the baseline every new PR diffs against.
Getting the globs right - this is where changes slip through
The filter is only as good as its list, and its failure mode is silent. Two rules keep it honest. Include the workflow file itself so a change to the filter re-runs and re-baselines. And remember the build closure is wider than your components directory: shared design-system packages, global CSS, a theme or tokens file, and often a lib/ or utils/ folder a component imports all change what renders.
Do not exclude a folder just because it is mostly non-UI
The tempting mistake is to exclude a mixed folder - say a lib/ that holds both a cn() class-name helper the UI imports and unrelated database or AI code - because it looks mostly like backend. Don't. If any file in it feeds a rendered component, dropping it means a real UI change slips through and ships an unseen baseline. Keep the folder in the filter.
If that over-inclusion runs the suite too often, the fix is more granularity, not a blunt exclude: separate the folder so UI-facing code lives on its own path - move the cn helper into src/lib/ui/, or a small ui-utils package - then filter on that narrower path. Separate first, then narrow. You never trade coverage for speed.
Set up visual testing
From no visual tests to a green check on every pull request.
Visual testing for agents
UI Verify captures your UI on every pull request and an AI judge tells an intended change from a real regression. See how it works.
Get started