Screenery

Docs / Start here

The screenery/push@v1 Action

One step after your tests. It publishes the screenshots they wrote to your Screenery project, with no secret to store.

Minimal workflow

This assumes a project created with the GitHub App on this repository. If you have not done that yet, start with Getting started.

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@v4
      # … install dependencies and run your Playwright tests …
      - uses: screenery/push@v1
        with:
          project: acme/dashboard
          path: test-results
LineWhy it is there
id-token: write Required. It lets the job ask GitHub for an OIDC token, which is how Screenery knows which repository is publishing.
contents: read Listing permissions turns off the ones you leave out. This keeps actions/checkout working.
pull-requests: write Lets the job post the preview comment on a pull request, when the job is the one that posts it. The Action passes the job's own token for you, so there is no env: block. See The pull-request comment.

The step runs in bash and installs the screenery CLI from npm, so the runner needs Node.js and npm. GitHub-hosted runners have both.

For a full Playwright workflow built on this step, see Playwright screenshots in GitHub Actions.

What it does

  1. Checks that the job has an OIDC token, and says so in the log if it does not.
  2. Runs screenery push from npm, at the version in the version input.
  3. The CLI trades the job's OIDC token for a publish credential that lives five minutes. Screenery checks the token against the repository's numeric id, so a renamed repository keeps working and a fork is refused.
  4. It uploads every screenshot under path and moves the channels the build belongs to: the branch's (@main), the pull request's (@pr-12), and @latest on the default branch. See How @latest moves.
  5. On a pull request, the preview comment is posted or updated, by Screenery or by the job. The step also writes Markdown you can reuse.

