Post-Deploy Screenshot Check in GitHub Actions

Verify a deployment with screenshots. Render key pages after each production deploy, fail on missing or forbidden text, and see each capture in the job summary.

By Simon Chiu · Updated

The deploy pipeline says green. The health endpoint answers 200. And the pricing page shows "Something went wrong" because an environment variable did not make it to production. After each production deploy, render your key pages in a real browser, fail the run when a page misses text it must show or shows text it must not, and put every capture in the GitHub job summary so a person can look at the release in ten seconds. This page has the checks file, a tested script and the workflow.

It sits after the PR stage. Visual regression testing compares a preview with production before merge. This check confirms that production, after merge, renders what you reviewed.

Why text checks and not only status codes

A modern site fails in ways an HTTP check cannot see. The server returns 200 for the HTML shell, and then the client code fails, or the API behind it returns an error that the page turns into a friendly message. Content checks look at the visible text in the rendered page (document.body.innerText) after the wait settings. They are case-sensitive and match parts of strings, which the content checks guide explains with recipes.

What broke What a curl check sees What the screenshot check sees
Client bundle failed to load 200, HTML shell fail_if_missing on a heading fails
Backend API down, page shows a fallback 200 fail_if_contains: ["Something went wrong"] fails
Wrong environment, test-mode banner visible 200 fail_if_contains: ["TEST MODE"] fails
Page replaced by a maintenance template 200 or 503 Title check against metadata.title fails
Layout broken, text intact 200 Passes, but the capture in the summary shows it

The last row is why the images matter. Text checks catch what you thought of. The images catch the rest, as long as someone looks at them.

The checks file

Each entry names one page and what must and must not appear on it. Pick phrases from your own templates: a full sentence from the error page is safer than a short word, because a short word also matches healthy text.

[
  { "path": "/", "title": "Example", "mustShow": ["Start free trial"], "mustNotShow": ["Something went wrong"] },
  { "path": "/pricing", "mustShow": ["per month"], "mustNotShow": ["Something went wrong", "Contact sales to see pricing"] },
  { "path": "/sign-up", "device": "iphone_14", "mustShow": ["Create your account"] },
  { "path": "/broken-page", "mustNotShow": ["Something went wrong"] }
]

The script turns each entry into one POST /v1/screenshot. These are the bodies it sends for the file above:

Entry Request body (beyond url, block, wait, cache) What fails it
/ "viewport": {"width": 1280, "height": 800}, "options": {"fail_if_missing": ["Start free trial"], "fail_if_contains": ["Something went wrong"]} Missing call to action, error banner, or a title without "Example"
/pricing "options": {"fail_if_missing": ["per month"], "fail_if_contains": [...]} Prices replaced by a sales form
/sign-up on a phone "device": "iphone_14", "options": {"fail_if_missing": ["Create your account"]} A mobile-only layout bug that hides the form heading

Every request sends Accept: application/json, so the response is JSON with a CDN link to the image instead of the image bytes. The JSON also carries metadata.title from the real page, which the script compares with the optional title field.

How it works

flowchart LR
  D[Production deploy] -->|deployment_status: success| W[GitHub Actions job]
  W --> S[smoke.mjs, 3 workers]
  S -->|POST /v1/screenshot + content checks| R[RenderScreenshot]
  R -->|200 JSON: image.url, metadata.title| S
  R -->|422 content_check_failed| S
  S --> J[Job summary: table + images]
  S -->|any failure| X[Exit 1: red run, notification]

The script

// smoke.mjs: after a deploy, render each page with content checks and write a
// GitHub job summary with the images. Node 22+, no dependencies.
//   RS_API_KEY=... SITE_ORIGIN=https://www.example.com node smoke.mjs
import { readFile, appendFile } from 'node:fs/promises';

const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const { RS_API_KEY, SITE_ORIGIN } = process.env;
const CONCURRENCY = Number(process.env.CONCURRENCY ?? '3');
if (!RS_API_KEY || !SITE_ORIGIN) {
  console.error('Set RS_API_KEY and SITE_ORIGIN');
  process.exit(2);
}

async function check(page, attempt = 1) {
  const url = new URL(page.path, SITE_ORIGIN).href;
  const body = {
    url,
    ...(page.device ? { device: page.device } : { viewport: { width: 1280, height: 800 } }),
    output: { format: 'webp', quality: 80 },
    block: { cookie_banners: true, chat_widgets: true },
    wait: { until: 'networkidle', timeout: 45 },
    cache: { ttl: 604800 }, // the CDN link in the summary works for 7 days
    options: {
      ...(page.mustShow && { fail_if_missing: page.mustShow }),
      ...(page.mustNotShow && { fail_if_contains: page.mustNotShow }),
    },
  };
  const res = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${RS_API_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/json', // JSON with a CDN link instead of image bytes
    },
    body: JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  // Retry rate limits up to 3 times. Retry other retryable errors (a timeout, text
  // that loaded late) once. Validation errors are not retryable and fail at once.
  const transient = res.status === 429 || (json.error?.retryable && attempt === 1);
  if (transient && attempt < 4) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? '5') * 1000));
    return check(page, attempt + 1);
  }
  if (!res.ok) return { page, ok: false, reason: `${json.error?.code ?? res.status}: ${json.error?.message ?? ''}` };
  const title = json.metadata?.title ?? '';
  if (page.title && !title.includes(page.title)) {
    return { page, ok: false, reason: `title is "${title}"`, image: json.image.url };
  }
  return { page, ok: true, reason: title, image: json.image.url };
}

