Automated visual diffs with Claude Code, Playwright MCP, and GitHub Actions

How to automate visual diffs for PRs with Claude Code, Playwright, and GitHub Actions

Here's how you can automate visual diffs for every PR using Claude Code, the Playwright MCP, and GitHub Actions. This workflow will help you get before/after screenshots posted as PR comments.

by on

What’s a visual diff?

Similar to a regular git diff, a visual diff shows what changes were made between two branches. You might want to see a visual diff of a frontend change in a PR, so a code reviewer can briefly see what changes you made, and what the same route looks like currently.

What components do you need to capture one?

In this implementation, we’re generating visual diffs via GitHub Actions. Each before/after comparison will get posted as a comment on its respective GitHub PR.

To get these set up, you’ll need:

  • A Claude Pro or Max subscription, or API key
  • A GitHub repo (in which you’re an admin)
  • A GitHub API key
  • Remote PR environments (try Shipyard and its GitHub Action)

How to automate visual diffs for every PR

We built a sample workflow that captures what changed visually in a PR. Here’s a high-level overview of this pipeline:

Visual git diffs workflow

Using the git diff

We’re going to start with the actual git diff as the foundation for our visual diff. This pipeline works best for atomic PRs since it captures a single before/after. It’s intended for code changes that touch the frontend.

Claude Code is useful here, since it can determine the URL route from the git diff. You can also do this programmatically, but that may be a tougher implementation depending on how your app’s files are named and organized.

Where do you preview your branch(es)?

Getting a visual diff requires an environment that previews your new feature/code changes. Depending on the complexity of this feature, you can use Netlify or Vercel preview links. For changes that impact your whole stack (e.g. a React component, anything that touches your DB), you’ll need a full-stack ephemeral environment.

For simplicity, we used Shipyard in our implementation. Here’s how to set it up. You’ll want to store your Shipyard API token as a repository secret.

To get an accurate before/after of main vs. the PR branch, we’ll need an environment spun up for each one. Using the Shipyard GitHub Action, we can get the URL for the feature branch from an env var, which will be env.SHIPYARD_ENVIRONMENT_URL. The URL for the main branch will be constant, so you can store it as an env var in your repo variables and reference it in the workflow like vars.SHIPYARD_MAIN_URL.

Automating the screenshots

Once you have your URL routes and base environment URLs, you can use a browser automation tool to visit the environment, click the right buttons/enter inputs, and take a screenshot. The Playwright MCP makes this really straightforward with Claude Code.

We’ll install and configure the Playwright MCP earlier on in our pipeline, then add a prompt for Claude to use it to visit our feature on the PR and main environment, and take screenshots of each.

The image hosting problem

You’ll want to get your before/after images rendered in Markdown for the visual diff comment, and there are a few different options you have for storing these images. An S3 bucket is an elegant choice. Storing the image in GitHub Packages won’t give you a link that enables rendering.

For simplicity, we went with a separate branch on the repo, named screenshots (this way we weren’t committing images to the feature branches).

Using the GitHub CLI in the Actions workflow, we can check out the screenshots branch, commit the images to a directory there, and use that URL to reference the image when we generate a Markdown comment.

Posting repo comments

At this stage, we’ll create a HTML template, and use variables to populate the links and images. We’ll use the GitHub CLI to post the comment to the current PR. In this layout, we’re adding each image to a column in a table.

BODY=$(cat <<EOF

<table>
	<tr>
		<th>${{ github.base_ref }} (base)</th>
		<th>${{ github.head_ref }} (feature)</th>
	</tr>
	<tr>
		<td><img src="$BASE_URL" width="50%"/></td>
		<td><img src="$FEATURE_URL" width="50%"/></td>
	</tr>
	<tr>
		<td><a href="$BASE_ENV_URL">$BASE_ENV_URL</a></td>
		<td><a href="$PR_URL">$PR_URL</a></td>
	</tr>
</table>
EOF
)

Limiting which PRs we generate visual diffs for

It doesn’t make sense to run this workflow on every single PR. Backend-only code changes, patches, or other non-frontend-heavy PRs can be excluded.

We can set another trigger condition in our Action workflow. One option is making a PR label for Visual diff and using the label as a trigger.

on:
  workflow_dispatch:
  pull_request:
    types: [opened, synchronize, labeled]

And setting the label’s name as the condition:

jobs:
  analyze-diff:
    if: |
      github.event_name == 'workflow_dispatch' ||
      contains(github.event.pull_request.labels.*.name, 'visual diff')

The final workflow

This is the resulting workflow of the components above. Test it out by opening a PR with changes to a frontend component and giving it the label visual diff.

name: Visual diff
on:
  workflow_dispatch:
  pull_request:
    types: [opened, synchronize, labeled]

