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 request gallery
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('<', '<'); 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_failederrors. 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_ORIGINcomes from a repository variable, not fromenvironment_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 pastdeployment_statusevent before you rely on the filter. If your host does not report deployments to GitHub, call the job from the step that deploys. workflow_dispatchlets 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.ttlof 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
mustShowfromGITHUB_SHAso 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.