收到一封 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 了
要优化先定位。第一反应是拉账单明细。结果踩了第一个小坑——常用的用户级计费端点已经下线:
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:
gh api /users/<account>/settings/billing/usage \ --jq '.usageItems[] | select(.product=="actions")'这个端点返回逐月、逐仓库、逐 SKU 的明细,每条带 quantity(实际分钟)、sku(Actions Linux / Actions macOS 3-core / Actions Windows)、repositoryName。数据有了,接下来就是算账。
第一遍:一张自相矛盾的表
GitHub-hosted runner 的分钟数是带乘数的:Linux 1x、Windows 2x、macOS 10x。我顺手按这个把 6 月的用量加权了一遍:
| repo | runner | 实际分钟 | ×乘数 | 加权 |
|---|---|---|---|---|
| TranscribeNote | macOS | 374 | ×10 | 3740 |
| blog | Linux | 3037 | ×1 | 3037 |
| mouse-keep-alive | Windows | 4 | ×2 | 8 |
加起来 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,再谈乘数。
for r in blog TranscribeNote ask toolbox mouse-keep-alive; do printf "%-18s " "$r" gh api "repos/<account>/$r" --jq '.visibility'doneblog privateTranscribeNote publicask publictoolbox publicmouse-keep-alive public真相大白:列表里出现的仓库,只有 blog 是 private。我那张表里 3740 加权分钟的”最大头” TranscribeNote,是个 public 仓库——它跑 macOS Swift 编译,一分钟配额都不占。
按 visibility 过滤后重新算逐月的 private 用量:
| 月份 | private(真吃配额) | public(免费) |
|---|---|---|
| 2026-03 | 1456 | 21280 |
| 2026-06 | 3060(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 305Build and Check 122Code quality 122Deploy 54Secret Scan 54auto-promote 305 次是绝对大头。要理解它,得先知道 blog 有两条内容进 main 的路:
- 本地一个 auto-commit watcher(就是上次被一个文件名空格搞停摆八天的那个),一天十几次直接
git push内容到 main; - 每天的 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 时才真正执行:
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 又推了一次,验证:
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 skippedworkflow_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/.astro,build 时 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 checkwatcher 的高频 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——到这一步就该停,把判断交回给人。