Website Screenshots in GitHub Actions, with PR Comments

A reusable GitHub Actions workflow that captures preview deployments, uploads the images as an artifact and posts them in a pull request comment.

By Simon Chiu · Updated

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

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 secret RS_API_KEY. Create a free account to get one.
  • jq and curl on the runner. The ubuntu-latest image 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, '&quot;')}"> |`
              : `| [\`${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 as pr-42.preview.example.com shows 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: 15 stops 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 sends network.headers only 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.

Frequently asked questions

Can a GitHub Action post website screenshots in a pull request comment?
Yes. Request the screenshot with Accept: application/json, and the API returns a public CDN link to the image. The workflow puts that link in an img tag inside a comment that actions/github-script creates, then edits on every later deployment.
Why does the workflow run on deployment_status and not on pull_request?
A pull_request run starts before the preview deployment exists, so it would capture an old or empty page. deployment_status runs after the host reports a successful deployment and carries the preview URL in environment_url.
How long do the images in the PR comment keep working?
The workflow sets cache.ttl to 2592000 seconds, so the CDN links work for 30 days. The PNG files in the workflow artifact last for the artifact's retention period, 14 days in this file.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.