Compress images in GitHub Actions
Someone drops a 4 MB hero image into assets/ and nobody notices until the
Lighthouse score drops two weeks later. The fix isn't a code review checklist —
it's a step in the pipeline that shrinks the image before it ships.
Here's the whole thing:
# .github/workflows/optimize-images.yml
name: Optimize images
on:
pull_request:
paths:
- 'assets/**.png'
- 'assets/**.jpg'
- 'assets/**.jpeg'
- 'assets/**.webp'
- 'assets/**.avif'
permissions:
contents: write
jobs:
compress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Compress
env:
PIPIC_TOKEN: ${{ secrets.PIPIC_TOKEN }}
run: npx @pipic/cli ./assets --replace --json
- name: Commit if anything shrank
run: |
if git diff --quiet; then
echo "Nothing to commit — images were already optimal."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "chore: compress images"
git push
The paths filter is doing real work there. Without it the job runs on every
pull request, and every run walks the whole assets/ directory — which matters
for reasons we'll get to.
The token
CI has no browser, so pipic login is not an option. Create a token on your
account page, store it as a repository secret named
PIPIC_TOKEN, and the CLI picks it up from the environment — it takes priority
over the credentials file, so there's nothing to log in to.
Note that pipic token create deliberately sends you to the web page rather
than minting one from the terminal. A stolen CI credential shouldn't be able to
mint fresh ones.
The flag you can't omit
--replace overwrites in place; -o <dir> writes elsewhere. They're mutually
exclusive and one of them is required — npx @pipic/cli ./assets with
neither exits with a usage error before sending a single request.
In a workflow that commits results back, --replace is what you want: the
files in the working tree are the files you're about to commit.
Reading the result
--json prints one NDJSON object per file. In a workflow you mostly don't need
to parse it — git diff --quiet already tells you whether anything changed —
but it lands in the job log, which is where you'll look when a run surprises
you:
{"file":"assets/hero.png","status":"ok","before":2411233,"after":486201,"saved":1925032}
{"file":"assets/logo.webp","status":"skipped","before":18944,"after":18944,"saved":0,"error":"not smaller — original kept"}
skipped is not a failure. Files only get written back when the result is
actually smaller, so an already-optimised image reports skipped with the
reason — and git diff stays clean for it.
Exit codes are the part to build on:
| Code | Meaning | In a workflow |
|---|---|---|
| 0 | Everything succeeded | Step passes |
| 1 | Some files failed | Step fails — check the log for which |
| 2 | Usage error | Your command is wrong; fix the workflow |
| 3 | Sign-in required | PIPIC_TOKEN is missing, wrong, or revoked |
| 4 | Monthly quota exhausted | See below |
A 3 in CI almost always means the secret didn't reach the step — a typo in the
env: block, or a secret that isn't available to pull requests from forks.
The quota maths
This is the part that catches people, so here it is plainly: the monthly allowance counts requests, not changed files. Every image the CLI hands to the server costs one, including the ones that come back "already small enough".
There's no local memory of what was compressed before. Point it at a directory and it processes everything in that directory, every run.
So a job that walks a 200-image assets/ folder on every pull request spends
200 of your monthly allowance per run — and the free tier is 100 a month. Two
practical consequences:
- Keep the
pathsfilter tight. It stops the job from running when no images changed, which is most of the time. - Point the command at the narrowest directory you can.
./assets/heroesbeats./assetsif that's where the churn is.
If you want the job to touch only the images changed in that pull request, pass them explicitly rather than handing over a directory:
- name: Compress changed images
env:
PIPIC_TOKEN: ${{ secrets.PIPIC_TOKEN }}
run: |
FILES=$(git diff --name-only --diff-filter=d origin/${{ github.base_ref }}... \
-- 'assets/**.png' 'assets/**.jpg' 'assets/**.jpeg' 'assets/**.webp' 'assets/**.avif')
if [ -z "$FILES" ]; then
echo "No image changes."
exit 0
fi
echo "$FILES" | xargs npx @pipic/cli --replace --json
That turns a fixed cost per run into a cost proportional to the diff, which on most repositories is a handful of files.
Exit 4 means you're out for the month. Nothing is charged automatically — Pro raises the allowance to 5,000, and compressing on pipic.cc stays free and unlimited either way.
What this workflow doesn't do
It doesn't gate the pull request. There's no "check only, fail if unoptimised"
mode — the CLI compresses, and you decide what to do with the result. If you
want a hard gate, the shape is: run the compression, then fail the step when
git diff is non-empty, and let the author commit the optimised files
themselves.
- name: Fail if images weren't optimised
run: |
git diff --quiet || {
echo "::error::These images can be compressed further. Run pipic locally and commit."
git diff --stat
exit 1
}
That trades convenience for control: nothing gets committed on the author's behalf, but somebody has to go do it.