Docs / Channels and promotion
How @latest moves
Every screenshot URL names a channel. @latest is the one you embed in a README, so Screenery is strict about what may move it.
Channels
Each push from CI is stored as an immutable version, and the channels it belongs to are moved to point at it. A URL picks a channel with @:
| Channel | Moved by |
|---|---|
| @latest | A push on the default branch, or a promotion. |
| @main, @feature-x, … | A push from that branch. |
| @pr-12 | A push from that pull request. |
| @v_… | Nothing. A version URL never changes. |
https://cdn.screenery.dev/acme/dashboard/settings@latest.png
Branch and @pr-N channels count toward your plan's channel limit. At the limit, a push still stores its screenshots and moves the channels that already exist, but does not create a new branch or pull-request channel (see Channel limit). @latest is never skipped this way: it is created and moved even when the project is at its limit.
The default branch moves it
@latest follows your repository's default branch: main, master, or whatever GitHub reports. A push from that branch moves it. A pull-request push never does: a pull request is unmerged work, and @latest is what your README shows.
One exception makes the first push work. If a project has no @latest yet, the first build that finishes creates it, whatever branch it came from. You usually add Screenery in a pull request, and a first push whose every URL answers 404 would look broken. This happens once per project.
So until the default branch builds, @latest shows that first push, even if it came from a feature branch or a pull request that never merges. The next push on the default branch replaces it. To replace it sooner, promote a channel by hand (below).
What a channel holds
A channel holds exactly what the last build pushed. When a build moves a channel, names that build did not push are removed from it. Rename or delete a screenshot test, and on the next default-branch push its @latest URL answers 404. The push log lists the names that went. Old @v_… URLs keep working, and a rollback in the console puts the names back.
If several jobs feed one project, such as test shards or separate web and store screenshots, set the project's channel mode to additive on the console's Access screen, so the jobs do not remove each other's names. To mark single pushes instead, use partial: true on the Action or --partial on the CLI; that needs CLI 0.4.0, which screenery/push@v1 runs.
Screenshots nobody pushes any more
With additive or --partial, nothing removes a name. If a screenshot's test is deleted, renamed or skipped, its @latest URL keeps serving the last picture that test took, and nobody notices until a reader does. Screenery calls that screenshot wilting, and can tell you about it.
A screenshot wilts when no build has pushed it to @latest for the number of days you choose, while other builds kept pushing there.
- Pushing identical bytes counts. A test that still runs and still produces the same picture never wilts, even though nothing about the image changed.
- A quiet project does not wilt. The clock runs against your other pushes, not the calendar, so a project nobody pushed for a month has nothing wilting.
- Still served comes first. Each alert says how often the image was requested in the last 7 days. A wilting screenshot that readers still fetch is the one to fix.
Turn it on under Wilting alerts on the console's Access screen: pick 7, 14, 30, 90 or 365 days, and optionally watch another channel than latest. It is off until you do. The project grid then offers a wilting filter, and the screenshot's page says "Not pushed for 41 days · still served 1.2k times last week".
A daily check tells you once per screenshot, and again only after it was pushed and then stopped again:
- In chat: on the Channels & rules screen, press + wilting rule. It posts one message per day listing the screenshots that started wilting and are still requested (see Chat notifications).
- By webhook: subscribe a webhook to asset.wilted. It is sent once per screenshot, signed like every Screenery webhook. It names the asset, never a URL:
{
"delivery": "whd_…",
"event": "asset.wilted",
"event_id": "ev_…",
"created_at": "2026-10-11T00:00:03.512Z",
"data": {
"project": "acme/dashboard",
"asset": "docs/billing-settings",
"channel": "latest",
"version": "v_51b0e8c2aa47",
"content_type": "image/png",
"visibility": "public",
"after_days": 30,
"last_pushed_at": "2026-08-31T23:12:44.001Z",
"channel_pushed_at": "2026-10-10T17:03:10.220Z",
"days": 40,
"requests_last_7_days": 1240,
"still_served": true,
"detected_at": "2026-10-11T00:00:02.987Z"
}
}
requests_last_7_days counts every request for the screenshot's name at the CDN, whatever the URL asked for. It is null, not 0, until delivery metering has counted anything for your project.
Promoting by hand
You can point @latest at another channel's current version yourself:
npx screenery promote acme/dashboard --from main --to latest
The command fails if the new version is not being served within the deadline, so a promotion nobody can see does not pass as a green CI step. The target ends up equal to the source: names the source no longer has are removed from the target.
A project created through the GitHub App also gets a default rule: when a GitHub release is published, the default branch is promoted to @latest and to a channel named after the release tag. Every run of a rule, including the ones it skipped, is logged on the console's channels screen.
Which jobs may move it
The GitHub Action signs in with GitHub's OIDC token, so there is no secret to store. That token names the repository and the exact branch or pull request the job ran for. Screenery uses it to protect one thing:
Only a job running on the default branch may push a build that moves the default branch's channel or @latest.
A job on feature-x, or a pull-request job, that pushes with --branch main or --branch latest gets 403 forbidden, and nothing is stored. Without this, any workflow in the repository could replace the images in your README.
Everything else stays open to any job in the repository:
- its own branch channel and its @pr-N channel;
- any other branch name you pass with --branch;
- the clip and eval channels that screenery clip and screenery eval-publish use;
- screenery verify-publish --pr N from any job.
If Screenery does not know the project's default branch yet, it assumes main. A push that authenticates with a SCREENERY_TOKEN instead of OIDC is not checked: whoever gave that token to the job chose to trust it.
Back-merge pull requests
One ordinary workflow meets this rule: a pull request whose head branch is the default branch, such as a pull request from main into develop. Its push says branch: main plus the PR number, which would move @main from a pull-request job, so it is refused with 403. Use one of these:
With the CLI, push only the pull-request channel:
npx screenery push ./screenshots --project acme/dashboard --omit-branch
With the Action, skip the step on those pull requests:
- uses: screenery/push@v1 if: github.head_ref != github.event.repository.default_branch