// A small worker pool: stays under the per-account rate limit.
async function runAll(pages) {
  const results = [];
  let next = 0;
  async function worker() {
    while (next < pages.length) {
      const i = next++;
      results[i] = await check(pages[i]);
    }
  }
  await Promise.all(Array.from({ length: CONCURRENCY }, worker));
  return results;
}

const pages = JSON.parse(await readFile('checks.json', 'utf8'));
const results = await runAll(pages);
const esc = (s) => String(s).replaceAll('|', '\\|').replaceAll('<', '&lt;');

const lines = [`## Post-deploy check: ${esc(SITE_ORIGIN)}`, '', '| Page | Result | Detail | Capture |', '|---|---|---|---|'];
for (const r of results) {
  const img = r.image ? `<img src="${r.image}" width="240" alt="${esc(r.page.path)}">` : '';
  lines.push(`| \`${esc(r.page.path)}\` | ${r.ok ? 'pass' : '**FAIL**'} | ${esc(r.reason)} | ${img} |`);
  console.log(`${r.ok ? 'pass' : 'FAIL'} ${r.page.path} ${r.reason}`);
}
if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n');
if (results.some((r) => !r.ok)) process.exitCode = 1;

The summary escapes | and < in page titles and error messages, because those strings come from the page under test and a stray character would break the table or inject markup into the summary.

Tested on 2026-10-18 with Node 22.18.0 against a local stub of our API that returns the documented JSON body and 422 content_check_failed errors. A checks file with one failing title check exited 1 and wrote a summary table with one FAIL row and an image tag per page. The file shown above, run against the stub's placeholder page, wrote four FAIL rows with the error messages. We did not run it against the live API or on GitHub for this page.

The workflow

name: Post-deploy check

on:
  deployment_status:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  smoke:
    if: >-
      github.event_name == 'workflow_dispatch' ||
      (github.event.deployment_status.state == 'success' &&
       github.event.deployment.environment == 'Production')
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - name: Render and check production pages
        working-directory: smoke
        env:
          RS_API_KEY: ${{ secrets.RS_API_KEY }}
          SITE_ORIGIN: ${{ vars.SITE_ORIGIN }}
        run: node smoke.mjs

The workflow passed actionlint 1.7.7. Three choices in it are deliberate:

  • SITE_ORIGIN comes from a repository variable, not from environment_url. Hosts often report the unique deployment URL there. You want to check the public domain your visitors use, after the alias moved.
  • The environment name must match your host. Vercel reports Production; other hosts use other names. Look at a past deployment_status event before you rely on the filter. If your host does not report deployments to GitHub, call the job from the step that deploys.
  • workflow_dispatch lets you re-run the check by hand after a config change that did not deploy code.

Settings that matter here

  • WebP at quality 80, not PNG. These images are for people to look at, not for pixel comparison, and WebP is smaller in a summary that loads many of them. Visual diffs need PNG; see visual regression testing.
  • cache.ttl of 7 days. The CDN link expires with it. The minimum is one hour and the maximum is 30 days, per the caching reference.
  • No cache reuse. Do not send cache.available. A post-deploy check must render the new release, not return yesterday's image.
  • A version marker, if you have one. If the footer shows the build ID, add it to mustShow from GITHUB_SHA so the check fails while a CDN still serves the old release. It must be visible text; hidden elements do not count.

What it costs

Passing pages cost one credit each. Failed checks are free.

Release cadence Pages Renders per month Plan that covers it
2 deploys a day 8 about 480 Starter: 2,500 credits for $10
10 deploys a day 12 about 3,600 Growth: 12,000 credits for $40
10 deploys a day, 2 devices 25 about 7,500 Growth

At 3,600 renders a month on Growth, each check costs about $0.0111. Prices change; the pricing page is current.

When a check fails

The error code tells you where to look. content_check_failed is the page; read the message for the text that was missing or found, and the error page for contentcheckfailed for causes. timeout and navigation_failed mean the page did not load in time. 429 too_many_requests means the pool is too wide for your plan's per-account limit, which all your keys share; lower CONCURRENCY.

For checks that run on a clock instead of on deploy, with a pixel diff against a stored baseline, see website monitoring with screenshots. To post captures in the PR before merge, the GitHub Actions integration has a reusable workflow. Get a free API key, add RS_API_KEY as a secret, and run the check by hand once with workflow_dispatch.

Frequently asked questions

Why not use the batch endpoint for a post-deploy check?
The batch endpoint does not pass content checks (options.fail_if_missing, options.fail_if_contains) to its items, so a broken page would come back as a passing image. Send single POST requests with a small worker pool instead.
Who can open the images in the job summary?
Anyone who has the link. The summary uses the public CDN URL that the API returns, which needs no API key. The script sets cache.ttl to 7 days, so the links expire then. Do not use this for pages that show private data.
Does a failed check cost credits?
No. A render that fails a content check returns HTTP 422 with content_check_failed and uses no credit. Only pages that pass are charged.
How is this different from a smoke test with curl?
curl sees the HTML the server sent. A screenshot check runs the page's JavaScript in a browser and checks the text a visitor sees, so it catches a blank client-rendered page, a failed hydration or an error banner that the API call behind it caused.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.