Screenery

Docs / Start here

Getting started

From zero to an embedded, self-updating screenshot. Your tests already take the screenshots. Screenery gives each one a URL that stays the same while the image behind it follows your default branch.

Two decisions

Setup asks you two things, and neither has a default:

PathWhat you getWhat it costs
github_app No secret in CI. Publishing a GitHub release promotes @latest for you. One trip to GitHub to install the App. Your CI must be GitHub Actions.
static_token Works with any CI. A SCREENERY_TOKEN secret in CI, and you promote by hand.

If your repository is on GitHub and your CI is GitHub Actions, take the GitHub App path. The rest of this page follows it. Without the GitHub App covers the other one.

Prefer to hand this to an agent? Add the MCP server with claude mcp add screenery -- npx -y screenery mcp (or your host's equivalent) and ask it to set up self-updating screenshots for the repository. It will ask you both questions above, together, and do the rest.

1. Install the GitHub App

Sign in to the console, follow install app, and pick the repository. Choose public or private. That creates the project, named {org}/{project}, for example acme/dashboard.

The install binds the project to the repository's numeric id, not its name. Renaming the repository changes nothing, and a fork cannot publish to your project. One repository can feed several projects: each push names the project it publishes to.

On a GitHub organization, the person who installs the App has to be the one who finishes setup in Screenery, signed in with the same GitHub account. Screenery checks that with GitHub. Other members of the organization cannot connect an installation they did not make. Right after the install, the console may say Finishing setup with GitHub for a few seconds while GitHub confirms who installed it.

2. Push screenshots from CI

After your Playwright step, in the same job, add the Screenery Action:

permissions:
  id-token: write
  contents: read
  pull-requests: write
steps:
  # … check out, install, run your Playwright tests …
  - uses: screenery/push@v1
    with:
      project: acme/dashboard
      path: test-results

There is no Screenery secret to store. Each line has a job:

Every input, output and option is on the GitHub Action reference.

How files become names

Each file's path under path, without its extension, is its name. Nothing is renamed for you.

FileName
test-results/checkout/payment-form.pngcheckout/payment-form
test-results/checkout/payment-form/mobile.pngcheckout/payment-form/mobile

A path that cannot be a legal name stops the push with an error that names the file. PNG, JPEG, WebP, AVIF, GIF, WebM and MP4 files are pushed. Other files, such as traces, are skipped and listed in the log.

3. The first push creates @latest

Every push lands on a channel: a name that points at one build. A pull request's push lands on @pr-12; a branch's lands on @main, @feature-x and so on. @latest is the channel you embed, and it follows your default branch.

The first push to a new project creates @latest, whatever branch it came from.

You usually add Screenery in a pull request, so this is what makes your first URLs work straight away. It happens once. After that, only a push on the default branch, or a promotion, moves @latest. A pull request never does.

With the GitHub App, publishing a GitHub release also promotes your default branch to @latest. The full rules are in How @latest moves.

4. Embed it

A public screenshot's @latest URL is ordinary Markdown. Paste it once:

![Payment form](https://cdn.screenery.dev/acme/dashboard/checkout/payment-form@latest.png)

The next push on your default branch changes the image behind that URL. There is no cache to purge. The extension is the format you pushed: a PNG is served as .png.

The push log prints a ready-to-paste snippet for every screenshot it published. Unlisted and private screenshots need a signed URL or a read token, so they have no paste-once snippet. The URL grammar, channels and versions are on Screenshot URLs.

Without the GitHub App

On the static_token path, CI publishes with a long-lived publish token instead of OIDC. Create the project through the MCP server and choose static_token when it asks. A publish token comes from the agent, or from new publish token on the project's Access screen in the console. Store it as a CI secret named SCREENERY_TOKEN.

In GitHub Actions, pass the secret to the Action instead of asking for an OIDC token:

- uses: screenery/push@v1
  env:
    SCREENERY_TOKEN: ${{ secrets.SCREENERY_TOKEN }}
  with:
    project: acme/dashboard
    path: test-results

In any other CI, run the CLI from npm with SCREENERY_TOKEN in the environment. Outside GitHub Actions it cannot read the branch, commit or run number for itself, so pass them:

npx screenery push ./test-results --project acme/dashboard \
  --branch main \
  --commit "$(git rev-parse HEAD)" \
  --commit-timestamp "$(git show -s --format=%cI HEAD)"

The first push still creates @latest. After that, a project with no repository bound has no default branch to follow, so you move @latest yourself, again with SCREENERY_TOKEN set:

npx screenery promote acme/dashboard --from main --to latest

Next