GitHub Actions 对我来说当了好几年黑盒。PR 有人检查,release 自动发布,绿色对勾按时出现——反正有人配好了,能用,我就从来没打开看过。这周我终于决定认真学一下。方法很简单:官方 Quickstart 扫一遍打个底,然后把我们一个内部 repo 里的两个真实 workflow 文件逐行读懂,读到每一行都说得出为什么。
结果第二步的收获远超任何教程。不只是因为真实文件比 hello-world 教得快——而是因为读到第一个文件第 25 行左右的时候,我发现了一个 bug。它躺在一个每个 PR 都会跑的 workflow 里,从来没让任何一次 CI 变红,安静地等着某个条件成熟,然后一次性弄挂整个 repo 的所有 PR。
要讲清楚这个 bug,得先把概念铺一遍。巧的是,这些概念正好就是读懂任何 workflow 文件需要的全部。
心智模型:一个文件,四层结构
GitHub Actions 的 workflow 就是 .github/workflows/ 下的一个 YAML 文件。整个文件挂在四个概念上:
触发器(on:) —— 什么事件启动这个 workflow。最常见的两个:
on: pull_request: types: [opened, synchronize, reopened]每个 PR 都跑(PR 上每次新 push 也跑——那就是 synchronize);另一个 on: push: branches: [main] 是代码进 main 时跑。还有 cron 定时、手动触发等等,但这两个覆盖了大部分 CI。
权限(permissions:) —— GitHub 给每次运行临时签发的 token 的权限范围。只读检查的 job 给 contents: read;要推 tag 的 release job 才给 contents: write。最小权限,直接写在文件里。
Jobs —— 工作单元,每个 job 一台全新虚拟机(runs-on: ubuntu-latest)。多个 job 默认并行。
Steps —— job 内部的执行序列。每个 step 要么是 uses:(调用别人发布好的 action,比如 actions/checkout 拉代码,参数用 with: 传),要么是 run:(直接在 VM 上执行 shell 命令)。给 step 加个 id:,后面的 step 就能用 ${{ steps.myid.outputs.something }} 读它的输出。
真的,大部分就这些了。靠这四层,我把整个 PR 检查 workflow 读下来了:PR 触发、拉代码、装包管理器、装依赖,然后 lint、类型检查、测试。任何一步退出码非零,job 失败,PR 上一个红叉。
但有一个 step 让我停住了:
- name: Set up Python run: | uv python install 3.12 export UV_INDEX_PRIVATE_PYPI_USERNAME=${{ secrets.PYPI_USERNAME }} export UV_INDEX_PRIVATE_PYPI_PASSWORD=${{ secrets.PYPI_PASSWORD }}
- name: Install dependencies run: | uv sync装 Python,export 私有包仓库的凭证,下一步 sync 依赖。读起来顺理成章——先设变量,再用变量。我差点就翻过去了。
这两行 export 是死代码,从写下的那天起就是。要真正看懂为什么——是推导出来,不是背规则——得完全离开 GitHub Actions,钻到操作系统怎么管进程那一层去。现在就下去看看。
第一层:环境变量到底是个什么东西
先把 CI 忘掉,这一层是纯粹的 Linux/macOS 知识。
机器上每个进程都带一块自己的数据,其中一部分叫环境变量表——一堆 key=value。管这张表的规则只有两条,也只需要这两条:
- 继承是拷贝。 进程 A 启动进程 B 时,B 拿到的是 A 环境变量表的一份拷贝。是拷贝,不是共享引用。
- 拷贝单向、一次性。 发生在 B 被创建的那一瞬间。之后 A 改自己的表 B 看不到,B 改自己的表 A 也看不到。子进程永远无法修改父进程的环境——操作系统压根没提供这个系统调用。数据流向只有一个:父 → 子,创建时,一次。
再看两个人人天天敲、很少细想的命令:
export —— shell 里单写 FOO=bar,只是 shell 自己的内部变量,连它的子进程都看不到。export FOO=bar 是把它标记为”放进我的环境变量表”,这样之后启动的子进程才能继承。整个功能就这么大。它能影响到的范围,就是当前这个 shell 进程,加上它之后生出来的孩子——再远一步都够不着。
PATH —— 毫无魔法,就是个普通环境变量,值是冒号分隔的目录列表。你敲 uv 时,shell 按顺序在这些目录里找一个叫 uv 的可执行文件,找到第一个就运行。所以你这辈子见过的每一次 “command not found”,无非就是两种情况:文件不在磁盘上,或者在磁盘上、但它待的目录不在 PATH 里。
这一层就这些。往下的一切都能从这两条规则推出来。
第二层:一个 job 跑起来,进程树长什么样
GitHub Actions 的 job 运行时,那台 VM 上的进程树是这样的:
runner process (long-lived, orchestrates the whole job)├── step 1's shell (a bash process) ← destroyed when the step ends├── step 2's shell (a NEW bash) ← destroyed when the step ends└── step 3's shell (yet another new bash) ...每个 run: step,runner 都把你的命令写进一个临时脚本文件,然后启动一个全新的 bash 进程去执行。现在用第一层的规则推导这个 bug:
- step 2 的 bash 的环境变量表从谁拷贝?从 runner(它爹)——规则 1。
- step 1 里的
export FOO=bar改的是 step 1 那个 bash 自己的表。runner 的表从头到尾没被碰过——规则 2,儿子改不了爹。 - step 2 的 bash 拷贝 runner 的表。runner 表里没有
FOO。所以 step 2 里没有FOO。
所以 export 跨不了 step——而且注意,GitHub 并没有设计什么隔离机制来实现这一点。这就是进程的默认行为。GitHub 什么都没做,结果就是这样。这也是那段 workflow 读起来那么自然却依然是错的原因:它是按”所有 step 共享一个 shell”写的,而实际上每个 step 都是一个你看不见的父进程新生的孩子。
下一步的 uv sync 跑在一个从来没听说过那些凭证的进程里。
第三层:官方机制是怎么把状态”偷渡”过去的
既然子进程改不了父进程,那 step 1 的信息到底怎么进入 step 2 的环境?可用的招只有一个:让爹当中间人,通过文件跟爹说话——因为磁盘不属于任何进程,也比任何进程都活得久。
$GITHUB_ENV —— runner 启动每个 step 前,塞给它一个叫 GITHUB_ENV 的环境变量,值是一个临时文件的路径。你的 step 往这个文件里写 MY_VAR=hello 这样的行。step 结束后,runner 读这个文件,把内容加进自己的”注入清单”。启动下一个 step 的 bash 时,照常拷贝自己的环境,再把清单里的都加上。机制本质:孩子在磁盘上给爹留言,爹转告下一个弟弟。
echo "MY_VAR=hello" >> "$GITHUB_ENV" # visible as $MY_VAR in every later step$GITHUB_PATH —— 同一机制的 PATH 特化版。往里写一行目录路径,runner 在启动后续每个 step 前把它拼到 PATH 前面。这也终于解释了一件曾经让我困惑的事:既然 step 之间进程隔离,uv 本身怎么从 setup-uv 那个 step 活到下一个 step 的?因为那个 action 干的正好是两件事:把二进制写到磁盘(磁盘天然持久,不需要任何技巧),再把安装目录追加进 $GITHUB_PATH(让后续每个 step 新建的 PATH 都找得到它)。没有任何东西被 export。是 runner 在每个 step 前刻意重建了世界。
Workflow 顶层 env: —— 比上面两个都简单,因为它压根不经过 step。runner 从 YAML 里读到这些 key=value,直接放进自己的环境变量表。然后规则 1 包办一切:它之后生的每一个 step bash 都自动继承。这就是顶层 env: 看起来”全局生效”的原因——不是广播给各个 step,而是爹的基因,人人有份。
env: UV_INDEX_PRIVATE_PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} UV_INDEX_PRIVATE_PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}这个块就是我们那个 bug 的正确修法——凭证是静态的,每个 step 都该看到,所以它属于爹。我们另一个 workflow(release 那个)恰恰就是这么写的,完全正确。PR workflow 里那个坏掉的版本,八成是一次只搬了意图、没搬机制的复制粘贴。
检验模型:五道自测题
学了个模型,要是不能拿来预测点什么,那只是多背了几个名词。下面五道题,建议先自己答一遍,再往下看。
Q1:step 1 里 cd /some/dir,step 2 的工作目录是哪?
repo 根目录,不是 /some/dir。工作目录也是进程状态,跟 export 的变量同命——随 step 1 的 bash 一起死。step 2 是新进程,继承 runner 的默认目录。
Q2:step 1 里 echo hi > /tmp/x.txt,step 2 读得到吗?
读得到。文件在磁盘上,磁盘跨进程。同一个 job 的所有 step 共享一台 VM。
Q3:同一个 step 里连写 export FOO=1 和 echo $FOO,能打出 1 吗?
能。同一个 bash 进程,正常 shell 行为。这正是这个 bug 最阴险的地方:如果 uv sync 恰好写在 export 同一个 step 里,代码就是对的。隔一条 step 边界,代码就从能跑变成死的——而且肉眼完全看不出来。
Q4:${{ secrets.XXX }} 和环境变量什么关系?
没关系——它在更早的一层。${{ }} 是 runner 在生成那个临时脚本文件时做的文本替换,bash 还没启动,脚本里已经是明文值了。所以这个语法在哪都能用(if:、with:、env:、run:):它是模板渲染,不是运行时求值。
Q5:那 ${{ steps.release.outputs.tag }} 又是什么?
第三条通道:$GITHUB_OUTPUT,$GITHUB_ENV 的兄弟机制——同样是给爹留言的文件,目的地不同。GITHUB_ENV 注入后续 step 的 shell 环境(shell 里直接 $MY_VAR);GITHUB_OUTPUT 进的是 runner 的上下文对象,在 YAML 层用 ${{ steps.<id>.outputs.<key> }} 引用。数据传给 YAML 层 → outputs;传给 shell 层 → GITHUB_ENV。
一张总表
| 你想跨 step 传的东西 | 正确机制 | 为什么行 |
|---|---|---|
| 环境变量(全局固定值,如凭证) | 顶层/job 级 env: | 进了 runner 的环境,所有子进程继承 |
| 环境变量(运行时算出来的) | echo "K=V" >> "$GITHUB_ENV" | 文件中转,runner 注入后续 step |
| 可执行工具 | 装到磁盘 + $GITHUB_PATH | 磁盘持久 + runner 每步重建 PATH |
| 给后续 step 的 YAML 表达式用的数据 | $GITHUB_OUTPUT + steps.<id>.outputs | runner 的上下文对象 |
| 文件/构建产物(同 job 内) | 直接写磁盘 | 同一台 VM |
| 文件跨 job | actions/upload-artifact / download-artifact | 不同 job = 不同 VM,连磁盘都不共享 |
最后一行是躲在这个坑后面的下一个坑:step 之间共享磁盘,但 job 之间什么都不共享——各自一台全新 VM。以后看到 build job 通过 upload/download artifact 把产物交给 deploy job,就是这个原因。
整张表可以压缩成一句话:进程死,内存清零;要跨进程,走文件或走爹。 GitHub Actions 里所有传值机制,都是这句话的变体。
为什么它从来没报过错
最有意思的部分来了。这个 workflow 每个 PR 都跑,它传丢的凭证是用来登私有包仓库的。为什么 uv sync 从来没吐过一次 401?
因为项目配置里私有仓库声明了 explicit = true——包管理器只在依赖被显式指到这个仓库时才去联系它。而这个 repo 目前的所有依赖恰好全来自公共 PyPI。坏掉的凭证传递,和需要凭证的仓库,两条线从来没相交过。两处各自安静地错着,错的方向恰好互补,加起来就是每一次都绿的对勾。
它将来会怎么爆,剧本其实已经写好了:第一次有人从私有仓库加依赖的那天,所有 open PR 同时开始报认证错误。而撞上的那个人会看到 export CREDENTIALS 就明晃晃地写在 workflow 文件里,看起来完全正确,然后跑去排查 secret 过期、仓库故障——因为真正的 bug 是一个进程作用域的细节,不知道”step 不共享 shell”的人根本不可能看见它。
修复一共五行:删掉两行死 export,顶部加上 env: 块。修复只是五行代码的事,真正值钱的是找到它的过程。
一点感想
这次最大的触动其实不在 GitHub Actions,而在下面那层。整个 bug 的解释里,真正干活的知识全是操作系统的:进程、环境变量表、父子拷贝、PATH 查找。GitHub Actions 只是把这些规则原样摆在了台面上。这种感觉以前也有过几次——docker 的隔离、shell 的管道、git 的对象模型,追到底都是同一批基础概念在换着衣服出场。操作系统的知识是一切知识的基石——这话以前听着像句口号,这回是实打实地领教了。
顺便安利:MIT 的 The Missing Semester 真是百看不厌。shell、环境变量、进程、dotfiles——每次重看都能在”早就知道”的地方捡到新东西,因为你带着新的 bug 回去看旧的课。
给过去的自己三句话
哪条当时最让我意外,就放在最前面:
- CI 全绿不代表 CI 是对的。 只代表坏掉的路径还没被走到。这个 bug 的通过率是 100%。
- 该内化的概念不是 GitHub Actions 的概念,是操作系统的进程模型。 环境变量表在进程创建时父拷贝给子,单向、一次性。这条扎实了,step 隔离、
$GITHUB_ENV、$GITHUB_PATH、顶层env:、跨 job artifact,全部从”要背的规则”变成”能推的结论”。 - 与其再看一篇教程,不如去读自己 repo 里的 workflow。 真实文件二十分钟内就会逼你过一遍触发器、权限、secrets、
usesvsrun、step outputs——而且跟教程不一样,它们还可能是错的——错得特别有教学价值。我们的就是。
下一篇我想拆那个 repo 里的第二个 workflow:全自动语义化版本和发布,没有任何人类挑版本号。那篇已经写出来了——不过在读它之前,你可能想先看看我接着往下读时又挖出了什么。