结论:凡是工作流会从其他 GitHub 仓库按 ref 拉取的 Action,都应把 @main、@v4、@v4.1.0 改成完整的 40 位 commit SHA,版本号另放在行尾注释里。真正决定执行内容的是 SHA,注释只帮助维护者识别版本;固定之后,再通过 Dependabot 或受控 PR 接收上游更新。
# 修改前
- uses: owner/action@v4
# 修改后,尖括号内容需要替换为真实的完整 SHA
- uses: owner/action@<完整40位commit SHA> # v4.x.y
第三方 Action 会在 Runner 上执行代码,并可能接触当前 job 的 GITHUB_TOKEN、显式传入的 Secrets 和运行环境中的凭据。固定 SHA 能阻止上游移动 Tag 后静默换掉执行内容,但不能把一个已经审核过的提交变成安全提交,也不能替代权限收口。
Hugo van Kemenade 的《Lock Your GitHub Actions》是这次 Hacker News 讨论的延伸阅读。在仓库中,需要决定哪些引用应该锁定,以及锁定后怎样继续更新。
哪些引用需要改,哪些是例外
@main 跟随分支最新提交,@v4 跟随一个可移动的主版本 Tag,@v4.1.0 虽然更具体,但仍然是 Tag。没有额外的不可变约束时,分支和 Tag 都可能被维护者移动或重写;完整 SHA 才直接对应某次提交。
| 引用方式 | 实际指向 | 处理建议 |
|---|---|---|
owner/action@main |
分支当前提交 | 改为完整 commit SHA |
owner/action@v4 |
主版本 Tag | 改为完整 commit SHA,保留版本注释 |
owner/action@v4.1.0 |
精确版本 Tag | 仍建议改为完整 commit SHA |
owner/action@<40位 SHA> |
固定提交 | 保留,后续通过 PR 更新 |
uses: ./path/to/action |
当前仓库内的本地 Action | 不需要上游 SHA |
uses: owner/repo/.github/workflows/build.yml@ref |
远程可复用工作流 | 可以锁定到仓库的完整 SHA |
uses: docker://image:tag |
容器镜像 | 不适用 Git commit SHA,需要单独的镜像管理策略 |
工作流通常放在 .github/workflows/,但不能只检查这个目录。Composite Action 的 action.yml 或 action.yaml 里也可能有 uses,工作流本身还同时存在步骤级、job 级和 workflow 级引用。
在仓库根目录执行:
rg --hidden -n --glob '*.yml' --glob '*.yaml' 'uses:'
--hidden 用来包含 .github 目录,*.yml 和 *.yaml 则能覆盖工作流及 Action 清单。检查结果时,要先排除 uses: ./... 和 docker://...,再确认每个远程 owner/repo@ref 的实际来源。
“第三方”也需要在仓库政策中定义清楚。actions/* 这类由 GitHub 维护的仓库是否纳入范围,可以由组织规则决定;如果没有明确豁免,把所有远程 Action 一并固定更简单,也更容易用扫描器统一检查。一个公开仓库中已合并的改造记录同时固定了 actions/checkout、actions/setup-python、actions/upload-artifact、actions/rust-cache 和发布 Action,而不是只处理社区项目。
怎样找到并替换正确 SHA
先建立“owner/repo + 当前 ref + 目标 SHA”的映射,不要对 @v4 做全局替换。不同仓库里的 @v4 指向不同提交;同一个仓库里的 actions/checkout@v4 和 actions/setup-python@v4 也不是同一个 SHA。
下面的命令适用于 Bash。先把 OWNER/REPO 换成目标仓库,v4 换成实际引用:
sha="$(gh api "repos/OWNER/REPO/commits/v4" --jq '.sha')"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
GitHub API 的 commits 接口会把这个引用解析到当前对应的提交,gh api 的调用形式可按 GitHub CLI 手册核对。 如果只知道主版本 Tag,还要找出它当时对应哪个精确发布版本:
gh api --paginate "repos/OWNER/REPO/tags?per_page=100" \
--jq '.[] | [.name, .commit.sha] | @tsv'
再用 Git 独立解析一次远程引用:
git ls-remote https://github.com/OWNER/REPO.git 'refs/tags/v4*'
这里的宽匹配可能同时列出 v4、v4.1.0 等多个 Tag。定位到准备采用的精确版本后,可以缩小范围:
git ls-remote https://github.com/OWNER/REPO.git \
'refs/tags/v4.1.0' 'refs/tags/v4.1.0^{}'
Annotated Tag 有一个容易踩的坑:普通 Tag 行指向 Tag 对象,而带 ^{} 的行才是剥离 Tag 对象后得到的 commit。两者的 SHA 都是 40 位十六进制值,只检查长度无法区分。对于 Annotated Tag,应锁定 ^{} 对应的 commit;Lightweight Tag 没有额外的 peeled 条目,唯一的 Tag 行就是目标 commit。
因此,GitHub API 得到的 commit SHA 应与 git ls-remote 中对应的 peeled commit 比较,而不是与 Annotated Tag 对象本身比较。如果结果不一致,先检查两次查询是否用了相同 Tag,以及查询期间 Tag 是否发生了移动:
gh api "repos/OWNER/REPO/commits/$sha" --jq '.sha'
API 和 ls-remote 一致,只能证明两条查询解析到了同一个提交;还必须确认它确实对应计划采用的发布版本,而不是上游仓库的任意最新提交。确认后再改写工作流:
- uses: owner/action@<完整40位commit SHA> # v4.x.y
版本注释最好写精确的语义版本,例如 v4.1.0,而不只写 v4。注释不会影响执行;如果以后因为排版原因删掉它,Runner 仍然只会执行 SHA 指定的提交。
还有一个常见混淆:Action 上游仓库的 commit SHA,与当前项目自己的 github.sha 完全不同。在 pull_request 事件中,github.sha 通常是 PR 的合并提交,github.event.pull_request.head.sha 才是 PR 源分支提交;两者都不能代替第三方 Action 的上游 SHA。
批量迁移时,可以让映射文件按完整的 owner/repo@ref 匹配。下面是制表符分隔的 TSV 模板,需要先把占位内容替换为真实值:
action_ref sha comment
actions/checkout@v4 <40位SHA> v4.x.y
actions/setup-python@v5 <40位SHA> v5.x.y
下面的 Python 3 脚本只处理已经存在于映射表中的完整 action_ref,不会把所有 @v4 替换成同一个 SHA。它处理已纳入 Git 跟踪的常规 YAML 写法;带引号或特殊格式的行仍应结合 git diff 复核。
python3 - <<'PY'
import csv
import pathlib
import re
import subprocess
mapping = {}
with open("action-sha.tsv", encoding="utf-8") as f:
for row in csv.reader(f, delimiter="\t"):
if not row or row[0].startswith("#") or row[0] == "action_ref":
continue
action_ref, sha = row[:2]
label = row[2] if len(row) > 2 else ""
if not re.fullmatch(r"[0-9a-f]{40}", sha):
raise SystemExit(f"invalid SHA: {sha}")
mapping[action_ref] = (sha, label)
pattern = re.compile(
r"^(?P<head>\s*(?:-\s+)?uses:\s*)"
r"(?P<quote>['\"]?)"
r"(?P<action>[^@\s'\"#]+)"
r"@(?P<ref>[^\s#'\"]+)"
r"(?P=quote)"
r"(?P<comment>[ \t]*#.*)?$",
re.MULTILINE,
)
files = subprocess.check_output(
["git", "ls-files", "--", "*.yml", "*.yaml"],
text=True,
).splitlines()
for name in files:
path = pathlib.Path(name)
text = path.read_text(encoding="utf-8")
def replace(match):
key = f"{match.group('action')}@{match.group('ref')}"
if key not in mapping:
return match.group(0)
sha, label = mapping[key]
comment = f" # {label}" if label else ""
quote = match.group("quote")
return (
f"{match.group('head')}{quote}"
f"{match.group('action')}@{sha}{quote}{comment}"
)
updated = pattern.sub(replace, text)
if updated != text:
path.write_text(updated, encoding="utf-8")
PY
git diff --check
git diff
不同 Action 必须使用各自的 SHA。公开改造记录中的快照如下;这些值用于说明迁移方式,不代表当前上游最新版本,实际项目仍应重新解析目标版本。
| Action | 原引用 | 改造记录中的 SHA |
|---|---|---|
actions/checkout |
@v4 |
11d5960a326750d5838078e36cf38b85af677262 |
actions/setup-python |
@v5 |
a26af69be951a213d495a4c3e4e4022e16d87065 |
actions/upload-artifact |
@v4 |
ea165f8d65b6e75b540449e92b4886f43607fa02 |
dtolnay/rust-toolchain |
@stable |
686976e191b89faba57d3206551f0f330d8cb249 |
Swatinem/rust-cache |
@v2 |
49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c |
softprops/action-gh-release |
@v2 |
3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 |
怎样持续升级并限制权限
SHA 固定解决的是“执行哪个提交”,不是“以后不再更新”。Dependabot 可以继续检查 github-actions 生态并提交 SHA 升级 PR,让更新继续走代码评审流程。
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/.github/workflows"
schedule:
interval: "weekly"
升级 PR 到达后,应核对新旧 SHA、对应版本、上游发布说明和 Action 行为变化。Dependabot 的检查通过只能说明依赖关系发生变化,不能替代对权限扩大、新增网络请求或发布行为变化的审查。版本注释也应与新 SHA 对应的发布版本保持一致。
Dependabot 负责发现升级,Zizmor 一类审计工具负责检查仓库里是否重新出现浮动引用,两者的用途不同。 可以按当前 Zizmor 官方文档确认安装和扫描参数,常见的仓库根目录扫描形式是:
zizmor .
具体规则名称、输出和退出行为可能随版本变化,不宜写死到 CI 脚本里。如果 Zizmor 本身也通过 GitHub Action 运行,这个用于审计的 Action 同样需要固定到完整 SHA。
权限必须同时收口:先为工作流声明普通任务所需的最小权限,只有发布等特定 job 才提升:
permissions:
contents: read
jobs:
publish:
permissions:
contents: write
steps:
- uses: owner/action@<完整40位commit SHA> # v4.x.y
完全不需要 GITHUB_TOKEN 的 job 可以使用 permissions: {};需要向云平台换取 OIDC 凭据时,才在对应 job 增加 id-token: write。不要依赖仓库或组织当前设置的默认权限。
checkout 完成代码拉取后,如果后续步骤不再使用 Git 凭据,可以关闭凭据持久化:
- uses: actions/checkout@<完整40位commit SHA> # v4.x.y
with:
persist-credentials: false
这项设置只处理 checkout 写入本地 Git 配置的凭据,不等于清空 job 中的其他 Secrets。部署密码、令牌和云密钥仍应放在 GitHub Secrets 中,不能写进 YAML 或提交到仓库。
如果可疑的浮动 Action 已经运行过,之后再锁定 SHA 不能撤销已经发生的暴露。此时应先暂停相关流程,轮换可能泄露的长期凭据,检查历史运行日志、仓库提交和发布等变更,再扫描所有工作流,确认是否还有其他浮动引用。是否需要处理 GITHUB_TOKEN,则要根据当时的 event、job 权限和 Secrets 注入范围判断。
完整 SHA 只固定了直接引用的 Action 提交。它不会自动修补已锁定提交中的漏洞,也不能保证 Composite Action 内部调用的其他 Action、运行时下载的依赖或 Docker 镜像同样不可变;脚本注入和 Secrets 暴露也需要单独治理。 因此,SHA 固定应与版本注释、持续升级、CI 审计、最小权限和异常处置一起维护,而不是作为单独的一条 YAML 规则。












暂无评论内容