2410 字
12 分钟
GitHub Actions 配额告警:先把账算错一遍,才发现只有 private 仓库在烧钱

收到一封 GitHub 的告警邮件:

You have used 90% of the Actions minutes included for the account 2,700 min used / 3,000 min included

Pro 账号每月 3000 分钟,已经用了 2700,6 天后重置。任务很清楚:找出谁在烧分钟,砍下来。

但这次排查最值得记的不是优化手法,而是我一开始把账算错了,错得自相矛盾,被用户一句话点破后才挖到真相。把弯路一起记下来,因为那个”真相”是很多人都会踩的认知盲区。

先拉数据:旧端点已经 410 了#

要优化先定位。第一反应是拉账单明细。结果踩了第一个小坑——常用的用户级计费端点已经下线:

Terminal window
gh api /users/<account>/settings/billing/actions
{"message":"This endpoint has been moved.",
"documentation_url":"https://gh.io/billing-api-updates-user",
"status":"410"}

410 Gone。GitHub 在 2025 年的增强计费改造里把它换成了统一的用量端点 /settings/billing/usage

Terminal window
gh api /users/<account>/settings/billing/usage \
--jq '.usageItems[] | select(.product=="actions")'

这个端点返回逐月、逐仓库、逐 SKU 的明细,每条带 quantity(实际分钟)、skuActions Linux / Actions macOS 3-core / Actions Windows)、repositoryName。数据有了,接下来就是算账。

第一遍:一张自相矛盾的表#

GitHub-hosted runner 的分钟数是带乘数的:Linux 1x、Windows 2x、macOS 10x。我顺手按这个把 6 月的用量加权了一遍:

reporunner实际分钟×乘数加权
TranscribeNotemacOS374×103740
blogLinux3037×13037
mouse-keep-aliveWindows4×28

加起来 6785 加权分钟。我据此判断 macOS 上的 CodeQL 是最大头,还顺手去给 TranscribeNote 的 macOS workflow 做了优化。

但这张表有个硬伤我当时没多想:6785 > 3000 的两倍。如果真消耗了这么多,账号早该爆配额、早该收账单了,而不是停在 90% 的告警上。光 blog 一个 Linux 就 3037,已经超过 2700 这个告警数字本身了。

用户直接点破:

有点不太可能,因为 permonth 的总数是 3k。要么你计算错了,要么你数据有噪音。

他是对的。自相矛盾的结论本身就是出错的信号,我却忽略了它继续往下做。

真相:public 仓库的 Actions 是免费的#

回头查证,问题出在一个我默认忽略的前提上:

GitHub Actions 的 included minutes 配额只对 private 仓库计费。public 仓库用标准 GitHub-hosted runner 运行 Actions 完全免费——不计入配额,连那个 macOS 10x 乘数都不适用官方文档)。

所以正确的算法是:拉到 usage 之后,先按仓库的 visibility 过滤,只留 private,再谈乘数。

Terminal window
for r in blog TranscribeNote ask toolbox mouse-keep-alive; do
printf "%-18s " "$r"
gh api "repos/<account>/$r" --jq '.visibility'
done
blog private
TranscribeNote public
ask public
toolbox public
mouse-keep-alive public

真相大白:列表里出现的仓库,只有 blog 是 private。我那张表里 3740 加权分钟的”最大头” TranscribeNote,是个 public 仓库——它跑 macOS Swift 编译,一分钟配额都不占。

按 visibility 过滤后重新算逐月的 private 用量:

月份private(真吃配额)public(免费)
2026-03145621280
2026-063060(100% 来自 blog)3748

3 月那个 public 列高达 21280 加权分钟——是 ask 仓库的 macOS Swift 编译(2128 分钟 ×10)。如果它是 private,早把配额炸穿 7 倍了。但它是 public,所以一分不占。

解读很清楚:这个账号一直扛得住,正是因为所有重活——macOS 编译那些——全都在 public 仓库里。唯一吃配额的就是 blog 一个 private 仓库,6 月 3060 分钟,已经越过 3000。那封 2700/3000 的告警,是几天前的快照。