There is no token input on purpose. (The github-token input is GitHub's token, used only to post the comment; it never reaches Screenery.) If you cannot use OIDC, set SCREENERY_TOKEN in the step's env to a publish token. The CLI uses OIDC when the job has it, and the token only when it does not.

Inputs

InputDefaultMeaning
project none (required) The project to publish to, as {org}/{project}. One repository can feed several projects; this picks which one.
path test-results Folder of screenshots, walked recursively. Each file's path under it, without the extension, is its name.
version 0.4.0 The screenery npm version to run. See Pinning.
api-url empty Screenery API origin. Leave empty to use the hosted service.
oidc-audience empty Who the OIDC token is minted for. Leave empty; it follows api-url.
force false Move a channel even when it already holds a newer build. Off by default, so a slow re-run of an old commit cannot overwrite newer screenshots.
partial false Mark this push as one of several feeding the project: it adds and updates names, and removes none. See Several pushes to one project.
diff-baseline empty A committed folder of baseline screenshots. When set, the screenshots are compared with it before anything is uploaded, and a change fails the job. See Failing on a visual change.
diff-threshold empty How different a pixel's colour must be to count as changed, from 0 to 1. Empty uses diff.threshold from screenery.config.json, or 0.1. Used only with diff-baseline.
playwright-snapshots false Publish only Playwright toHaveScreenshot goldens: files in folders whose name ends in -snapshots. Set path to your test folder too. See Playwright.
comment auto auto posts on pull requests. true always tries. false never posts. Who posts is the project's setting; see The pull-request comment.
github-token the job's token The token the job posts the comment with. Leave it alone unless you want the comment posted by another identity. A GITHUB_TOKEN in the step's env: still wins over it.
gallery-out empty Also write the gallery Markdown to this path.
readme-out empty Also write paste-once README snippets (@latest URLs) to this path.
comment-out empty Also write the pull-request comment Markdown to this path, whether or not it was posted.
fail-on empty Comma-separated statuses, from changed, removed and added. After the build is published, the job fails if any screenshot has one of them. Empty never fails on this.
report-out empty Write the build's JSON report to this path.

Outputs

Give the step an id and read them as ${{ steps.<id>.outputs.gallery-markdown }}.

OutputValue
channelThe channel the gallery and comment used, such as pr-12 or main.
comment-urlLink to the posted or updated pull-request comment, if one was posted.
gallery-filePath of the gallery Markdown file.
readme-filePath of the README snippets file.
comment-filePath of the pull-request comment Markdown file.
gallery-markdownThe gallery Markdown itself: every screenshot, grouped, with its status and URLs.
readme-markdownThe README snippets themselves.
pr-comment-markdownThe pull-request comment body, posted or not.
report-gatepublished or failed when the step wrote a report (fail-on or report-out set). Empty otherwise.
report-filePath of the report JSON, when report-out was set.

When you set no -out path, the files are written under $RUNNER_TEMP/screenery/.

The pull-request comment

On a pull request the step posts one comment per project, and updates it on the next push instead of adding another. It counts what changed, was added or stayed the same compared with @latest, links to the full gallery, and shows up to four screenshots, changed ones first.

The images in the comment use version URLs (@v_…), which never change. GitHub caches comment images by URL, so a moving @pr-12 URL would keep showing the first push. Only public screenshots are shown; the comment says how many it left out.

Who posts it is the project's comment setting, on the console's Access screen:

Until you choose, Screenery posts when its GitHub App may write pull requests on the repository, and the job posts otherwise. If the job cannot post, the step warns and the push still succeeds. See Who posts the comment.

Failing on a visual change

Point diff-baseline at a folder of known-good screenshots committed to your repository:

- uses: screenery/push@v1
  with:
    project: acme/dashboard
    diff-baseline: ./baseline

If any screenshot differs from its baseline, or has no baseline, the job fails and nothing is published.

That is the default. Each change is graded minor, moderate or major, and a screenery.config.json in your repository can set which grade fails the job. The Action reads it from the checkout. See Graded changes.

Each screenshot is compared pixel by pixel with the file of the same name in the baseline. A file that is in the baseline but no longer pushed is reported, not failed. The baseline folder must sit outside path, or it would be uploaded with the screenshots.

Create the baseline, or accept a change on purpose, with the CLI, then commit the folder:

npx screenery diff ./test-results --baseline ./baseline --update

Several pushes to one project

A channel holds exactly what the last build pushed. When a push moves a channel, names it did not push are removed from that channel, and their channel URLs answer 404. Old @v_… URLs keep working.

Jobs in the same workflow run do not remove each other's names on their first attempt. Re-running one failed job on its own makes that job the newest build, so it can remove the names the other jobs pushed. A test run split across machines can push as shards of one snapshot instead; see Sharded test runs.

If separate workflows or separate pushes feed one project, such as web and store screenshots, they would remove each other's names. Give each of those steps partial: true. A partial push only adds and updates names.

- uses: screenery/push@v1
  with:
    project: acme/dashboard
    path: store-screenshots
    partial: true

If every push to the project is partial, set the project's channel mode to additive on the console's Access screen once instead.

Fork pull requests

GitHub does not give a pull request from a fork an OIDC token. That is deliberate: a fork's workflow must not be able to publish to your project. So on a fork pull request nothing is published, and the step fails with a message saying that this is expected and nothing is misconfigured. The screenshots publish from your own branch once the pull request merges.

To skip the step on fork pull requests instead of failing it:

- uses: screenery/push@v1
  if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository

Fork previews would need a pull_request_target workflow, which runs your base branch's workflow with your repository's permissions. Decide that on purpose, not to silence the message.

Pinning

Two things are versioned: the Action, and the CLI it runs.

You writeYou get
screenery/push@v1 v1 is a tag that moves to each compatible release of the Action. You get fixes without editing your workflow.
screenery/push@<commit SHA> Exactly that commit of the Action, until you change it. Find the commits on github.com/screenery/push.

The version input pins the CLI separately. Its default is fixed in each release of the Action, so a new CLI on npm never changes what your workflow does. Today the default is 0.4.0, the latest version on npm.

Override it only to run a different release on purpose, and only with a version npm lists. A version npm does not have fails the job before anything is pushed.

- uses: screenery/push@v1
  with:
    project: acme/dashboard
    version: 0.4.0