All docs
Recipes4 min readUpdated

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.

The two mistakes are not equal. An unnecessary run costs a few CI minutes. A missed run renders nothing, the check still passes green, and whatever the PR changed becomes the next baseline unseen - a silent regression every later branch inherits. So when a path is ambiguous, include it. Over-run on purpose; never under-run.

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.

.github/workflows/visual.yml
on:
  pull_request:
    paths:
      - "src/components/**"
      - "src/app/**"
      - "packages/ui/**"                 # shared UI packages
      - "**/*.css"
      - ".github/workflows/visual.yml"   # the workflow itself
One catch: paths: 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.

.github/workflows/visual.yml
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.

Once the workflow is wired, make the check block merges until changes are reviewed - see Make the visual check required.

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