我第一遍那套 “macOS 10x 是吸血鬼” 的叙事,全部打在免费的 public 仓库上,纯属噪音。

NOTE

教训先写在这:算 GitHub Actions 账,第一步永远是过滤 visibility。public 仓库的用量会出现在 usage 报告里,但它们不消耗配额。把它们算进去,结论必然失真。

blog 的真正浪费:auto-promote 在空转#

锁定 blog 之后,看它 6 月跑了多少 workflow run——692 个。对一个博客来说多得离谱。逐 workflow 数一下次数:

Auto-promote green draft PRs 305
Build and Check 122
Code quality 122
Deploy 54
Secret Scan 54

auto-promote 305 次是绝对大头。要理解它,得先知道 blog 有两条内容进 main 的路:

  1. 本地一个 auto-commit watcher(就是上次被一个文件名空格搞停摆八天的那个),一天十几次直接 git push 内容到 main;
  2. 每天的 roundup bot 造 claude/* draft PR,CI 过了由 auto-promote 合并。

auto-promote 挂在 workflow_run: ["Build and Check"] 上——只要 “Build and Check” 一跑完就触发它。而 “Build and Check” 在每一次 watcher push 都会跑。于是:watcher 直接推内容到 main(根本没有 draft PR)→ Build 跑完 → auto-promote 被触发 → 扫一圈发现 “No candidate draft PRs” → 空转退出。

问题在于:GitHub Actions 每个 job 最低计费 1 分钟,向上取整。一天十几次 watcher push,就是十几次 1 分钟的空转。

修复用一个 job 级 if,让它只在上游 Build 来自 pull_request 时才真正执行:

.github/workflows/auto-promote-drafts.yml
jobs:
promote:
runs-on: ubuntu-latest
if: >-
github.event_name != 'workflow_run' ||
github.event.workflow_run.event == 'pull_request'

关键事实(查证过):job 因为 if: 为 false 被 skip 时,它从未启动,不消耗、不计费任何分钟官方文档)。所以 watcher 那十几次 push 触发的 auto-promote 现在全部 skip = 全部免费,只有真正的 PR 才实跑。

推上去之后,watcher 又推了一次,验证:

Terminal window
gh api ".../actions/workflows/auto-promote-drafts.yml/runs?per_page=4" \
--jq '.workflow_runs[] | "\(.event) \(.conclusion)"'
workflow_run skipped ← watcher push,现在 skip(不计费)
workflow_run skipped
workflow_run success ← if-gate 生效前

缓存:pnpm store + Astro 构建产物#

接着压每次 build 的耗时。原来的 build.yml 每次裸跑 pnpm install,从 registry 重新拉 57 个依赖(含 sharp、mermaid 这些大包)。加两层缓存。

第一层 pnpm store,用 actions/setup-node 自带的 cache: pnpm。有个顺序坑——pnpm/action-setup 必须排在 setup-node 之前,否则 setup-node 找不到 pnpm store path

- uses: pnpm/action-setup@... # 必须在前
with: { run_install: false }
- uses: actions/setup-node@...
with:
node-version: 22
cache: pnpm

第二层是 Astro 自己的构建缓存。Astro 5 的 cacheDir 默认在 node_modules/.astrobuild 时 sharp 优化过的图片按内容哈希缓存在这里。这个博客 src 里有 138 张图、52MB,每次从零优化是大头。用 actions/cache 把它跨 run 持久化:

- uses: actions/cache@...
with:
path: |
node_modules/.astro
.astro
key: astro-${{ runner.os }}-${{ hashFiles('src/**/*.png','src/**/*.jpg','src/**/*.webp','astro.config.mjs','pnpm-lock.yaml') }}
restore-keys: |
astro-${{ runner.os }}-

效果:冷跑(无缓存)145s,暖跑 105s。日志里能看到图片全部命中:

