Screenery

Home / Guides / Playwright in GitHub Actions

Playwright screenshots in GitHub Actions

One workflow that runs your Playwright tests on every pull request, keeps the report for debugging, and publishes the screenshots at URLs people can open. It commits nothing to the repository.

The test that takes the screenshots

Write screenshots to a folder of their own, here screenshots/. The path you give each file becomes its published name:

// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';

test('dashboard', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await page.screenshot({
    path: 'screenshots/dashboard/home.png',
    fullPage: true,
    animations: 'disabled',
  });
});

Why not Playwright's test-results/? It is Playwright's output folder. It holds traces, videos and failure images from retried tests. Screenery publishes every image and video in the folder you push, so a dedicated folder publishes only what you meant.

The workflow

This assumes a Screenery project created with the GitHub App on this repository. If you have not made one, start with Getting started. Save this as .github/workflows/screenshots.yml:

name: screenshots
on:
  push:
    branches: [main]
  pull_request:

jobs:
  screenshots:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
      - uses: screenery/push@v1
        with:
          project: acme/dashboard
          path: screenshots

Replace acme/dashboard with your project, as {org}/{project}. The steps up to the artifact upload follow Playwright's own CI setup, checked October 2026. The last step is the only one Screenery adds.

What each part does

LineWhy it is there
push: branches: [main] A build on the default branch moves @latest, the URL your README uses.
pull_request: Each pull request publishes to its own channel, @pr-12, and gets a preview comment.
id-token: write Required. The job asks GitHub for an OIDC token, and Screenery uses it to know which repository is publishing. There is no Screenery secret to store.
contents: read Listing permissions turns off the ones you leave out. This keeps the checkout working.
pull-requests: write Lets the job post the preview comment, when the job is the one that posts it. The Action passes the job's own token, so there is no env: block.
npx playwright install --with-deps Downloads the browsers and the system libraries they need on the runner. Add chromium to install one browser and save time.
if: ${{ !cancelled() }} Uploads the HTML report even when tests fail, which is when you need it.
screenery/push@v1 Has no if:, so it runs only when every test passed. A failing run publishes nothing.

The Action installs the screenery CLI from npm and runs screenery push. The CLI trades the OIDC token for a publish credential that lasts five minutes. Every input is on the GitHub Action reference.

What happens on a pull request

  1. Screenery stores the screenshots under screenshots/ as a new version, and @pr-12 moves to it.
  2. Screenery grades each PNG against the version it replaces. That is the screenshot on the branch the pull request merges into, or @latest.
  3. One comment on the pull request counts what changed, was added or stayed the same. It shows up to four screenshots, changed ones first, and links to the full gallery. A second push edits the same comment. See Pull-request comments.
  4. When the pull request merges, the build on main moves @latest. A pull request never moves it. See How @latest moves.

A pull request from a fork gets no OIDC token, so the step fails with a message saying that this is expected. To skip it there instead:

- uses: screenery/push@v1
  if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
  with:
    project: acme/dashboard
    path: screenshots

Artifacts or published URLs

actions/upload-artifact is the usual way to keep screenshots from a run. It is the right tool for the Playwright report. It is a poor home for screenshots people need to look at. The artifact facts below are from the upload-artifact README, checked October 2026.

Workflow artifactScreenery URL
What you get A zip per run, unless you upload one file with archive: false. One URL per screenshot.
Who can open it People signed in to GitHub with access to the repository. Anyone, for a public screenshot. Unlisted and private screenshots need a signed URL or a read token. See Visibility and credentials.
How long 90 days by default. The repository settings set the maximum. @latest follows the default branch. A version URL, @v_…, never changes.
In a README or doc Not as an image. ![Dashboard](…/dashboard/home@latest.png), updated by the next default-branch build.
On the pull request A link on the run's summary page. Images in the preview comment.

Use both. Keep the report, traces and failure images as an artifact for debugging. Publish the screenshots that people review and embed. The README line for the screenshot above is:

![Dashboard](https://cdn.screenery.dev/acme/dashboard/dashboard/home@latest.png)

The push log prints a snippet like that for every public screenshot. See Screenshot URLs.

Goldens, gates and shards

You already use toHaveScreenshot

Keep it. Publish the goldens it accepted instead of a separate folder. Point path at your test folder, and Screenery publishes only the -snapshots folders in it:

- uses: screenery/push@v1
  with:
    project: acme/dashboard
    path: tests
    playwright-snapshots: true

Your goldens must then come from Linux, the runner's OS, or the test fails before the push. See playwright --update-snapshots and Playwright goldens and Argos folders.

Fail the job on a visual change

Two options. diff-baseline compares with a committed folder before anything is published, and a change fails the job. Or, with no committed folder, set failAbove in screenery.config.json to fail when a screenshot changed by more than a percentage of its pixels from the version it replaces. See The visual-diff gate and Baselines outside git.

Sharded tests

By default a push replaces what its channels hold and removes the names it did not push. Jobs in one workflow run do not remove each other's names on their first attempt. A re-run of one failed shard, or several workflows feeding one project, can. Give those pushes partial: true, or set the project to additive on the console's Access screen. See Several pushes to one project.

Next