stableFor developers

GitHub Actions

This guide adds pixmoat to a GitHub repository. Every pull request uploads screenshots to pixmoat, and its workflow fails until the changes are reviewed.

What’s not available on GitHub

pixmoat has no GitHub integration beyond uploads from Actions:

  • It doesn’t post commit statuses or checks. The only result on the pull request is this workflow’s own pass or fail.
  • It doesn’t comment on pull requests.
  • It doesn’t receive merge events, so approved baselines aren’t promoted when a pull request is merged. See After you merge.

The support matrix lists what’s supported on each Git host.

Before you start

You need a pixmoat project, a project access token, and Playwright tests that capture screenshots with the pixmoat fixture. Your first build covers the project and token, and Connect Playwright covers playwright.config.ts, global-setup.ts and global-teardown.ts. The workflow needs all three files.

Store the token

In GitHub, open the repository’s Settings > Secrets and variables > Actions and select New repository secret. Name it PIXMOAT_TOKEN and paste the project access token from pixmoat (Settings, then the Tokens tab). The token starts with pxg_.

GitHub doesn’t pass repository secrets to workflows triggered by pull requests from forks. On those pull requests, the capture step fails with:

Missing required pixmoat environment variables: PIXMOAT_TOKEN

Add the workflow

Save this as .github/workflows/visual.yml. Set PIXMOAT_PROJECT to your project’s slug, and replace main if your default branch has another name.

# pixmoat visual regression in GitHub Actions: capture on pull requests and
# on the default branch, and gate pull requests on the build verdict.
#
# pixmoat doesn't post GitHub commit statuses, checks or pull request
# comments. The result shows up only as this workflow's pass or fail, and the
# review happens in the pixmoat app.
#
# File location: .github/workflows/visual.yml
# Install:       npm install --save-dev @playwright/test @s4labs/pixmoat-playwright,
#                plus playwright.config.ts, global-setup.ts and
#                global-teardown.ts from the Playwright example.
# Variables:     a repository secret PIXMOAT_TOKEN holding the project access
#                token from pixmoat (the project's Settings > Tokens tab). Set
#                PIXMOAT_PROJECT below to your project slug.
#                Replace main below if your default branch has another name.
# Command:       push to the default branch, then open a pull request.
# Expected:      "Capture screenshots" uploads the screenshots and logs
#                "[pixmoat] Build finished: ...". On pull requests, "Gate on
#                the pixmoat verdict" prints "pixmoat verdict: PASS" and passes,
#                or prints REVIEW, REVIEW_NEW or FAIL and fails until the
#                changes are approved in pixmoat and the job is re-run.

name: Visual regression

on:
  pull_request:
  # Builds on the default branch give pull requests a baseline to compare with.
  push:
    branches: [main]

permissions:
  contents: read

env:
  PIXMOAT_URL: https://app.pixmoat.com
  PIXMOAT_PROJECT: my-project

jobs:
  visual:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - run: npx playwright install --with-deps chromium

      # On pull_request events GITHUB_REF_NAME is "<number>/merge" and
      # GITHUB_SHA is a temporary merge commit. GITHUB_HEAD_REF is the pull
      # request's branch, and the head SHA is the commit you pushed. On push
      # events GITHUB_HEAD_REF is empty, so the branch falls back to
      # GITHUB_REF_NAME and the commit to GITHUB_SHA.
      - name: Capture screenshots
        env:
          PIXMOAT_TOKEN: ${{ secrets.PIXMOAT_TOKEN }}
          PIXMOAT_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
          PIXMOAT_PR: ${{ github.event.pull_request.number }}
          PIXMOAT_CI_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: |
          export PIXMOAT_BRANCH="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
          npx playwright test

      # Reads the build verdict and fails unless it is PASS. wait=true waits
      # for the server to finish processing (up to 300 s), not for a human
      # review. global-setup.ts wrote PIXMOAT_BUILD_ID to pixmoat-build.env.
      - name: Gate on the pixmoat verdict
        if: github.event_name == 'pull_request'
        env:
          PIXMOAT_TOKEN: ${{ secrets.PIXMOAT_TOKEN }}
        run: |
          . ./pixmoat-build.env
          verdict=$(curl --fail --silent --show-error --max-time 330 \
            -H "Authorization: Bearer $PIXMOAT_TOKEN" \
            "$PIXMOAT_URL/api/builds/$PIXMOAT_BUILD_ID/agent-summary?wait=true&timeout=300" \
            | jq -r '.verdict')
          echo "pixmoat verdict: $verdict"
          test "$verdict" = "PASS"