generating optimized images
▶ /_astro/cat-witch-banner.xxx.webp (reused cache entry) (1/112)
▶ /_astro/cat-witch-avatar.xxx.webp (reused cache entry) (2/112)
...

112 张图全部 reused cache entry,每张 +1~2ms 而不是重新跑 sharp。

一个不可变性的坑:cache key 别挂整个 src#

第一版我把 key 挂在 hashFiles('src/**') 上。用户很快发现 cache 列表里堆了两个几乎一样的 14MB 条目。

根因是 actions/cache 的条目按 key 不可变——一旦某个 key 存过,后续同 key 的 save 会被跳过,无法原地更新官方文档)。所以为了让缓存能随新图片更新,key 必须随内容变。但我挂的是整个 src/**,于是每发一篇纯 markdown 文章,src 哈希就变 → 生成一个新的 14MB 条目,哪怕一张图都没动。

修复:key 只挂图片文件,不挂整个 src。纯文字文章图片哈希不变 → 精确命中现有 key → not saving,不再产生新条目;只有真加/改图片才滚动 key。restore-keys 的前缀回退保证每次都从最近的缓存起步,只重优化变了的图。

build 还能更快吗?先量再说#

用户问能不能更快。我没猜,把暖跑的每一步耗时拉出来:

7s Setup Node.js(恢复 245MB pnpm 缓存)
6s Install dependencies
1s Cache Astro(恢复 14MB)
15s Run Astro Check
72s Run Astro Build ← 62%,绝对大头

图片已经全缓存命中了,build 还是 72s——说明瓶颈根本不是图片,是 Vite/Rollup 打包 + 渲染 273 篇文章(expressive-code 的 shiki 全语言高亮、katex 数学公式)。这是跟内容量绑定的固定开销,每次全站静态生成都得重跑,Astro 没有稳定的”只重建改动页”增量构建。

能稳拿的只剩 astro check 那 15s。它对内容改动是冗余的——astro build 本身就校验 content schema 和 import,check 多做的只是 TS 类型检查,而 watcher 推的全是 markdown,碰不到类型。所以把它限定成只在 PR 跑:

- name: Run Astro Check
if: github.event_name == 'pull_request'
run: pnpm astro check

watcher 的高频 push build 从此跳过这 15s,push build 实测从 117s 降到 105s。

再往下,72s 的 build 要再快只能上付费的大 runner(4 核并行 Rollup),但那是付费 SKU、不走免费配额——跟”省配额”的初衷正好相反。所以这里就是免费标准 runner 的地板:~100s = 72s 固定 build + ~28s setup/install/缓存收发。

经验总结#

  • 算 GitHub Actions 账,先过滤 visibility。 public 仓库的 Actions 免费、不计配额、不吃乘数。它们会出现在 usage 报告里,但算进去就会得出错误结论。
  • 自相矛盾的结论是出错的信号。 6785 加权分钟 > 3000 配额却没爆,这个矛盾当时就该让我停下来,而不是继续往下做。被用户点破前我浪费了一整轮。
  • 数据驱动,别猜。 每一步——是哪个 workflow、跑了多少次、每步多少秒、缓存有没有命中——都拉真实的 billing / run / timing 数据来定,而不是凭印象。
  • if: skip 的 job 不计费,是”让挂在高频 workflow_run 上的下游只在真正需要时才烧分钟”的正解,比把逻辑塞进上游 workflow(会耦合部署闸门)安全。
  • 缓存 key 只挂真正要缓存的东西。 actions/cache 不可变,key 挂太宽会让每次构建都堆一个新条目(churn)。
  • 分清能免费拿的优化和要花钱的。 砍空转、加缓存、跳冗余检查都不花钱;再快就得上付费 runner——到这一步就该停,把判断交回给人。
GitHub Actions 配额告警:先把账算错一遍,才发现只有 private 仓库在烧钱
https://blog.lishuyu.app/posts/github-actions-配额告警-只有private仓库计费/
作者
猫猫魔女
发布于
2026-06-25
许可协议
CC BY-NC-SA 4.0