GitHub Actions 锁文件实测:6 个 uses 只锁住 2 个

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 在拦什么。

相关推荐
2601_9679801124 分钟前
使用gensim处理文本(1)语料库与词向量空间
python·自然语言处理·gensim·词向量·文本分析
3095566662 小时前
股票接口返回 404 与空数组的排查笔记:证书状态与级别参数
java·笔记·python·ios·iphone·股票数据api·股票数据接口
2601_967980112 小时前
世界上最好的Python编辑器是什么?我投PyCharm一票
ide·python·pycharm·数据科学·jupyter notebook
wuyk5552 小时前
Spider-第09讲-三格式落盘CSV-Excel-JSON
爬虫·python·json·excel
青少儿编程课堂2 小时前
蓝桥杯青少组图形化 + 全国青少年信息素养大赛 + 电子学会图形化考级:校园大富翁经营闯关解析
c++·python·算法·bfs·信息学竞赛
特立独行的猫A9 小时前
用仓颉语言 cj-tauri 框架,五分钟给娃做了个鸿蒙形近字游戏
java·后端·python
大可不必了13 小时前
03 函数定义和调用
开发语言·python
承渊政道14 小时前
【Python编程—从入门到实践】(记分与闯关:用Python+Pygame,打造越战越精彩的太空大战!)
开发语言·python·pycharm·游戏程序·pygame
爱学习的程序媛14 小时前
Python常用框架梳理
开发语言·后端·python·ai·web·后端框架·python框架