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',
});
});
- Wait before you capture. page.screenshot() takes the picture at once. An assertion on something visible makes sure the page has rendered.
- Turn animations off. page.screenshot() lets them run by default, unlike toHaveScreenshot. A half-finished transition makes every capture slightly different.
- Use lowercase paths. Each part of the path must be lowercase letters, digits, ., _ or -. Dashboard/Home.png fails the push and names itself. See URLs and asset names.
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
| Line | Why 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
- Screenery stores the screenshots under screenshots/ as a new version, and @pr-12 moves to it.
- Screenery grades each PNG against the version it replaces. That is the screenshot on the branch the pull request merges into, or @latest.
- 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.
- 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 artifact | Screenery 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. | , 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:

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
- Playwright visual testing: toHaveScreenshot options, why goldens break across machines, and baselines outside git.
- playwright --update-snapshots: updating goldens without the wrong-OS trap.
- GitHub Action reference: every input and output, and pinning.