All posts

Compress images in GitHub Actions

PiPic team
Guides
npx @pipic/cli ./assets --replace --json
✓ compressed · committed back to the PR

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 requirednpx @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:

CodeMeaningIn a workflow
0Everything succeededStep passes
1Some files failedStep fails — check the log for which
2Usage errorYour command is wrong; fix the workflow
3Sign-in requiredPIPIC_TOKEN is missing, wrong, or revoked
4Monthly quota exhaustedSee 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 paths filter 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/heroes beats ./assets if 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.

Try it on your own images
Free, no sign-up, never stored.
Compress images