This page gives you a reusable workflow that captures pages of a preview deployment, keeps the PNG files as a workflow artifact, and posts the images in one pull request comment that it edits on each new deployment. Reviewers see the change on the PR itself, without opening the preview. It belongs to our integrations hub.
If you only need a few files as build artifacts, the shorter CLI in CI guide is enough. This page adds three things: one workflow that any repository can call, a PR comment, and retry rules for 429 and 422 responses.
Last tested: 2026-10-18. Both workflow files passed actionlint 1.7.12 (with shellcheck). We ran the capture step, as extracted from the YAML, with bash, curl and jq against a local stub of our API that returns the documented JSON body and errors and enforces a per-minute limit. We ran the comment script with a mock of the Octokit client: the first run created the comment and the second run edited it. We did not run the workflow on GitHub for this page, so it has no screenshots of the Actions UI.
What you can build
- Visual review on every PR. Your host deploys a preview. The workflow captures
/,/pricingand/docsand posts the images in the PR. - A record of each release. Call the same workflow after a production deployment, with no
pr-number, and keep the PNG files as an artifact of that run. - Input for a diff tool. The artifact holds the files with stable names, so a later job can compare them with the images of the main branch. See visual regression testing for the method.
Prerequisites
- A host that reports deployments to GitHub with an
environment_url. Vercel's GitHub integration does this. With other hosts, call the workflow from the job that deploys, and pass the URL yourself. - A RenderScreenshot API key that starts with
rs_live_, saved as the repository secretRS_API_KEY. Create a free account to get one. jqandcurlon the runner. Theubuntu-latestimage has both.
How the workflow runs
flowchart LR D[Host reports deployment success] --> F[find-pr job: commit SHA to open PR] F --> C[Capture step: POST /v1/screenshot per path] C --> A[upload-artifact: PNG files] C --> M[github-script: create or edit one PR comment]
The capture step sends each request with Accept: application/json. The API then answers with JSON instead of image bytes, and image.url is a public CDN link. The step downloads that link into the artifact and puts the same link into the comment. One render serves both, so each page costs one credit.
Step 1: add the reusable workflow
Save this file as .github/workflows/rs-screenshots.yml. It runs only when another workflow calls it.
name: Page screenshots (reusable) on: workflow_call: inputs: base-url: description: Origin of the deployment to capture, for example https://pr-42.preview.example.com required: true type: string paths: description: One path per line, for example "/" and "/pricing" required: false type: string default: / preset: description: RenderScreenshot preset required: false type: string default: desktop_hd pr-number: description: Pull request to comment on. Leave empty to skip the comment. required: false type: string default: '' secrets: RS_API_KEY: required: true jobs: screenshots: runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read pull-requests: write steps: - name: Capture pages id: capture env: RS_API_KEY: ${{ secrets.RS_API_KEY }} BASE_URL: ${{ inputs.base-url }} PATHS: ${{ inputs.paths }} PRESET: ${{ inputs.preset }} run: | set -euo pipefail api=https://api.renderscreenshot.com mkdir -p screenshots echo '[]' > screenshots/results.json i=0 while IFS= read -r path; do path="$(echo "$path" | xargs)" # trim spaces [ -z "$path" ] && continue i=$((i + 1)) page="${BASE_URL%/}${path}" body=$(jq -n --arg url "$page" --arg preset "$PRESET" \ '{url: $url, preset: $preset, block: {ads: true, cookie_banners: true}, cache: {ttl: 2592000}}') waits=0; retried=0 while :; do status=$(curl -sS -o resp.json -D headers.txt -w '%{http_code}' -X POST "$api/v1/screenshot" \ -H "Authorization: Bearer $RS_API_KEY" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d "$body") if [ "$status" = 429 ] && [ "$waits" -lt 3 ]; then waits=$((waits + 1)) secs=$(awk 'tolower($1)=="retry-after:" {print $2+0}' headers.txt) echo "::notice::429 for $path, waiting ${secs:-60}s" sleep "${secs:-60}" continue fi if [ "$status" = 422 ] && [ "$retried" -eq 0 ]; then retried=1 echo "::notice::422 $(jq -r .error.code resp.json) for $path, retrying once" continue fi break done if [ "$status" = 200 ]; then cdn=$(jq -r .image.url resp.json) curl -sS --fail -o "screenshots/$i.png" "$cdn" jq --arg path "$path" --arg page "$page" --arg cdn "$cdn" --arg file "$i.png" \ --arg title "$(jq -r '.metadata.title // ""' resp.json)" \ '. + [{path: $path, page: $page, image: $cdn, file: $file, title: $title}]' \ screenshots/results.json > tmp.json && mv tmp.json screenshots/results.json else code=$(jq -r '.error.code // "unknown"' resp.json) echo "::warning::Screenshot of $path failed: HTTP $status $code" jq --arg path "$path" --arg page "$page" --arg error "$status $code" \ '. + [{path: $path, page: $page, error: $error}]' \ screenshots/results.json > tmp.json && mv tmp.json screenshots/results.json fi done <<< "$PATHS" ok=$(jq '[.[] | select(.image)] | length' screenshots/results.json) echo "captured=$ok" >> "${GITHUB_OUTPUT:-/dev/null}" [ "$ok" -gt 0 ] || { echo "::error::No page was captured"; exit 1; } - name: Upload screenshots id: upload if: ${{ !cancelled() }} uses: actions/upload-artifact@v7 with: name: screenshots-${{ github.run_id }} path: screenshots/ retention-days: 14 if-no-files-found: ignore - name: Comment on the pull request if: ${{ inputs.pr-number != '' && steps.capture.outcome == 'success' }} uses: actions/github-script@v9 env: PR_NUMBER: ${{ inputs.pr-number }} ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} with: script: | const fs = require('fs'); const marker = '<!-- renderscreenshot-preview -->'; const results = JSON.parse(fs.readFileSync('screenshots/results.json', 'utf8')); const rows = results.map((r) => r.image ? `| [\`${r.path}\`](${r.page}) | <img src="${r.image}" width="360" alt="${(r.title || r.path).replace(/"/g, '"')}"> |` : `| [\`${r.path}\`](${r.page}) | Failed: \`${r.error}\` |`); const body = [ marker, `### Page screenshots for ${context.sha.slice(0, 7)}`, '', '| Page | Screenshot |', '|---|---|', ...rows, '', `Full-size files: [workflow artifact](${process.env.ARTIFACT_URL}). Image links expire after 30 days.`, ].join('\n'); const { owner, repo } = context.repo; const issue_number = Number(process.env.PR_NUMBER); const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); const mine = comments.find((c) => c.user.type === 'Bot' && c.body.includes(marker)); if (mine) { await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); } else { await github.rest.issues.createComment({ owner, repo, issue_number, body }); }
Points that are easy to miss:
cache: {ttl: 2592000}keeps each image on the CDN for 30 days, the maximum. The default is 24 hours, which is too short for a PR that stays open over a weekend.- The body does not send
cache.available. A preview URL such aspr-42.preview.example.comshows new content after each push, but the cache key comes from the URL and options. Reuse would put an old render in the comment. - The step fails only when no page was captured. One broken path gives a warning and a "Failed" row, and the other images still reach the PR.
Step 2: call it after each preview deployment
Save this as .github/workflows/preview-screenshots.yml. The first job finds the open pull request for the deployed commit. The second job calls the reusable workflow.
name: Preview screenshots on: deployment_status: jobs: find-pr: if: ${{ github.event.deployment_status.state == 'success' && github.event.deployment_status.environment_url != '' }} runs-on: ubuntu-latest permissions: contents: read pull-requests: read outputs: number: ${{ steps.pr.outputs.number }} steps: - id: pr uses: actions/github-script@v9 with: script: | const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ ...context.repo, commit_sha: context.payload.deployment.sha, }); const open = data.find((pr) => pr.state === 'open'); core.setOutput('number', open ? String(open.number) : ''); screenshots: needs: find-pr uses: ./.github/workflows/rs-screenshots.yml permissions: contents: read pull-requests: write with: base-url: ${{ github.event.deployment_status.environment_url }} pr-number: ${{ needs.find-pr.outputs.number }} paths: | / /pricing /docs secrets: RS_API_KEY: ${{ secrets.RS_API_KEY }}
A called workflow cannot get more permissions than its caller gives it. This is why the screenshots job repeats pull-requests: write. Secrets also do not pass to a called workflow by default, so the caller passes RS_API_KEY by name. If you prefer, secrets: inherit passes all of them.
Parameter mapping
| Workflow input | API field | Notes |
|---|---|---|
base-url + each line of paths |
url |
The step joins them and removes a double slash |
preset |
preset |
desktop_hd by default. Any name from the presets reference |
| (fixed) | block.ads, block.cookie_banners |
Keeps consent dialogs out of review images |
| (fixed) | cache.ttl |
2592000 seconds, so comment images live 30 days |
pr-number |
none | Empty means no comment, for example after a production deployment |
To add a wait for client-side data, add wait: {until: "networkidle"} to the jq body. The POST endpoint reference lists every field.
Errors and retries
The step reads the HTTP status of each request and acts on it:
| Response | What the step does | Why |
|---|---|---|
429 toomanyrequests |
Waits Retry-After seconds, up to 3 times |
The limit is per minute, so the wait is short |
422 (for example timeout) |
Tries once more, then writes a "Failed" row | A preview that wakes from cold can miss the first load |
401, 400, 403 |
Writes a "Failed" row at once | A new request gets the same answer |
In our stub run with a limit of 2 requests per 3 seconds, four paths produced two 429 waits, one 422 retry and three images, and the step exited 0. With a bad key, every path failed with 401 missing_credentials and the step exited 1.
Limits and costs
- Credits. Each path is one fresh render and one credit per deployment. Three paths on a PR with ten pushes use 30 credits. The free plan gives 150 credits each month. See pricing.
- Rate limit. Requests per minute are counted per account, for all keys together. A matrix of repositories that share one account shares one limit. The free plan allows 10 requests per minute. See rate limits.
- Runner minutes. Requests run one after another, so the job takes about as long as the sum of the renders.
timeout-minutes: 15stops a stuck run. - Public image links. The CDN links need no key. Anyone who has a link can open the image. For a private repository, decide if previews may appear at an unguessable public URL for 30 days. If not, remove the comment step and keep only the artifact, which needs GitHub sign-in.
Troubleshooting
- The find-pr job outputs an empty number. The deployment's commit is not the head of an open PR, for example a deployment of
main. The capture still runs and skips the comment. - The images show a login page. Your host protects preview deployments. Vercel documents a bypass header for automation. Send it with
network: {headers: {...}}in the body. RenderScreenshot sendsnetwork.headersonly to the target's origin. - Two comments appear. The script finds its comment by the hidden marker and a bot author. Do not edit the marker line, and run the comment step with the default
GITHUB_TOKEN. - Every request returns 401. The secret is missing or the called workflow did not receive it. Check the
secrets:block of the caller. - The step waits for a long time. Another job on the same account used the minute's requests. Lower the parallel jobs, or read the rate limit headers in your other jobs.