GitHub Actions 之痛:令人头疼的 CI 配置
The Pain That Is Github Actions
Gerd ZellwegerHead of Engineering / Co-Founder | March 17, 2025
过去两周,我大部分时间都在重写我们的 CI 脚本,这次使用的是 GitHub Actions。 这已经是我们第三次重做 CI 设置了 —— 先是 GitHub Actions,然后是 Earthly (我们放弃了它,因为它已经停止维护),现在,不情愿地又回到了 GitHub Actions。
我们的 CI 非常复杂:合并队列、多个 runner(自托管的、blacksmith.sh、GitHub 托管的)、Rust 构建、Docker 镜像以及大量的集成测试。 每次我们合并一个 PR,都会消耗一个小时的 CI 时间,在多个并行 runner 上运行。
我们希望实现一些东西(我们认为这是“良好的软件实践”),但这些需求也并不罕见:
- 所有进入
main
分支的代码都必须通过所有测试。 - 琐碎的错误(格式化、未使用的依赖项、lint 问题)应该自动修复,而不是导致构建失败。
- 我们在 CI 中测试的工件应该与我们发布的工件完全一致。
- CI 应该快速完成(以保持开发者的满意度)。
GitHub Actions 在技术上允许实现所有这些,但配置过程却令人沮丧,充满了隐藏的陷阱、不一致的行为,以及让人怀疑自己选择的调试体验。
使用合并队列强制状态检查的奇怪方式
强制保持 main
分支干净的关键在于 GitHub 的 merge queue,它会在运行 CI 之前将 PR rebase 到 main
分支上。 听起来不错。 但有趣的是:
- 我们需要在进入队列 之前 运行 CI,以自动修复琐碎的问题。
- 我们需要在队列 内部 再次运行 CI,以验证最终的合并结果。
- GitHub Actions 使要求两次运行都通过变得异常困难。
解决方案? 在两个阶段中为 job 指定相同的名称。 就这样。 GitHub 将它们视为相同的检查,因此它们都需要成功。 这是在花费了几个小时调试之后,通过阅读 Stack Overflow post 中的这个回答解决的。 任何其他尝试这样做的方式都会导致在将内容放入队列之前等待状态检查(因此它永远不会启动 job),或者更糟糕的是,即使您希望通过的 job 在合并队列中失败,内容也会被合并。
一个安全噩梦?
几天前,有人 compromised a popular GitHub Action。 响应? “只需将您的依赖项锁定到 hash 值即可。” 除了评论也指出的那样,几乎没有人这样做。
即使不考虑供应链攻击,GitHub 的安全模型对我来说也是一个令人困惑的迷宫:我的观点是,如果我不能轻易理解一个安全模型,那么它最终可能会失败或崩溃。免责声明:我写这篇文章时只是一个对它只有模糊理解的 github actions 用户,因此如果得知它不仅仅是“在事物之上堆积事物直到安全”,我将很高兴,这是我目前的印象。 我非常清楚,为分布式源代码控制提供安全 CI 的问题非常复杂。
在 github 中,有一个名为 GITHUB_TOKEN
的“默认” token。 它的工作方式是,它会被初始化为一些默认权限。 您可以在存储库的设置中设置该默认值(在 Actions -> General -> Workflow Permissions 下)。 以下是 github 文档对此的说明:
如果
GITHUB_TOKEN
的默认权限是受限制的,您可能需要提升权限以允许某些 actions 和命令成功运行。 如果默认权限是宽松的,您可以编辑 workflow 文件以从GITHUB_TOKEN
中删除一些权限。
- Github 文档
删除不必要的权限听起来不错(尽管我认为更好的“默认”是从没有特权开始,并要求用户添加所需的任何内容)。 不幸的是,有 many of them,如果您不是 github 专家,很难清楚地了解它们都在保护什么。
您的 workflow 权限实际上也不依赖于 action 本身。 这是一个这样的实例的示例,我正在使用 softprops/action-gh-release
在 github 上自动创建一个新 release
-name:ReleaseonGitHubif:env.version_exists=='false'uses:softprops/action-gh-release@v2with:tag_name:v${{env.CURRENT_VERSION}}generate_release_notes:truemake_latest:truetoken:${{secrets.CI_RELEASE}}
为什么我需要一个自定义 token? 因为没有它,release 完成了,但不会触发我们的 post-release workflow。 可悲的是,您不会收到任何关于它的指示,直到您最终 find an issue,其中有人遇到了相同的问题,这会将您引向正确的方向。
您还可以在 workflow yaml 文件中提升权限。 在您试图保护的代码中这样做似乎很奇怪。 至少根据 github 文档,有一些限制:
您可以使用 permissions
键来添加和删除 fork 的存储库的读取权限,但通常您不能授予写入权限。 此行为的例外情况是,管理员用户在 GitHub Actions 设置中选择了 Send write tokens to workflows from pull requests 选项。 有关更多信息,请参见 Managing GitHub Actions settings for a repository。
这只是我认为使 github actions 安全模型如此晦涩的众多实例之一的根本原因:有太多的缺陷,以及您必须考虑的例外情况。 显然,该系统非常强大,允许您做很多事情,但它也扩大了破坏事情的攻击面。
据我所知,我并不是孤单的。 我遇到的另一个相同问题的实例是,当我阅读 this paragraph 时,他们建议您不要在公共存储库中使用自托管 runner:
我们建议您仅将自托管 runner 与私有存储库一起使用。 这是因为您的公共存储库的 fork 可能会通过创建一个在 workflow 中执行代码的 pull request,从而在您的自托管 runner 机器上运行危险代码。
- Github 文档
但是,github 还有一个自托管 runner 的设置,来自外部协作者的 pull-request 需要在运行之前获得批准。 对我来说出现的一个实际问题是“自托管 runner 与此设置结合使用是否安全”? 我相信是的,但 github 文档没有说明,并且互联网上的其他地方也没有达成共识。 鉴于存在如此多的复杂性,很难 100% 确信。 即使 github 文档编写者似乎也不再了解他们的安全模型。
Docker 和 Github Actions,一种糟糕的组合
如果您认为 GitHub Actions 已经很糟糕了,请尝试将其与 Docker 混合使用。
GitHub 允许您 run jobs inside a container。 这在理论上很棒 —— 您可以将依赖项预先打包到 dev container 中,而不是每次都安装它们。 在实践中:
- 文件权限经常中断。 container 以一个用户身份构建文件,但 GitHub runner 可能会使用另一个用户(不同的 uid 和 gid)来运行它。 因此,它可能无法访问 container 中的文件或 github 工作区以及临时主机目录(这些目录将被挂载)。
$HOME
目录移动。 您的 dev container 可能会将工具安装到/home/ubuntu
中,但在 GitHub Actions 内部,它突然变成了/github/home
。 依赖于$HOME
中文件的工具可能不再找到它们。- 任何与主机系统交互的 action 现在都可能会中断。 例如,我使用 blacksmith’s sticky disk action 来挂载 NVMe 驱动器以进行缓存(因为 GitHub 缓存限制为 10GB)。 它在 container 内部不起作用,直到 they made a fix for me (感谢 blacksmith.sh 的 Aditya Jayaprakash 在这一天内完成的!)。
同时,container
字段本身具有奇怪的限制。 想要覆盖 entrypoint 吗? 不行。 想要在 container 内部运行 some steps,而在外部运行其他 steps 吗? 不行。
使用 YAML 开发 Workflows
不幸的是,您最终在 YAML 中编写的所有这些逻辑可能会很快变得复杂,并且您一定会犯错。 我在使用 RustRover 作为我的 IDE 编写 YAML 时,它有一些内置的 github YAML linter 检查,这帮助很大。 我仍然希望对所有这些进行更好的静态检查。 你真的无法在本地尝试任何这些(我知道 act 但它只支持你在 CI 中尝试做的东西的一小部分),这无济于事。 我发现调试 CI 的最佳方法是创建一个与您尝试更改的存储库相同的存储库,并执行 git commit -a -m "wip" && git push test-ci branch
,直到 CI 按预期工作。
由于我不想每次进行更改时都运行整个 CI pipeline,因此我试图保持各个 workflow 较小,并在其步骤结束时推送工件,然后后续 workflow 可以下载工件并重新使用它们,而不是从头开始重建所有内容。 这让您可以隔离测试 workflow,因为您可以从之前的运行中下载工件,直到它起作用为止(当然,从之前的运行中下载时,需要为 download-artifact action 提供一个 token,但这可以只是默认 token。 为什么仍然需要提供它,这又是一个未解决的谜团......)。
然后,主 workflow 文件变成调用其他 YAML 文件的链:
jobs: invoke-build-rust:
name: Build Rust
uses: ./.github/workflows/build-rust.yml
invoke-build-java:
name: Build Java
uses: ./.github/workflows/build-java.yml
invoke-tests-unit:
name: Unit Tests
needs: [invoke-build-rust, invoke-build-java]
uses: ./.github/workflows/test-unit.yml
invoke-tests-adapter:
name: Adapter Tests
needs: [invoke-build-rust]
uses: ./.github/workflows/test-adapters.yml
secrets: inherit
invoke-build-docker:
name: Build Docker
needs: [invoke-build-rust, invoke-build-java]
uses: ./.github/workflows/build-docker.yml
invoke-tests-integration:
name: Integration Tests
needs: [invoke-build-docker]
uses: ./.github/workflows/test-integration.yml
invoke-tests-java:
name: Java Tests
needs: [invoke-build-java]
uses: ./.github/workflows/test-java.yml
请注意添加到某些 job 的 secrets: inherit
。 另一个我花了太长时间才弄清楚的陷阱。 每次当我运行整个 CI pipeline 时,事情都无法正常工作,但是当我单独运行这些步骤时,它们会正常工作(这是因为当您从另一个 workflow 调用 workflow 时,secrets 默认情况下不会共享)。
还有很多我想写的陷阱,但是这篇文章已经很长了。 总的来说,我仍然对 our new CI scripts 感到满意,因为它大大减少了我们合并的时间。 我只是希望到达那里的过程花费的时间更少,并且在出现问题时更容易调试。 我想,我希望这里有一些创新。