Screenery

Self-updating screenshots for docs and READMEs

Screenshots that stay fresh, and stay right.

Every release makes a few of your screenshots wrong, and nothing tells you which. Your users find out first, when their screen doesn't match your docs. Screenery plugs into the CI you already run and publishes the screenshots your tests take, so the image in your README changes on the merge that changed the screen. Turn on the visual check, and a screen that broke by accident fails CI before it reaches your docs.

URL format
https://cdn.screenery.dev/{org}/{project}/{asset}@latest.png
cdn.screenery.dev/jfreal/screenery/projects-cards@latest.png
The Screenery console's projects list: the header and two project cards, as of the latest push to the default branch
Screenery's own console on demo data, at the URL above. Nobody re-exports it: our CI shoots it, and the push to the default branch promotes it. The second project has not pushed a build yet, so its picture is the console's placeholder.

In production

Pheidi uses Screenery

Pheidi builds training plans for runners. Its homepage used to show a PNG exported in May and a calendar drawn by hand in CSS. Both drifted from the app. Now its tests capture 15 screens on every merge, and the homepage and 13 doc pages show them by name at @latest.

How Pheidi replaced its mockups

cdn.screenery.dev/jfreal/pheidi/desktop/plan/overview@latest.png
Pheidi's plan Overview for a half marathon: plan settings, a training-load chart and a week-by-week table of runs, as of Pheidi's latest push to master
The hero on pheidi.training, from the same URL. Pheidi's CI pushed it.

Why screenshots rot

Your docs are showing an app you already changed.

A renamed button or a moved menu is a one-line diff. The PNG in your README never shows up in that diff, so no test fails and no reviewer sees it. The page looks fine to everyone except the reader trying to follow it.

Readers stop trusting the page

When the screen in front of them doesn't match the picture, people start doubting the words around it too. Then they open a support ticket, or they give up on step three.

AI gets it wrong too

Coding agents and AI assistants read your docs, pictures included. A stale screenshot teaches them the old screen, and they walk your users through it with full confidence.

Retaking them is a chore nobody owns

Open the app, seed the right data, capture it at two sizes, crop it, upload it, then hunt down every page that pasted the old one. Most teams wait until someone complains.

Every big change gets harder to ship

A redesign, a new menu or a new logo means updating the same screenshots in 20 places. So teams put off the change, or ship it and leave the docs behind. Pairings Up rebranded without retaking one. Read how.

Screenery adds one step to the CI you already run. Your tests take the screenshot, the merge publishes it, and every page that embeds it shows the new one.

How it works

Five minutes of setup. After that, you never touch a screenshot by hand again.

  1. 1

    Push from CI

    One step after your tests publishes every PNG. No token to store. id-token: write proves the job. screenery[bot] posts the preview, or the job does; pull-requests: write covers that case.

    permissions:
      id-token: write
      pull-requests: write
    steps:
      - uses: screenery/push@v1
        with:
          project: acme/dashboard
          path: test-results
  2. 2

    The default branch moves @latest

    A push on the default branch moves @latest. The first build on a new project does too, from any branch. After that, a pull request keeps @pr-n and does not move @latest. With no repository bound, screenery promote moves it.

    # no repository bound
    npx screenery promote \
      acme/dashboard \
      --from main --to latest
  3. 3

    Embed it once

    Paste the Markdown once. The next push on the default branch updates the image, with nothing to purge.

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

The docs walk through each step: add a self-updating screenshot to your README, how @latest moves and the screenshot URL format.

Visual testing and approval, built in

Fresh is only safe if it's right.

A screenshot that updates itself also publishes its mistakes: a spinner, an error toast, a test account's data. So Screenery checks before your docs change. It keeps every screenshot your CI has ever pushed, so it always knows what @latest looked like. A screen that changed by accident fails the job. A reviewer, or an agent, approves each change before the merge that moves @latest.

Fail CI on a visual regression

Point the Action at a baseline and it compares every screenshot pixel by pixel before publishing. A change past your threshold, or a new finding, fails the job, and nothing is pushed.

permissions:
  id-token: write
  pull-requests: write
steps:
  - uses: screenery/push@v1
    with:
      project: acme/dashboard
      diff-baseline: ./baseline

Approve every change

