GitHub Actions Workflow Lockfiles:只锁 uses@SHA 为什么还不够?
GitHub Actions Workflow Lockfiles:只锁 uses@SHA 为什么还不够?
把 GitHub Actions 里的 uses: some-org/action@v2 改成完整 Commit SHA,是常见的供应链加固动作。但如果这是一个 Composite Action,内部又调用另一个 @v1 Action 呢?顶层那串 SHA 没变,下游的 Tag 却仍可能移动。只检查工作流 YAML,我们未必知道 CI 最终会执行几层第三方 Action。
GitHub 在 Workflow dependency lockfiles 公测公告中给出了新的选择:保留易读的 uses 引用,把直接及传递 Action 依赖解析到 .github/workflows/actions.lock。官方表示,纳入 Lockfile 的 Workflow 运行时会使用锁定的 Commit,并验证来源仓库身份。
如果已经做了 uses@SHA,是否还要上 Lockfile?我的判断是:先看依赖图,再决定是否迁移。 新机制补的是传递依赖的可审计清单和仓库身份,而不是给已有 SHA 再加一层装饰;被锁定的代码是否安全、CI Token 权限是否最小,仍得另外检查。
| 你现在的 CI 情况 | 建议 | 原因 |
|---|---|---|
| 直接 Action 已固定完整 SHA,引用少、没有复杂二级调用 | 先盘点,暂缓整仓切换 | 新增的锁文件维护成本未必能换来明显收益 |
Composite Action 内层继续引用 Action,尤其有 @v1、@main | 优先试点一条非关键 Workflow | 可以把传递依赖解析进可审查的锁文件 |
| 多仓库共享 Reusable Workflow | 先核对调用方和被调用仓库各自的责任 | 外部 Reusable Workflow 的内部依赖由定义仓库锁定 |
| 组织已启用 Allowed Actions / SHA Pinning 强制策略 | 先复核允许列表,再试点 | 锁文件接入后可能按锁定 SHA 匹配策略,影响 CI |
这些条件可以同时出现,不是四选一。下面的机制与命令采用 GitHub 官方公测说明和 gh-actions-lock v0.2.0 为基线。
直接 SHA 固定入口,不等于锁住整个依赖图
假设 .github/workflows/ci.yml 通过完整 Commit SHA 使用一个 example-org/build Action,而它的 action.yml 内部调用 example-org/upload@v1。顶层 Action 的源代码已固定,内部 @v1 所指向的代码却可能随 Tag 改变。
| 依赖类型 | 直接写 uses@SHA | 纳入 Workflow Lockfile 后 |
|---|---|---|
| 当前 Workflow 直接调用的第三方 Action | 固定这一条入口版本 | 记录并校验 Commit 与仓库身份 |
| Composite Action 内部继续调用的其他 Action | 不会自动替下游可变引用上锁 | 官方规定覆盖传递 Action 依赖 |
| 跨仓库 Reusable Workflow | 可固定调用入口 | 锁定跨库调用,但内部依赖由被调用仓库负责 |
同一仓库的 uses: $/… | 对应当前仓库运行 Commit | 自身已固定,不需要独立 Lockfile Pin |
run: 动态下载脚本、外部镜像 Tag | 不属于这条 uses 引用的保证 | 不能因为有 Lockfile 就视为已审计 |
表格第三行是实际迁移时最容易忽略的责任边界。GitHub 官方概念文档规定,一个仓库的锁文件覆盖该仓库定义的 Workflow,并扩展到 Action 的传递依赖;但是外部 Reusable Workflow 的内部 Action 依赖,由定义它的仓库自己的锁文件覆盖。业务仓库锁住公共部署工作流的入口,不表示业务仓库也对那个部署仓库的每一层 Action 做了锁定。
如果多个项目复用中心化的部署 Workflow,比较合理的检查顺序是先看共享仓库如何维护内部 Action 和 Lockfile,再检查业务仓库如何锁定它的调用版本。调用方仅有一行 uses,不代表执行时的信任关系也只有一层。
actions.lock 记录的是提交和仓库身份
打开 actions.lock,先会看到 workflows 和 dependencies 两张映射。前者按 Workflow 路径列出依赖 Pin,后者记录每个 Pin 解析到的 ref、commit、owner_id 和 repo_id;Composite Action 内部调用其他 Action 时,还会在依赖项中出现 uses。
下面这段是 GitHub 官方格式仓库固定提交中的 benchV003 测试夹具。它采用完整的 v0.0.3 结构,夹具中 aaaa…、bbbb… 是用于解析测试的占位 SHA,而非线上生成的真实锁定记录:
version: v0.0.3
workflows:
.github/workflows/ci.yml:
- actions/checkout@v4
- actions/setup-go@v5
- actions/cache@v4
dependencies:
actions/checkout@v4:
hostname: github.com
ref: v4
commit: sha1-11bd71901bbe5b1630ceea73d27597364c9af683
owner_id: 44036562
repo_id: 197814629
actions/setup-go@v5:
hostname: github.com
ref: v5
commit: sha1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
owner_id: 44036562
repo_id: 249058325
actions/cache@v4:
hostname: github.com
ref: v4
commit: sha1-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
owner_id: 44036562
repo_id: 251882839
uses:
- actions/checkout@v4
workflows 中的清单包含直接和传递 Pin,dependencies.actions/cache@v4.uses 则展示这段测试数据里的直接子依赖关系。它们是两种不同的视图。这个夹具显式写了 hostname: github.com;在普通 github.com 仓库上,生产工具通常会省略所属主站的 hostname,不应把测试字段数量当成每份生成文件的固定要求。
相应的代码路径也能找到:固定提交 d5dca22 的 state_marshal.go 依次写出这些字段(Go 源码节选):
a := file.Dependencies[k]
entry := &yaml.Node{Kind: yaml.MappingNode}
// 此处省略对 hostname 的处理分支
if a.Ref != "" {
addQuotedField(entry, "ref", a.Ref)
}
if a.Commit != "" {
addQuotedField(entry, "commit", a.Commit)
}
addIntField(entry, "owner_id", a.OwnerID)
addIntField(entry, "repo_id", a.RepoID)
Commit 解决了“究竟是哪份代码”的问题,数字形式的仓库身份则进一步回答“仓库名称背后是不是原先锁定的那个仓库”。官方把身份匹配列为运行时校验的一部分,这在仓库转移、重命名等情形下尤其重要。
还有个版本坑:GitHub 概念文档展示的示例仍写着 version: v0.0.2,但我核对的 gh-actions-lock v0.2.0 Release 已将 Lockfile Schema 升为 v0.0.3;格式仓库的固定 README也说明了这个版本。CLI 版本号和 Lockfile Schema 版本号不是同一个东西。因此以 CLI 生成结果为准,审查时也要确认 Schema 版本,而不是直接照搬概念页的 YAML 示例。
接入之前,先把工具会改写什么看清楚
比起直接对整座 CI 仓库启用新策略,我更倾向于在独立分支挑一条非关键 Workflow 试点,先确认它会改动哪些文件。官方的 迁移指南支持按文件纳入。假定 GitHub CLI 已安装、已登录并可读取所引用的仓库,从测试分支的仓库根目录可以这样做。示例固定 gh-actions-lock v0.2.0,旧版扩展不保证支持同样的参数:
# 固定本文检查的扩展版本
gh extension install github/gh-actions-lock --pin v0.2.0
gh actions-lock .github/workflows/ci.yml
# 同时检查 workflow、本地 action.yml 的修改与新文件
git status --short
git diff
cat .github/workflows/actions.lock
# 只校验试点 Workflow,避免未迁移的其他文件干扰
gh actions-lock --verify .github/workflows/ci.yml
这里的“按文件校验”并不是猜的命令写法:固定版本源码的 root.go 明确接收 workflow-path 参数;verify.go 和 run.go 都会使用这个范围。共享的 actions.lock 仍须格式有效,但未接入的其他 Workflow 不会因为这一条试点命令而被自动纳入。
它并非只增加一份新文件。uses: example-org/deploy@v3 可能被改写为解析出的具体版本号;找到对应 action.yml 的本地 Action,则可能从相对路径改为 $ 前缀。例如,假设 .github/workflows/ci.yml 引用仓库内的 ./actions/build/action.yml,相关 step 在修复模式下可能出现这样的 YAML 差异:
steps:
- - uses: ./actions/build
+ - uses: $/actions/build
$/ 绑定运行中的当前仓库 Commit,因此不再需要额外的独立 Pin。实际能否改写取决于本地路径是否存在及 Action 文件是否合法;路径错误的 Workflow 仍可能被跳过。普通 git diff 不显示尚未跟踪的 actions.lock,所以还要结合 git status --short 和新锁文件内容查看完整变化。
旧的组织级 Allowed Actions 配置也需要重新审视。纳入 Lockfile 后,官方规则按锁定的 Commit SHA匹配允许列表;原先按 actions/checkout@v6 这类 Tag 写的条目可能不再匹配。迁移前最好把策略与锁定 SHA 对照:如果只是 Tag 规则未匹配,按审查后的明确提交调整,比放开整个第三方 Action 命名空间更稳。
本地开发和 PR 门禁的验证侧重点不同。先限定在这一条试点 Workflow:
gh actions-lock --verify-local .github/workflows/ci.yml
gh actions-lock --verify .github/workflows/ci.yml
当仓库所有相关 Workflow 都纳入锁文件后,再在 CI 中运行无路径参数的 gh actions-lock --verify 做全量检查。v0.2.0 还会检查无用锁记录和未接入的工作流,试点期间提前全量校验可能被其他文件阻断。
--verify-local 是离线覆盖检查,只检查引用是否有锁定条目;它不核验 GitHub 上的仓库身份和 Pin 可信性。--verify 会进行更严格的在线约束检查,适合用作 PR 的 CI 关口。排查时可加 --json=valid,findings 查看结构化 Finding;v0.2.0 的程序契约是退出码 1 表示仍有阻断项,2 表示命令、I/O、网络等工具错误。具体分类以 源码的 Finding Category 为准。固定版本 README还注明,两个验证模式都不会替你迁移本地 ./… 引用;遇到 local-action 报告,应检查路径和迁移方式。离线模式能检查条目覆盖,但无法确认上游仓库身份是否变化;用于 PR 门禁时,在线 --verify 才能核对这类信任约束。
一次低风险试点,按这个顺序落地:
- 清点入口:选一条可回滚的非关键 Workflow,列出外部
uses、内部 Composite Action 和跨库 Reusable Workflow。共享 Workflow 的内部锁文件让被调用仓库维护者独立确认。 - 在测试分支生成:按上面的固定版本命令纳入该 Workflow,查看
actions.lock、Workflow 与本地action.yml的完整 Diff。确认是否出现./→$/或版本引用的改写。 - 核对策略:把锁文件里的 Commit 与组织 Allowed Actions 规则对照,检查 PR 的
git status --short是否出现漏加的新文件。对代码身份变化安排正常 Code Review。 - 分两层验收:先用指定路径的
--verify-local查覆盖,再用--verify做在线校验。验收看退出码和 findings:正常情况应无阻断性问题;若出现not-pinned、stale或local-action,回到改动 Diff 和锁文件检查;如果出现repo-replaced、unreachable-pin,先调查上游身份与 Git 历史,暂不合并。 - 合并前留后路:把生成文件及同一 PR 的源码改动一起审阅,确保所需 CI Job 与 Reusable Workflow 责任人均确认;若策略或解析阻断试点,可直接撤销测试分支上的变更。只有小范围稳定后,才考虑全仓库校验与组织级
Require lockfile。
锁住之后,依赖更新怎么走
首次纳入时锁到哪个 Commit,取决于原始 uses 引用及其解析结果,也要留意 v0.2.0 的 release cooldown。这里不存在固定默认七天的强制等待:工具优先读取仓库 .github/dependabot.yml 中 github-actions 的 cooldown.default-days(配置为正数时),其次读取用户配置 ~/.config/gh-actions-lock/config.yml 的 cooldown_days;未配置时则不启用这层过滤,仅对三天内发布的新标签给出非阻断的 fresh-tag 提示。配置解析与新标签提示都有固定源码对应。配置冷却期会影响新完整版本标签的候选筛选,但它不等于让所有 Action 自动回退到若干天前的 Commit;真正应核对的是生成的 ref、commit 与 Workflow Diff。
新版工具使用 sticky pins。正常执行 gh actions-lock 不会因为上游 main 或 v3 移动就擅自替换已锁定的提交;只有新增的引用会在正常流程中被解析。准备接受合法的上游更新时,可以运行 gh actions-lock --relock,再审查 Lockfile 中发生变化的 Commit。
如果旧提交在上游已不可到达(例如强制推送后历史变化),普通 --relock 仍可能拒绝。调查上游历史、确认新代码与仓库身份之后,可以评估更宽松的 gh actions-lock --relock --accept-moved。单纯为修好 CI 红灯而追加这个参数,会跳过本该人工调查的信任变化。对于完整版本 Tag,官方工具还检查其指向是否与原锁定记录一致;这与追踪可移动分支的策略不同。
Dependabot 能为已经纳入锁文件的 Workflow 同步更新 Action 版本和 Lockfile 记录,但不会替尚未纳入的 Workflow 自动完成首次接入。新 Workflow 被创建、uses 新增或删除、复用的 Action 发生改动时,都应有相应的锁文件更新流程。对于多仓库组织,GitHub 强制锁文件文档建议先针对少数仓库试点,将 actions.lock 纳入 CODEOWNERS 审阅,验证维护机制可靠后再考虑 Require lockfile 的 Active 强制模式。组织级 Evaluate 策略的可用性取决于具体账户与权限;固定版本 CLI README还注明 GitHub Enterprise Server(GHES)暂不支持,不能与 Enterprise Cloud 的预览能力混用。
锁文件不会自动判断被锁定的 Action 是否包含漏洞或恶意行为,更不会保护 run: 下载的未知脚本、未按 Digest 固定的镜像、脚本运行时获取的软件包和 CI Token 的实际权限。对这些执行入口,仍要独立做版本固定、最小权限和代码审查。
真正能说明试点有价值的,不是新增了 actions.lock 这个文件,而是团队能在 PR 里回答:依赖图比以前多锁住了哪些 Action?锁定的提交和仓库身份由谁审核?共享 Reusable Workflow 的内部依赖又由谁负责? 如果这三个问题仍然没人接手,先修职责边界,比直接打开强制策略更重要。
这也是我不会把 actions.lock 当成漏洞扫描工具的原因:它能让代码身份与变更进入 PR 审查,但代码本身的风险和 CI 权限仍需另一道安全门禁。关于“发现问题为何未必阻断发布”,此前的 GitHub AI Scan 复盘讨论过类似的工程分工。
附注 / 证据时间:GitHub 在 2026 年 10 月 14 日宣布 Workflow Lockfiles Public Preview;本次核对使用 gh-actions-lock v0.2.0 的固定提交 d5dca22a06ff8590d50b41612038996800cfb336,以及格式仓库提交 ac158834765237ee94c36606cfc7d87316aa49ab,源码与文档复核于 2026 年 10 月 18 日。官方公告称预览面向 Free、Team 与 Enterprise Cloud 等计划,但部分操作文档的权限标题仍较窄,实际可用性应以账户和策略为准。正文里的命令是基于固定版本官方接口整理的试点步骤,尚未在真实 GitHub Runner 或本机执行迁移、重定向 Tag 的实验;文中的 benchV003 数据属于上游解析测试夹具。