jobs:
  analyze-diff:
    if: |
      github.event_name == 'workflow_dispatch' ||
      contains(github.event.pull_request.labels.*.name, 'visual diff')
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: write
      id-token: write

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.GH_TOKEN }}

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install Playwright browsers
        run: npx playwright install chromium --with-deps

      - name: Integrate Shipyard
        uses: shipyard/shipyard-action@1.0.0
        with:
          api-token: ${{ secrets.SHIPYARD_API_TOKEN }}
          timeout-minutes: 10

      - name: Write MCP config
        run: |
          mkdir -p /tmp/mcp
          cat > /tmp/mcp/config.json << 'EOF'
          {
            "mcpServers": {
              "playwright": {
                "command": "npx",
                "args": ["@playwright/mcp@latest", "--headless"]
              }
            }
          }
          EOF

      - uses: anthropics/claude-code-action@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
          SHIPYARD_ENVIRONMENT_URL: ${{ env.SHIPYARD_ENVIRONMENT_URL }}
          SHIPYARD_MAIN_URL: ${{ vars.SHIPYARD_MAIN_URL }}
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          claude_args: |
            --max-turns 20
            --allowedTools "Bash" "mcp__playwright__*"
            --mcp-config /tmp/mcp/config.json
          prompt: |
            You are a visual QA agent. Follow these steps:

            1. Run: git diff origin/${{ github.base_ref }}...HEAD --name-only
               Identify the most likely frontend route that changed.

            2. Take a screenshot of the feature branch environment:
               URL: ${{ env.SHIPYARD_ENVIRONMENT_URL }}/<inferred-route>
               Save to: /tmp/screenshots/pr-${{ github.event.pull_request.number }}-feature.png

            3. Take a screenshot of the main environment at the same route:
               URL: ${{ vars.SHIPYARD_MAIN_URL }}/<inferred-route>
               Save to: /tmp/screenshots/pr-${{ github.event.pull_request.number }}-base.png

            Output the inferred route on the last line in this exact format:
            ROUTE=/your-route

      - name: Commit screenshots to screenshots branch
        id: upload
        run: |
          PR_NUM="${{ github.event.pull_request.number }}"
          FEATURE_PATH="/tmp/screenshots/pr-${PR_NUM}-feature.png"
          BASE_PATH="/tmp/screenshots/pr-${PR_NUM}-base.png"
          FEATURE_FILE="pr-${PR_NUM}-feature.png"
          BASE_FILE="pr-${PR_NUM}-base.png"

          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git remote set-url origin https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${{ github.repository }}.git

          git fetch origin screenshots
          git checkout screenshots

          mkdir -p screenshots
          cp $FEATURE_PATH screenshots/$FEATURE_FILE
          cp $BASE_PATH screenshots/$BASE_FILE
          git add screenshots/$FEATURE_FILE screenshots/$BASE_FILE
          git commit -m "screenshots: PR #${PR_NUM}"
          git push origin screenshots

          FEATURE_URL="https://github.com/${{ github.repository }}/blob/screenshots/screenshots/$FEATURE_FILE?raw=true"
          BASE_URL="https://github.com/${{ github.repository }}/blob/screenshots/screenshots/$BASE_FILE?raw=true"

          echo "feature_url=$FEATURE_URL" >> $GITHUB_OUTPUT
          echo "base_url=$BASE_URL" >> $GITHUB_OUTPUT

      - name: Post PR comment with screenshots
        run: |
          FEATURE_URL="${{ steps.upload.outputs.feature_url }}"
          BASE_URL="${{ steps.upload.outputs.base_url }}"

          BODY=$(cat <<EOF
          ## Visual diff for this PR

          <table>
            <tr>
              <th>${{ github.base_ref }} (main)</th>
              <th>${{ github.head_ref }} (feature)</th>
            </tr>
            <tr>
              <td><img src="$BASE_URL" width="100%"/></td>
              <td><img src="$FEATURE_URL" width="100%"/></td>
            </tr>
            <tr>
              <td><a href="${{ vars.SHIPYARD_MAIN_URL }}">${{ vars.SHIPYARD_MAIN_URL }}</a></td>
              <td><a href="${{ env.SHIPYARD_ENVIRONMENT_URL }}">${{ env.SHIPYARD_ENVIRONMENT_URL }}</a></td>
            </tr>
          </table>
          EOF
          )

          gh pr comment ${{ github.event.pull_request.number }} \
            --repo ${{ github.repository }} \
            --body "$BODY"
        env:
          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}

Do it yourself!

Want to get the same workflow set up on your PRs? As long as you have a Claude subscription, you can do this for your repo pretty easily. Kick off a free 30-day Shipyard trial to get full-stack previews of every branch, which you can use for this workflow, as well as testing, QA, and stakeholder review.

Good luck, happy building!

Try Shipyard today

Get isolated, full-stack ephemeral environments on every PR.

About Shipyard

Shipyard manages the lifecycle of ephemeral environments for developers and their agents.

Get full-stack review environments on every pull request for dev, product, agentic, and QA workflows.

Stay connected

Latest Articles

Shipyard Newsletter
Stay in the (inner) loop

Hear about the latest and greatest in cloud native, agents, engineering, and more when you sign up for our monthly newsletter.