Capture website screenshots in GitHub Actions

Capture deployed pages in GitHub Actions with ScreenshotOne. Save multiple PNGs as artifacts, record failures, and compare a capture with an approved baseline.

Blog post7 min read

Written by

Dmytro Krasun

Published on

Deployments can pass every check and still ship a broken layout. A missing stylesheet, an overflowing hero, or an empty pricing table won’t fail a build. Attaching screenshots to the workflow run gives reviewers a quick way to see what actually went live.

Here’s the setup:

  • GitHub Actions runs a small Node.js script and stores the images as artifacts.
  • ScreenshotOne does the rendering, so there’s no Chromium to install, cache, or debug on the runner.
  • Comparing against a baseline is an optional extra step at the end.

We’ll start with a manual trigger: you paste a deployment URL and click Run. Wiring it to push straight away is tempting, but a push doesn’t mean your preview deployment is ready yet.

Setup

You’ll need:

  • A deployed site that’s reachable from the public internet
  • A ScreenshotOne access key, saved as an Actions secret called SCREENSHOTONE_ACCESS_KEY (Settings → Secrets and variables → Actions)

Then list the routes to capture in .github/screenshot-paths.txt:

/
/pricing/

Each route is one API request, so start with the pages that matter most. Use plain paths that start with /, and leave out anything sensitive such as tokens in query strings.

The capture script

Create .github/scripts/capture-pages.mjs:

import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const accessKey = process.env.SCREENSHOTONE_ACCESS_KEY;
const baseUrl = new URL(process.env.BASE_URL || "");
if (!accessKey) throw new Error("Missing SCREENSHOTONE_ACCESS_KEY.");
if (baseUrl.protocol !== "https:") throw new Error("BASE_URL must be HTTPS.");
const routes = (await readFile(".github/screenshot-paths.txt", "utf8"))
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
if (!routes.length) throw new Error("No routes in .github/screenshot-paths.txt.");
const outputDir = "artifacts/screenshots";
await mkdir(outputDir, { recursive: true });
const results = [];
const pngHeader = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
for (const [index, route] of routes.entries()) {
const file = `${String(index + 1).padStart(3, "0")}.png`;
try {
const target = new URL(route, baseUrl.origin);
// Stop a route like "//evil.example" from pointing at another site.
if (target.origin !== baseUrl.origin) {
throw new Error("Route must stay on the deployment's origin.");
}
const response = await fetch("https://api.screenshotone.com/take", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Access-Key": accessKey,
},
body: JSON.stringify({
url: target.href,
format: "png",
full_page: true,
viewport_width: 1280,
viewport_height: 800,
device_scale_factor: 1,
cache: false,
}),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const image = Buffer.from(await response.arrayBuffer());
if (!image.subarray(0, 8).equals(pngHeader)) {
throw new Error("The response wasn't a PNG image.");
}
await writeFile(join(outputDir, file), image);
results.push({ route, file, status: "ok" });
console.log(`${route} → ${file}`);
} catch (error) {
results.push({ route, status: "failed", error: error.message });
console.error(`${route} failed: ${error.message}`);
}
}
await writeFile(
join(outputDir, "results.json"),
JSON.stringify({ origin: baseUrl.origin, capturedAt: new Date().toISOString(), results }, null, 2)
);
if (results.some((result) => result.status === "failed")) process.exitCode = 1;

What it does:

  • It keeps going when a route fails. You get every screenshot that worked, plus a results.json that maps files to routes and records the errors. The step still fails at the end, so nobody misses a problem.
  • Fixed viewport and scale. Captures from different runs line up, which you’ll need if you compare them later.
  • cache: false. Every run gets a fresh render, not one cached from an earlier deploy.

Routes are resolved against the origin only. If your app lives under /app/, put that prefix in each route. A path in BASE_URL is ignored on purpose.

The workflow

Create .github/workflows/website-screenshots.yml:

name: Website screenshots
on:
workflow_dispatch:
inputs:
base_url:
description: Public HTTPS URL of the completed deployment
required: true
type: string
permissions:
contents: read
jobs:
capture:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# Always run the capture script from the default branch, whatever ref was dispatched.
- uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
package-manager-cache: false
- name: Capture pages
env:
BASE_URL: ${{ inputs.base_url }}
SCREENSHOTONE_ACCESS_KEY: ${{ secrets.SCREENSHOTONE_ACCESS_KEY }}
run: node .github/scripts/capture-pages.mjs
- name: Upload screenshots
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: screenshots-${{ github.run_id }}-${{ github.run_attempt }}
path: artifacts/screenshots/
retention-days: 7
if-no-files-found: warn

The upload step runs with if: always(), so if two of ten routes fail you still get the other eight and the manifest. If your org requires actions pinned to commit SHAs, pin checkout, setup-node, and upload-artifact accordingly.

