Docs / Testing and review
Pull-request comments and Markdown
After a push, Screenery can comment on the pull request with what changed. Every push also writes the gallery and README Markdown for you, so nobody assembles screenshot URLs by hand.
The preview comment
After the push, the CLI reads the build's gallery: every screenshot, each with a status against a base channel (added, removed, changed or unchanged). The base is the channel of the branch the pull request merges into. If that branch has no channel yet, because it has never pushed or its name cannot be a channel, the base is @latest. The comment is built from that gallery:
## šø Screenery ā 3 changed Ā· 1 added Ā· 12 unchanged **[Open the gallery](ā¦)** ā 16 screenshots from `a1b2c3d`, [CI run](ā¦) | Payment form Ā· mobile | Cart | Home (added) | Settings | | --- | --- | --- | --- | |  | ⦠| ⦠| ⦠| _Compared with `latest`. Unchanged screenshots are byte-identical to it._
- The strip shows up to four stills: changed first, then added. Filler cells say (unchanged). When more changed than fit, a line says how many more are in the gallery.
- Walkthroughs. GIF, WebM and MP4 files get their own section, showing up to two clips that changed or were added.
- One comment per project. A second push to the same pull request edits the comment in place instead of adding another.
- Only public screenshots are embedded. An unlisted or private screenshot's plain URL would not load for readers, so it is left out, and the log says how many were withheld. See Visibility and credentials.
- Visual changes first. When the visual-diff gate passes with graded changes, the comment leads with them, worst first. See Graded changes.
- One change across many screenshots. Requires CLI 0.5.0. Each push grades every screenshot against the version it replaces, and screenshots that changed the same way (a header recoloured on every page) are listed as one line before the strip: 1 change across 40 screenshots Ā· Shade changed #1E293B ā #0F172A Ā· minor. The strip then shows a different change in each cell, marked (+39 similar). The console's PR gallery stacks them the same way.
Compared at the merge base
Your branch grew from one commit of the base branch: the merge base. If the comment compared with the base channel as it is today, every change the base branch gained since you branched would show up as a change your pull request made. So when the push recorded its merge base, the comparison is with the base channel as it was at that commit, and the last line says so:
_Compared with `main` at `a1b2c3d`, the merge base. Unchanged screenshots are byte-identical to it._
- Where it comes from. A CLI newer than 0.4.0 runs git merge-base in the checkout and sends the result with the push. No GitHub API call and no extra permission.
- No build of that commit? The nearest earlier commit of the base branch that has one is used, and the line names both.
- Otherwise the tip, and why. For example Compared with the tip of `main` (the checkout was shallow ā¦). A push from CLI 0.4.0 records no merge base and is compared with the tip, as before, with no note.
A shallow checkout is the usual reason. actions/checkout fetches one commit by default; on a pull request the CLI can still read the base off GitHub's merge commit, but for the merge base itself and its history, fetch it all:
- uses: actions/checkout@v4
with:
fetch-depth: 0
Possibly flaky screenshots
A flaky screenshot is one whose bytes change when nothing real changed: an animation caught mid-frame, a clock, a font that loaded late. Screenery spots two patterns from builds it already has, with no extra capture work:
- Same commit, different bytes. Two or more builds of one commit (a re-run, a retried attempt, a second workflow) stored different versions of the screenshot.
- Flip-flop on the base channel. Within the base channel's last 20 builds, a build put the screenshot back to a version an earlier build had replaced (A ā B ā A). A rollback by a person does not count, but a deliberate revert looks the same, which is why this is only a hint.
When a changed or added screenshot is flaky, its cell in the strip says so and a note follows the pictures:
| Settings (flaky) | Pay (added) | ⦠| **Possibly flaky:** this screenshot has changed before when nothing real did, so the change here may be noise. - `settings` ā went back to an earlier version on `main` in recent builds
- It changes nothing else. The status, the counts in the heading, promotion rules and the visual-diff gate all ignore it. A flaky change still shows as changed; a reviewer decides.
- In the console the build and pull-request galleries show a flaky chip beside the status. Hover it for the reason.
- In the JSON. Each gallery asset, and each entry of the build and channel diffs, carries flaky: null, or {"reason": "same_commit" | "flip_flop", "versions": [ā¦], "commit", "channel"}.
- Which comment shows it. A comment the Screenery GitHub App posts shows the note right away. A comment the CLI posts with the job's token needs CLI 0.5.0.
To fix a flaky screenshot, make the page hold still before the capture: wait for fonts and network to settle, freeze the clock, and turn off animations (Playwright's animations: 'disabled').
Who posts the comment
Each project has a setting for who posts the comment, on the console's Access screen:
| Setting | Posted by | What the job needs |
|---|---|---|
| Screenery | screenery[bot], after the build | Nothing. No token and no pull-requests: write. |
| CI | Your job, as github-actions[bot] | pull-requests: write. The Action passes the job's own token through its github-token input, so there is no env: line. |
| Off | Nobody | Nothing. The gallery and the Markdown are still written. |
Until you choose, Screenery posts when its GitHub App may write pull requests on the repository, and your job posts otherwise. So a workflow that covers both looks like this:
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: screenery/push@v1
with:
project: acme/dashboard
path: test-results
When Screenery posted, the log prints the comment's URL and the job does not post a second one. Without pull-requests: write, GitHub refuses the job's post. That is a warning naming the permission, and the push still passes.
Switching a project from CI to Screenery starts a new comment. The old one from github-actions[bot] is left as it was, because Screenery never edits another author's comment. A push that uses SCREENERY_TOKEN instead of OIDC is always posted by the job.
When it posts
| comment input | CLI flag | Behaviour |
|---|---|---|
| auto | (default) | Posts on a pull request. Skips a pull request from a fork, where the token cannot write. |
| true | --comment | Always tries to post. |
| false | --no-comment | Never posts. |
To update its comment rather than add a new one, the CLI finds the comment by a hidden marker and checks that its own identity wrote it. It never edits anyone else's comment. If your job posts as its own GitHub App rather than github-actions[bot], set SCREENERY_COMMENT_AUTHOR to that App's login so the comment is updated in place.
Why it embeds version URLs
GitHub caches images in comments by their URL. A moving URL such as ā¦@pr-12.png would keep showing the first push's pixels after a second push, and a reviewer could approve a screenshot the branch no longer produces. So the strip embeds the immutable version URL, ā¦@v_9f1cā¦.png, which always shows exactly what was pushed. The moving channel URL is in the gallery Markdown beside it. See URLs and asset names.
Gallery and README Markdown
Every push writes three pieces of Markdown, whether or not a comment is posted:
| Markdown | What it holds | Action input / output |
|---|---|---|
| Gallery | Every screenshot, grouped, with its status, both URLs, its size, and the README snippets at the end. Never shortened. | gallery-out / gallery-markdown |
| README snippets | An embed for each screenshot at its @latest URL, ready to paste once. | readme-out / readme-markdown |
| PR comment | The comment body, posted or not. | comment-out / pr-comment-markdown |
In GitHub Actions the three files land in $RUNNER_TEMP/screenery/ even when you set no path, and the comment body is added to the job's summary page. The Action also has the outputs channel, comment-url, gallery-file, readme-file and comment-file. With the CLI, the flags are --gallery-out, --readme-out and --comment-out, and the README snippets are also printed in the log:
README snippets (@latest) ā paste once: 
A video gets a link instead of an image.
The snippets point at @latest because a README shows what your default branch looks like. A pull request's URL would show unmerged work. See How @latest moves.
Reading a gallery later
Any job, person or script can fetch the same gallery after the push:
npx screenery gallery acme/dashboard --pr 12 npx screenery gallery acme/dashboard --channel latest --format markdown
Pick exactly one of --pr, --build or --channel. --against <channel> chooses the base. The default output is JSON, which suits agents and jq. A public project needs no credential to read its gallery.
Verify receipts
screenery verify-publish is for a different job: after a browser check in a pull request, publish its screenshots and diffs as receipts, whether the check passed or failed. Product screenshots still go through screenery push.
npx screenery verify-publish ./verify-out --project acme/dashboard --verdict fail
- Pass and fail both publish. A failed check is exactly the receipt a reviewer needs.
- Receipts go to the pull request's channel only, @pr-N, under receipts/pass/⦠or receipts/fail/ā¦, so they never overwrite a product screenshot.
- They never move @latest. A default-branch run with no pull request is refused, and so is a project that has no @latest yet.
- It writes Markdown to paste into the pull request, screenery-pr-comment.md by default (--comment-out to change it), and adds it to the job summary in GitHub Actions. It does not post the comment itself.
- In a pull_request workflow the PR number is found for you. Elsewhere, pass --pr. --url takes a comma-separated list of Screenery image URLs to check are live first.