Hugo van Kemenade 11 月 11 日发的《Lock your GitHub Actions》把一件别扭事说透了:为了防供应链投毒,社区让大家把 uses: 里的 tag 换成 40 位 commit SHA,结果工作流里塞满读不懂的哈希。GitHub 10 月 28 日把 workflow lockfiles 推到 GA,用 .github/workflows/actions.lock 把哈希挪出工作流,YAML 里写回 actions/checkout@v7.0.1。锁文件生成完不等于万事大吉:工作流改了没重新锁、上游 tag 被挪走、仓库换了身份,这几种情况 CI 未必拦得住。我用标准库写了个校验器,把三个合成工作流扫了一遍,8 行 uses 里 6 个远程引用只有 2 个真锁住。
选题来源与环境
时间线能对上当天:GitHub Changelog 2026-10-28 宣布 workflow lockfiles 正式可用;hugovk.dev 原文发布于 2026-11-11;这条在 Hacker News 2026-11-12 前排,同日榜单里还有 Apache HTTP/2 的 RCE 与 AWS 本地模拟器;GitHub Trending 2026-11-13 的日榜快照是 agent-skills、codex-security 这批 CI/agent 工具在涨。CSDN 上 10 月底已有博主把锁文件的间接依赖、失效语义拆过一遍,dev.to 同期也有 2026 年的落地复盘。NVIDIA 换帅、Anthropic IPO 属于人事融资,Gemini Decisions API 这类要资格的封闭功能也没法在本地验证,都没选。
本机环境一行就能确认,全部代码只用标准库,不需要装 PyYAML:
bash
python -c "import sys; print(sys.version.split()[0])"
输出 3.13.14。
扫描器先把 uses 分对类
旧写法长这样(引自 hugovk.dev 公开示例):
yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: j178/prek-action@44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d # v3.1.0
@ 后面那串决定跑什么代码。末尾的 # v7.0.1 只是给人看的注释,机器不读,把它当成「已经锁了版本」是最常见的误判。判断依据只有一条:40 位小写十六进制是 SHA,其余都是可变引用(tag 或 branch)。
下面这段把三个合成工作流的 uses 全抽出来分类。[^\s#]+ 里的 # 不能省,否则注释会被一起吞进引用串。
python
import copy
import hashlib
import json
import re
import sys
print("Python", sys.version.split()[0])
WORKFLOWS = {
".github/workflows/ci.yml": """name: CI
on: [push]
jobs:
build:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@v6.0.0
- uses: docker://alpine:3.20
- uses: ./.github/actions/notify
""",
".github/workflows/lint.yml": """name: Lint
jobs:
lint:
steps:
- uses: actions/checkout@v4
- uses: j178/prek-action@44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d # v3.1.0
""",
".github/workflows/release.yml": """name: Release
jobs:
release:
steps:
- uses: softprops/action-gh-release@v2
- uses: actions/upload-artifact@v4
""",
}
USES_RE = re.compile(r"^\s*-\s*uses:\s*([^\s#]+)", re.M)
SHA40 = re.compile(r"^[0-9a-f]{40}$")
def parse_workflow(text):
rows = []
for ref in USES_RE.findall(text):
if ref.startswith("./") or ref.startswith("docker://"):
rows.append(("skip", ref))
elif "@" in ref:
ver = ref.rsplit("@", 1)[1]
rows.append(("sha" if SHA40.fullmatch(ver) else "tag", ref))
else:
rows.append(("invalid", ref))
return rows
refs = {p: parse_workflow(t) for p, t in WORKFLOWS.items()}
flat = [(p, k, r) for p, rows in refs.items() for k, r in rows]
remote = [r for _, k, r in flat if k in ("sha", "tag")]
sha = [r for _, k, r in flat if k == "sha"]
tags = [r for _, k, r in flat if k == "tag"]
skipped = [r for _, k, r in flat if k == "skip"]
print("uses 总数:", len(flat))
print("排除(本地/容器):", len(skipped), skipped)
print("远程引用:", len(remote), "SHA 锁定:", len(sha), "tag 引用:", len(tags))
输出:
text
Python 3.13.14
uses 总数: 8
排除(本地/容器): 2 ['docker://alpine:3.20', './.github/actions/notify']
远程引用: 6 SHA 锁定: 2 tag 引用: 4
docker:// 和 ./ 两类不进锁文件,扫描器必须单独排除,否则「锁住率」会算虚高。剩下 6 个远程引用里 4 个还是 tag,其中 actions/checkout@v4 是浮动大版本,维护者每次发补丁都会把它往前挪,比 @v6.0.0 更松。
锁文件里存了什么
真实 actions.lock 是 YAML,格式版本 v0.0.3(引自 hugovk.dev 原文):
yaml
version: 'v0.0.3'
workflows:
'.github/workflows/lint.yml':
- 'actions/checkout@v7.0.1'
- 'j178/prek-action@v3.1.0'
dependencies:
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
workflows 记哪个文件用了哪些引用,dependencies 把每个引用解析成 commit 加两个数字 ID。这两个 ID 不是摆设:仓库改名、旧名被别人重新注册时,commit 可能还一样,但名字背后的仓库已经换了,GitHub 靠数值 ID 确认身份。SHA pin 只覆盖你直接写的那一行,composite action 内部再引用别人,锁文件会连间接依赖一起记。
后面的实验用 JSON 表达同一份结构,是为了不装第三方库也能跑。commit 用 sha1(引用名) 现场生成占位值,owner_id / repo_id 也是合成编号,全部数字来自本地计算;真实环境里这些由 gh actions-lock 向上游解析。
python
def fake_commit(ref):
return "sha1-" + hashlib.sha1(ref.encode()).hexdigest()
UPSTREAM = {}
for i, r in enumerate(remote, 1):
UPSTREAM[r] = {"commit": fake_commit(r), "owner_id": 1000 + i, "repo_id": 2000 + i}
LOCK = {
"version": "v0.0.3",
"workflows": {p: [r for k, r in rows if k in ("sha", "tag")] for p, rows in refs.items()},
"dependencies": {r: dict(UPSTREAM[r]) for r in remote},
}
print("锁文件格式版本:", LOCK["version"])
print("登记依赖:", len(LOCK["dependencies"]))
输出:
text
锁文件格式版本: v0.0.3
登记依赖: 6
校验器:四类不一致都要报
校验分三步:工作流里每个远程引用都得在 dependencies 里登记;登记过的引用不能没人用;每个引用当前解析到的 commit 与数值 ID 要和锁定时一致。第三步行内再分两种------commit 变了是 tag 漂移,commit 没变但 ID 变了是仓库身份变化。
python
def verify(workflows, lock, upstream):
problems = []
used = set()
for path, text in workflows.items():
for kind, ref in parse_workflow(text):
if kind not in ("sha", "tag"):
continue
used.add(ref)
if ref not in lock["dependencies"]:
problems.append("missing: " + ref)
for ref in lock["dependencies"]:
if ref not in used:
problems.append("stale: " + ref)
for ref, rec in lock["dependencies"].items():
cur = upstream.get(ref)
if cur is None:
continue
if cur["commit"] != rec["commit"]:
problems.append("drift: " + ref)
elif (cur["owner_id"], cur["repo_id"]) != (rec["owner_id"], rec["repo_id"]):
problems.append("repo_changed: " + ref)
return sorted(problems)
跑五组,每组只动一处:
python
def run(label, workflows, lock, upstream):
out = verify(workflows, lock, upstream)
print(label, "->", out if out else ["OK"])
run("基线", WORKFLOWS, LOCK, UPSTREAM)
l2 = copy.deepcopy(LOCK)
del l2["dependencies"]["actions/setup-python@v6.0.0"]
run("缺登记", WORKFLOWS, l2, UPSTREAM)
l3 = copy.deepcopy(LOCK)
l3["dependencies"]["actions/setup-node@v6.0.0"] = {
"commit": fake_commit("unused"), "owner_id": 9001, "repo_id": 9002,
}
run("僵尸条目", WORKFLOWS, l3, UPSTREAM)
u4 = copy.deepcopy(UPSTREAM)
u4["actions/checkout@v4"]["commit"] = fake_commit("moved-tag")
run("tag 漂移", WORKFLOWS, LOCK, u4)
u5 = copy.deepcopy(UPSTREAM)
u5["softprops/action-gh-release@v2"]["owner_id"] = 999999
run("仓库身份变化", WORKFLOWS, LOCK, u5)
输出:
text
基线 -> ['OK']
缺登记 -> ['missing: actions/setup-python@v6.0.0']
僵尸条目 -> ['stale: actions/setup-node@v6.0.0']
tag 漂移 -> ['drift: actions/checkout@v4']
仓库身份变化 -> ['repo_changed: softprops/action-gh-release@v2']
tag 漂移 那行最值得看:工作流文件一个字没改,git diff 是空的,跑的代码却换了。可变 tag 的代价就在这里,而锁文件把它变成一次能观测到的失败而不是静默通过。僵尸条目 容易被忽略,它通常是删步骤时忘了重新生成锁文件,留着会让评审以为某个依赖还在用。
踩坑记录:三个真实报错
这四条都在本机跑过,报错原文照抄。
python
fake_pinned = " - uses: actions/checkout@v7.0.1 # v7.0.1"
print("注释不算锁:", parse_workflow(fake_pinned))
try:
json.loads("{ version: 'v0.0.3'")
except Exception as e:
print(type(e).__name__, ":", e)
try:
print(LOCK["dependencies"]["actions/setup-node@v6.0.0"]["commit"])
except Exception as e:
print(type(e).__name__, ":", e)
try:
int("v4", 16)
except Exception as e:
print(type(e).__name__, ":", e)
输出:
text
注释不算锁: [('tag', 'actions/checkout@v7.0.1')]
JSONDecodeError : Expecting property name enclosed in double quotes: line 1 column 3 (char 2)
KeyError : 'actions/setup-node@v6.0.0'
ValueError : invalid literal for int() with base 16: 'v4'
第一行把误判钉死了:@ 后是 v7.0.1、行尾补了同样的注释,分类仍是 tag。锁没锁只看 @ 后面。
JSONDecodeError 来自手改坏的锁文件。文件头写着「机器生成,不要手改」是有理由的,格式一坏校验就废;这里的 JSON 只是等价表达,别把它当成真实文件格式照抄。
KeyError 是直接索引未登记引用。校验器要用集合差找出差集再报,不能拿引用名当 key 硬取,否则第一个缺失项就把整个检查打断,后面的问题一条都报不出来。
ValueError 是拿 v4 去做十六进制转换。别用「能不能转 16 进制」判断是不是 SHA,v4 里的 v 不在字符集里会抛异常,abcd 这种纯字母却会被误判成合法哈希。稳妥做法是 re.fullmatch(r"[0-9a-f]{40}", ver),长度和字符集一起卡。
收敛到真实命令
自己的校验器适合理解机制,落到仓库还是用官方扩展:
bash
gh extension install github/gh-actions-lock
gh actions-lock
gh actions-lock --verify
生成后把改动的工作流和 actions.lock 一起提交,CI 里单开一个 job 跑 --verify,扩展本身按官方示例 pin 到 v0.4.1、跑在 ubuntu-slim 上。Renovate 和 Dependabot 已经会顺带更新锁文件,zizmor 从 v1.32.0 起支持锁文件校验。有个边界要记住:锁文件只保证「锁了什么就跑什么」,不判断 action 本身好坏,锁进一个已被投毒的版本,你会稳定地跑到那个版本,评审仍不能省。
小结
先跑一遍分类统计,看仓库里到底有几个 uses 还是裸 tag;再决定是全量迁移还是先给发布类工作流加锁。校验器不必自己写进生产,但把 missing / stale / drift / repo_changed 四类失败各造一遍,比读一遍文档更清楚 --verify 在拦什么。