Every repo I've worked on accumulates the same debt. Someone drops a hero image exported straight out of Figma into public/, it's 5 MB, nobody notices in review because the diff just says Binary file added, and six months later the landing page ships 12 MB of images.
Nobody does this on purpose. It happens because there is no feedback loop. Code gets linted, types get checked, tests get run — and assets get waved through.
This post wires an asset check into GitHub Actions so a pull request fails when someone commits a bloated image. Everything runs locally in the job, no upload, no third-party service.
First: decide what you're actually gating
This is where most setups go wrong, so it's worth being precise. There are two different things you might want, and they need different commands.
Pattern A — gate the committed files. Your repo is the source of truth. You want CI to say "this PR adds an image that could be 80% smaller, fix it before merging." The optimized files live in git.
Pattern B — gate the build output. Your CI optimizes assets as part of producing dist/, and you want a sanity check that the optimization step actually did something before you deploy. Nothing optimized gets committed.
Pattern A is what most people mean by "check my images in CI." Pattern B is a pipeline health check. Mixing them up gives you a job that passes when it should fail.
I'll use assetopt (MIT, runs on sharp) because it handles images, CSS, JS and SVG in one command and has CI exit codes built in — but the shape of the workflow applies whatever tool you reach for.
Installing it in the job
No custom action needed, it's a plain npm package:
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g @assetopt/cli
Pattern A: fail the PR on unoptimized files
The command that does this is audit. It walks your assets, and flags a file when either of these is true:
- the file is over a size threshold (defaults: 500 KB images, 100 KB JS, 50 KB CSS/SVG), or
- with
--savings, the file could shrink by more than--thresholdpercent
If any file is flagged, the process exits 1.
name: assets
on: [pull_request]
jobs:
check-assets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install -g @assetopt/cli
- run: assetopt audit ./public --savings --threshold 20
Here's what that prints on a folder with real problems in it:
✖ image logo.png 55.9 KB would save 46.6 KB (-83.3%)
✖ image landscape-export.png 5.2 MB oversized (5.2 MB > 500.0 KB) · would save 4.6 MB (-89.8%)
✖ image mountain-lake.jpg 1009.0 KB oversized (1009.0 KB > 500.0 KB)
✖ image river-canyon.jpg 1.7 MB oversized (1.7 MB > 500.0 KB)
✖ js app.js 2.5 KB would save 1.5 KB (-61.0%)
✖ css main.css 2.2 KB would save 785 B (-34.3%)
✖ svg diagram.svg 2.5 KB would save 1.3 KB (-54.6%)
7 issues found · 0 files clean · 2.2s
Exit code 1, PR blocked, and the reviewer sees exactly which file to fix. landscape-export.png is the classic case — a 5.2 MB export that has no business being in a repo.
The caveat nobody mentions
Look at mountain-lake.jpg in that output. It's flagged as oversized, but there's no "would save" on the line — meaning it's already compressed about as far as it goes (assetopt could only get 16.7% more out of it, under my 20% threshold). It's a 1 MB photo that is genuinely 1 MB of photo.
The size thresholds are dumb by design: they don't know that your full-bleed hero legitimately weighs 900 KB. So a strict audit will flag files you have already decided are fine. You have two outs:
# skip a path entirely (repeatable)
assetopt audit ./public --savings --threshold 20 --exclude "public/hero/**"
or point the audit at the directories where sloppiness actually creeps in (public/icons, public/blog) rather than the whole tree. Do this once when you set the gate up; otherwise the team learns to ignore a permanently red check, which is worse than no check.
Pattern B: gate the build output
Different command, and this is the part I got wrong the first time I wrote this workflow.
--min-savings <percent> fails the job when total savings come in below the threshold. Read that twice, because the intuition runs the other way:
assetopt optimize ./dist --min-savings 5
# exit 1 if the run saved LESS than 5% overall
It is not "fail if my assets are bad." It's "fail if the optimization step barely did anything" — a tripwire for a build that silently stopped optimizing (bad config path, wrong directory, a preset that got dropped). Put it on the build:
- run: npm run build
- run: assetopt optimize ./dist --min-savings 5
- run: ./deploy.sh # ships the now-smaller ./dist
Start the threshold low. On a project whose bundler already squeezes assets, --min-savings 15 fails every single run — 3–5% is a realistic floor.
One trap when optimizing a build directory: if you let the tool convert formats, your built HTML still points at .jpg and you serve 404s. Keep extensions intact and just recompress in place:
{ "images": { "outputFormat": "keep" } }
Only convert to WebP/AVIF when you control the markup that references the filenames.
Cache between runs
A dry-run pipeline over a large public/ is not free — it decodes and re-encodes every image to find out what it would save. assetopt writes a manifest (.assetopt-cache.json) so unchanged files are skipped next time. Persist it:
- uses: actions/cache@v4
with:
path: optimized/.assetopt-cache.json
key: assetopt-${{ hashFiles('public/**') }}
restore-keys: assetopt-
The restore-keys fallback matters more than the exact key: a PR that changes one image should restore the previous manifest and only reprocess that one file.
Make the check required
A failing job doesn't block anything by itself. In Settings → Branches → branch protection rule → Require status checks to pass, select the check-assets job. Now the PR genuinely can't merge.
Tune it locally before you commit the workflow — the exit code is identical on your machine:
npm install -g @assetopt/cli
assetopt audit ./public --savings --threshold 20
echo $?
Run it on your repo as-is. If it comes back with a wall of red, don't crank the threshold up to make it green — fix the three files that actually matter, exclude the ones you've consciously accepted, and then turn the gate on. A quality gate is only worth having if a red check means something.
assetopt is open source and MIT: github.com/Nathmaxx/assetopt. Happy to hear how other people gate this — especially anyone who's found a decent way to handle "this image is legitimately huge" without a growing exclude list.
Top comments (0)