在 GitHub Actions 里压缩图片
有人往 assets/ 里丢了一张 4 MB 的头图,没人注意到,直到两周后 Lighthouse 分数掉了
下来。解决办法不是往 code review 清单里再加一条,而是在流水线里加一步——让图片在发布
之前就先瘦下来。
完整的工作流就这些:
# .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
那个 paths 过滤器是在干实事的。没有它,这个 job 会在每个 pull request 上都跑一遍,
而每一次运行都会走完整个 assets/ 目录——为什么这件事要紧,下面会说。
令牌
CI 里没有浏览器,所以 pipic login 这条路走不通。去账号页
建一个令牌,存成名为 PIPIC_TOKEN 的仓库 secret,CLI 会自己从环境变量里读到它——
它的优先级高于凭据文件,所以根本不存在"要登录"这件事。
注意 pipic token create 是故意把你送到网页上去建,而不是在终端里直接铸一个出来。
一份被偷走的 CI 凭据,不该有能力再铸出新的凭据。
那个不能省的 flag
--replace 是就地覆盖,-o <dir> 是写到别处。两者互斥,而且必须二选一——
npx @pipic/cli ./assets 两个都不带的话,会在发出任何一个请求之前就以用法错误退出。
在一个要把结果提交回去的工作流里,你要的是 --replace:工作区里的文件就是你接下来要
提交的文件。
读懂输出
--json 会为每个文件打印一行 NDJSON 对象。在工作流里你多半不需要解析它——
git diff --quiet 已经告诉你有没有东西变了——但它会留在 job 日志里,而那正是某次运行
出乎你意料时你要去翻的地方:
{"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 不是失败。只有结果确实更小的时候文件才会被写回去,所以一张已经优化过的图片
会报 skipped 并附上原因——而 git diff 对它保持干净。
真正值得拿来搭东西的是退出码:
| 退出码 | 含义 | 在工作流里意味着 |
|---|---|---|
| 0 | 全部成功 | 这一步通过 |
| 1 | 部分文件失败 | 这一步失败——去日志里看是哪些 |
| 2 | 用法错误 | 命令写错了,去改工作流 |
| 3 | 需要登录 | PIPIC_TOKEN 缺失、写错,或已被吊销 |
| 4 | 当月配额用尽 | 见下文 |
CI 里出现 3,几乎总是意味着 secret 没送到这一步——env: 块里打错了字,或者这个
secret 对来自 fork 的 pull request 不可见。
配额这笔账
这是最容易让人栽跟头的地方,所以直说:每月额度算的是请求数,不是变化的文件数。 CLI 交给服务端的每一张图片都要算一次,包括那些原样返回"已经够小了"的。
本地没有任何"这张压过了"的记忆。你把它指向一个目录,它就处理那个目录里的全部文件, 每一次运行都是。
所以一个在每个 pull request 上都走一遍 200 张图 assets/ 目录的 job,每跑一次就花掉
200 次额度——而免费额度是每月 100 次。两个可操作的结论:
- 把
paths过滤器收紧。 它能让这个 job 在没有图片变动时压根不跑,而那是大多数 时候的情况。 - 把命令指向尽可能窄的目录。 如果改动都发生在
./assets/heroes,就别写./assets。
如果你想让这个 job 只碰这个 pull request 里真正改动过的图片,那就把它们逐个传进去, 而不是甩给它一个目录:
- 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
这就把"每次运行的固定成本"变成了"与 diff 成正比的成本",而在大多数仓库上,那不过是 寥寥几个文件。
退出码 4 表示这个月的额度用完了。不会自动扣任何费用—— Pro 把额度提到 5,000,而在 pipic.cc 网页上压缩无论如何都是 免费且不限量的。
这个工作流不做什么
它不会卡住 pull request。没有"只检查、未优化就失败"这种模式——CLI 负责压缩,怎么处理
结果由你决定。如果你想要一道硬门禁,形状是这样:先跑压缩,然后在 git diff 非空时让
这一步失败,让作者自己去提交优化后的文件。
- 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
}
这是拿便利换控制权:没有任何东西会以作者的名义被提交,但总得有个人去把这活干了。