The workflow runs in two cases:

  • Pushes to the default branch capture screenshots but don’t gate. These builds give pull requests a baseline to compare with. See Baselines and branches.
  • Pull requests capture screenshots, then gate on the build’s verdict.

Push the workflow to the default branch first. Its first build has only New screenshots. Open it in pixmoat, approve them, then select Finish review and Confirm. That sets the default branch’s baselines.

Branch and commit on pull requests

On a pull_request event, GitHub checks out a temporary merge commit, not the commit you pushed:

VariableValue on pull_request
GITHUB_SHAThe temporary merge commit of your branch into the base branch
GITHUB_REF_NAME<number>/merge
GITHUB_HEAD_REFYour pull request’s branch, for example feature/login
github.event.pull_request.head.shaThe last commit you pushed

pixmoat keeps baselines per branch, so PIXMOAT_BRANCH must be the pull request’s branch. With GITHUB_REF_NAME, every pull request would get its own <number>/merge branch. The example uses GITHUB_HEAD_REF, and falls back to GITHUB_REF_NAME on pushes, where GITHUB_HEAD_REF is empty.

PIXMOAT_COMMIT is the pull request’s head commit, so the build in pixmoat shows the commit you pushed. On pushes it falls back to GITHUB_SHA.

The gate step

finishBuild() never fails the test run, so the workflow gates in a separate step. global-setup.ts wrote the build ID to pixmoat-build.env, and the gate step reads the verdict for that build:

verdictMeaningStep
PASSNo pending changes, no rejectionsPasses
REVIEWChanged screenshots waiting for reviewFails
REVIEW_NEWOnly new screenshots waiting for reviewFails
FAILA screenshot was rejected, the build failed, or a baseline size mismatchFails

wait=true waits for pixmoat to finish processing the build, up to 300 seconds. It doesn’t wait for a human review, so a pull request with changes fails as soon as processing is done. If processing takes longer, pixmoat returns HTTP 408 and the step fails. Re-run the job.

The gate fails on pending changes whatever the project’s blocking mode is. If your project is in report-only mode, remove the gate step.

The verdict doesn’t check the approval policy. On GitHub there’s no commit status that waits for the policy, so a pull request can pass once every change is approved, even if the policy isn’t met yet.

Block merges

To require the review before merging, add a branch protection rule or ruleset for the default branch that requires the visual status check to pass. GitHub lists the check under the job’s name once the workflow has run on a pull request.

After a review

A failed job doesn’t rerun on its own:

  1. A reviewer opens the build in pixmoat, approves the changes, then selects Finish review and Confirm.
  2. In GitHub, open the pull request’s failed workflow run and select Re-run jobs.
  3. The job captures the screenshots again and compares them with the baselines you just approved. The gate prints pixmoat verdict: PASS and the check turns green.

Finish the review before you re-run. The re-run captures again for the same branch and commit, so pixmoat reopens the build and discards its screenshots and any decisions that weren’t finished.

If a reviewer rejected a change, the check keeps failing until you push a fix and its build is approved.

After you merge

pixmoat can’t receive merge events from GitHub, so merging doesn’t move baselines to the default branch. The push to the default branch after the merge starts a new build, and the screenshots you approved in the pull request show as Changed or New there. Approve them in that build to update the default branch.

To promote an approved pull request build without reviewing it again, a project admin can use manual promotion.

Troubleshooting

SymptomCause and fix
“Missing required pixmoat environment variables: PIXMOAT_TOKEN”The secret is missing, misnamed, or the pull request comes from a fork. See Store the token.
The gate step fails with HTTP 401The token is wrong or was revoked. Create a new one in pixmoat and update the secret.
The gate step fails with HTTP 408pixmoat took longer than 300 seconds to process the build. Re-run the job.
Every screenshot is New on each pull requestThe default branch has no approved baselines. Run the workflow on the default branch and approve its first build.
Every pull request shows up as a branch like 12/merge in pixmoatPIXMOAT_BRANCH is set from GITHUB_REF_NAME. Use GITHUB_HEAD_REF as in the example.
The check stays red after approvalThe job didn’t run again. Re-run it after you finish the review.