Approve or reject each screenshot a pull request changed, by keyboard in the console or through an agent over MCP. Screenery reports the result to GitHub as a check named screenery. Make it required, and a pull request with an unreviewed or rejected change cannot merge. Approving records a decision; the merge is what moves @latest.

Proof on every pull request

On a pull request the job posts one comment showing what changed against @latest, with immutable images reviewers can trust. screenery/push@v1 posts it with the job's own token, not as screenery[bot]. Run screenery verify-publish after a browser check, pass or fail, to publish the screenshots and diffs as receipts onto @pr-N. That does not move @latest.

Read the docs on visual regression testing in CI, reviewing a pull request's screenshots, screenshot previews in pull request comments and publishing UI eval results, which keeps a judge model's report next to each screenshot it scored.

Hand it to your agent

Screenery ships an MCP server that carries its own runbook. Add it, say one sentence, and the agent creates the project, pushes a first screenshot, wires the workflow and pastes the snippet.

Three things stay yours: approve a sign-in code once, say whether the screenshots are public or private, and choose the setup path (github_app or static_token). On the GitHub App path, install the App and pick the repository.

Setup for each host is in the MCP server docs.

1 · add the server
$ claude mcp add screenery -- npx -y screenery mcp
2 · then say
“Add the Screenery MCP server and set up self-updating screenshots for this repo.”

Works with Claude Code, Codex, Cursor, VS Code and any MCP host. Other hosts →

Set it up once, then stop thinking about it

Built like infrastructure

No secrets in CI

The Action trades a GitHub OIDC token for a publish credential that lives five minutes. Nothing stored, nothing to rotate, nothing to leak.

Fork-safe by design

Projects bind to a repository's numeric id. Renames change nothing, and a fork's pull request cannot publish to your project.

Any CI, not just GitHub

GitHub Actions needs no secret at all. Every other CI pushes with one static publish token, and the same diff gate runs anywhere the CLI does.

Public, unlisted or private

Public assets need no credential. Unlisted ones need a signed URL, private ones a read token — and an unauthorised request gets a 404, never a hint.

Delivery that stays up

Images are served from the edge, independently of the control plane. If the dashboard is down, every embedded screenshot keeps serving.

Works with your goldens

Keep toHaveScreenshot as your pixel gate. Screenery publishes the files it already accepted, or an Argos upload folder, as-is.

Read the docs on CI credentials and GitHub OIDC, publishing Playwright screenshots and the screenery/push GitHub Action.

Questions

What is Screenery?

Screenery is a package registry for product screenshots. CI pushes screenshots on every build. Each one gets a stable URL ending in @latest.png, and the image behind it follows your default branch.

How do I keep README screenshots up to date?

Push screenshots from CI with the screenery/push@v1 GitHub Action, then paste the @latest URL into your README once. The next push on the default branch changes the image behind it within a minute. There is no cache to purge. Getting started has the full walkthrough.

Does Screenery do visual regression testing?

Yes, when you ask for it. Set diff-baseline to a committed folder of approved screenshots, and the Action compares every screenshot with it pixel by pixel before publishing. A change past your threshold fails the job, and nothing is pushed. See visual regression testing in CI.

Do I need a secret in CI?

Not on GitHub Actions. The Action trades a GitHub OIDC token for a publish credential that lives five minutes. Other CI systems push with one static publish token. See Credentials.

Does it work with Playwright's toHaveScreenshot?

Yes. Keep toHaveScreenshot as your pixel gate. Screenery publishes the goldens it already accepted, or an Argos upload folder, as they are. See Playwright goldens.

Can screenshots be private?

Yes. Each screenshot is public, unlisted or private. An unlisted one opens with a signed URL that expires, and a private one with a read token. A refused request gets a 404, so nobody learns that a private screenshot exists.

Can an AI coding agent set it up?

Yes. Screenery ships an MCP server. Add it to Claude Code, Codex, Cursor or VS Code and ask the agent to set up self-updating screenshots. You still approve the sign-in, choose public or private, and pick the setup path.

Does a pull request change the screenshot in my README?

After the first build, no. A pull request gets its own @pr-n channel and does not move @latest, so the image in your README stays on the default branch. The first build on a new project does move @latest, from whichever branch it came from. How @latest moves.

Stop re-exporting screenshots.

Set it up once. Point it at a baseline and every build is checked before it publishes, and a push on the default branch updates every image, everywhere you embedded it.