# GitHub Actions Workflow Lockfiles：只锁 uses@SHA 为什么还不够？

> Canonical: https://vxi.me/posts/github-actions-workflow-lockfiles-transitive-dependencies/
> Locale: zh-cn
> Published: 2026-10-18
> Updated: 2026-10-18
> Markdown: https://vxi.me/posts/github-actions-workflow-lockfiles-transitive-dependencies.md

# 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 公测公告](https://github.blog/changelog/2026-10-14-github-actions-workflow-lockfiles-in-public-preview)中给出了新的选择：保留易读的 `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 官方公测说明](https://github.blog/changelog/2026-10-14-github-actions-workflow-lockfiles-in-public-preview)和 [`gh-actions-lock v0.2.0`](https://github.com/github/gh-actions-lock/releases/tag/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 官方概念文档](https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles)规定，一个仓库的锁文件覆盖该仓库定义的 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` 测试夹具](https://github.com/github/actions-lockfile/blob/ac158834765237ee94c36606cfc7d87316aa49ab/go/pkg/lockfile/bench_test.go#L31-L58)。它采用完整的 `v0.0.3` 结构，夹具中 `aaaa…`、`bbbb…` 是用于解析测试的占位 SHA，而非线上生成的真实锁定记录：

```yaml
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`](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/internal/lockfile/state_marshal.go) 依次写出这些字段（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](https://github.com/github/gh-actions-lock/releases/tag/v0.2.0) 已将 Lockfile Schema 升为 **`v0.0.3`**；格式仓库的[固定 README](https://github.com/github/actions-lockfile/blob/ac158834765237ee94c36606cfc7d87316aa49ab/README.md)也说明了这个版本。CLI 版本号和 Lockfile Schema 版本号不是同一个东西。因此以 CLI 生成结果为准，审查时也要确认 Schema 版本，而不是直接照搬概念页的 YAML 示例。

## 接入之前，先把工具会改写什么看清楚

比起直接对整座 CI 仓库启用新策略，我更倾向于在独立分支挑一条非关键 Workflow 试点，先确认它会改动哪些文件。官方的 [迁移指南](https://docs.github.com/en/actions/how-tos/secure-your-work/lock-workflow-dependencies)支持按文件纳入。假定 GitHub CLI 已安装、已登录并可读取所引用的仓库，从测试分支的仓库根目录可以这样做。示例固定 `gh-actions-lock v0.2.0`，旧版扩展不保证支持同样的参数：

```bash
# 固定本文检查的扩展版本
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](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/cmd/gh-actions-lock/root.go#L64-L70) 明确接收 `workflow-path` 参数；[verify.go](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/cmd/gh-actions-lock/verify.go#L24-L40) 和 [run.go](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/cmd/gh-actions-lock/run.go#L120-L130) 都会使用这个范围。共享的 `actions.lock` 仍须格式有效，但未接入的其他 Workflow 不会因为这一条试点命令而被自动纳入。

它并非只增加一份新文件。`uses: example-org/deploy@v3` 可能被改写为解析出的具体版本号；找到对应 `action.yml` 的本地 Action，则可能从相对路径改为 `$` 前缀。例如，假设 `.github/workflows/ci.yml` 引用仓库内的 `./actions/build/action.yml`，相关 step 在修复模式下可能出现这样的 YAML 差异：

```diff
 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：

```bash
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](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/internal/pipeline/checks/category.go) 为准。固定版本 [README](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/README.md)还注明，两个验证模式都不会替你迁移本地 `./…` 引用；遇到 `local-action` 报告，应检查路径和迁移方式。离线模式能检查条目覆盖，但无法确认上游仓库身份是否变化；用于 PR 门禁时，在线 `--verify` 才能核对这类信任约束。

**一次低风险试点，按这个顺序落地：**

1. **清点入口**：选一条可回滚的非关键 Workflow，列出外部 `uses`、内部 Composite Action 和跨库 Reusable Workflow。共享 Workflow 的内部锁文件让被调用仓库维护者独立确认。
2. **在测试分支生成**：按上面的固定版本命令纳入该 Workflow，查看 `actions.lock`、Workflow 与本地 `action.yml` 的完整 Diff。确认是否出现 `./` → `$/` 或版本引用的改写。
3. **核对策略**：把锁文件里的 Commit 与组织 Allowed Actions 规则对照，检查 PR 的 `git status --short` 是否出现漏加的新文件。对代码身份变化安排正常 Code Review。
4. **分两层验收**：先用指定路径的 `--verify-local` 查覆盖，再用 `--verify` 做在线校验。验收看退出码和 findings：正常情况应无阻断性问题；若出现 `not-pinned`、`stale` 或 `local-action`，回到改动 Diff 和锁文件检查；如果出现 `repo-replaced`、`unreachable-pin`，先调查上游身份与 Git 历史，暂不合并。
5. **合并前留后路**：把生成文件及同一 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` 提示。[配置解析](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/cmd/gh-actions-lock/fresh_tag.go#L16-L32)与[新标签提示](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/cmd/gh-actions-lock/fresh_tag.go#L48-L75)都有固定源码对应。配置冷却期会影响新完整版本标签的候选筛选，但它不等于让所有 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 强制锁文件文档](https://docs.github.com/en/actions/how-tos/administer/enforce-workflow-lockfiles)建议先针对少数仓库试点，将 `actions.lock` 纳入 CODEOWNERS 审阅，验证维护机制可靠后再考虑 `Require lockfile` 的 Active 强制模式。组织级 Evaluate 策略的可用性取决于具体账户与权限；固定版本 [CLI README](https://github.com/github/gh-actions-lock/blob/d5dca22a06ff8590d50b41612038996800cfb336/README.md)还注明 GitHub Enterprise Server（GHES）暂不支持，不能与 Enterprise Cloud 的预览能力混用。

锁文件不会自动判断被锁定的 Action 是否包含漏洞或恶意行为，更不会保护 `run:` 下载的未知脚本、未按 Digest 固定的镜像、脚本运行时获取的软件包和 CI Token 的实际权限。对这些执行入口，仍要独立做版本固定、最小权限和代码审查。

真正能说明试点有价值的，不是新增了 `actions.lock` 这个文件，而是团队能在 PR 里回答：**依赖图比以前多锁住了哪些 Action？锁定的提交和仓库身份由谁审核？共享 Reusable Workflow 的内部依赖又由谁负责？** 如果这三个问题仍然没人接手，先修职责边界，比直接打开强制策略更重要。

这也是我不会把 `actions.lock` 当成漏洞扫描工具的原因：它能让代码身份与变更进入 PR 审查，但代码本身的风险和 CI 权限仍需另一道安全门禁。关于“发现问题为何未必阻断发布”，此前的 [GitHub AI Scan 复盘](/posts/github-ai-scan-pr-security-checks/)讨论过类似的工程分工。

---

*附注 / 证据时间：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` 数据属于上游解析测试夹具。*