Commit the workflow and scripts to your default branch. Manually triggered workflows only show up once they’re there (see GitHub’s docs). Then go to Actions → Website screenshots → Run workflow, paste your deployment URL, and wait a minute. The screenshots appear as a downloadable artifact on the run page.

Trigger it after a preview deploy

Before automating, check one thing: open your preview URL in a private window. If deployment protection asks you to log in, ScreenshotOne will get the same login page. Use a public test deployment for this example.

Once the manual run works, have your deployment pipeline trigger it when a deploy succeeds. If that pipeline can use the GitHub CLI with permission to run workflows:

Terminal window
gh workflow run website-screenshots.yml \
--ref main \
-f "base_url=${DEPLOYED_PREVIEW_URL:?Set the completed preview URL}"

Use your real default branch, and take DEPLOYED_PREVIEW_URL from the deploy step’s output. Don’t build it from a naming pattern you think your host uses.

Some hosting providers send GitHub a deployment_status event, which can trigger the workflow directly. If you go that route, filter for successful deployments to the environment you care about. And be careful with pull requests from forks: don’t run code from an unreviewed PR in a job that has your screenshot secret.

Optional: compare against a baseline

Saving screenshots is not visual regression testing. A “successful” capture might show a 500 page, a login form, or a layout broken in a way only a person would notice. If you want CI to flag changes automatically, compare each capture against an approved baseline.

A minimal version with pixelmatch:

Terminal window
npm install --save-dev pixelmatch pngjs

compare.mjs:

import { readFile, writeFile } from "node:fs/promises";
import pixelmatch from "pixelmatch";
import { PNG } from "pngjs";
const [baselinePath, actualPath, diffPath = "diff.png", tolerance = "0.01"] =
process.argv.slice(2);
if (!baselinePath || !actualPath) {
throw new Error("Usage: node compare.mjs baseline.png actual.png [diff.png] [max ratio]");
}
const maxRatio = Number(tolerance);
if (!tolerance.trim() || !Number.isFinite(maxRatio) || maxRatio < 0 || maxRatio > 1) {
throw new Error("Max ratio must be a number from 0 to 1, such as 0.01 for 1%.");
}
const baseline = PNG.sync.read(await readFile(baselinePath));
const actual = PNG.sync.read(await readFile(actualPath));
if (baseline.width !== actual.width || baseline.height !== actual.height) {
throw new Error(
`Size changed: ${baseline.width}×${baseline.height} → ${actual.width}×${actual.height}. Review it by eye.`
);
}
const { width, height } = baseline;
const diff = new PNG({ width, height });
const changed = pixelmatch(baseline.data, actual.data, diff.data, width, height, {
threshold: 0.1,
});
await writeFile(diffPath, PNG.sync.write(diff));
const ratio = changed / (width * height);
console.log(`${(ratio * 100).toFixed(2)}% of pixels differ`);
if (ratio > maxRatio) process.exitCode = 1;
Terminal window
node compare.mjs baseline.png actual.png diff.png 0.01

The two numbers control different things. threshold decides how different one pixel has to be before it counts as changed. The ratio (1% here) decides how many changed pixels fail the check. Pass that ratio as 0.01, not 1%. There’s no universally right value for either. Capture an unchanged page several times and tune them until the check stays quiet.

A size mismatch fails immediately, because pixel diffs between full-page captures of different heights don’t mean much. Often a single extra line of text pushes everything below it down.

Store approved baselines somewhere reviewed, such as the repo or a protected bucket. Upload the diff images with the run, and update a baseline only when someone has looked at the diff and approved it. Don’t refresh baselines automatically after every deploy, or the check will never catch anything.

Make captures repeatable

Most flaky diffs come from the page, not the code:

  • Wait for real readiness. If the page shows a marker once charts and images have loaded, add wait_for_selector to the request. A fixed delay will eventually be too short.
  • Keep everything else identical. Same viewport, scale, locale, and login state for the baseline and the new capture.
  • Deal with things that always change. Timestamps, carousels, animations, and personalized sections will produce diffs on every run. Hide them for the capture, or seed stable test data.

Remember that artifacts are visible to anyone who can see the repository’s Actions runs, so only capture pages they’re allowed to see. And if you need to log in, click, or fill forms before capturing, use a browser you drive yourself, as in the Playwright screenshot guide.

Read more Screenshot rendering

Interviews, tips, guides, industry best practices, and news.

View all posts
How to take website screenshots with Go

How to take website screenshots with Go

The article examines how you can take screenshots of any URL with Go (a.k.a. Golang) by using Selenium, Puppeteer alternatives, Playwright, or screenshot API as a service.

Read more

Automate website screenshots

Exhaustive documentation, ready SDKs, no-code tools, and other automation to help you render website screenshots and outsource all the boring work related to that to us.