自动化供应链安全:依赖更新器与版本固定系统
前置知识
- ›第 1 篇:架构概述(了解 versions.json 的背景)
- ›语义化版本(semver)基础知识
- ›熟悉 GitHub API
自动化供应链安全:依赖更新器与版本固定系统
在前三篇文章中,我们将 versions.json 视为既定事实——Dockerfile 从中读取信息来克隆并验证上游依赖。但这些版本信息需要有人(或某个工具)来持续维护。如果手动操作,就意味着要监控四个上游仓库的新版本发布、解析不同格式的 tag、确保更新不会导致版本降级、同步更新 versions.json 和 versions.env,还要创建带有有意义 diff 链接的 Pull Request。
dependency_updater 是一个 Go CLI 工具,将上述整个流程全部自动化。它通过 GitHub Actions 每天运行,使用分页查询 GitHub Tags API,按追踪模式过滤版本,强制执行防降级保护,并自动生成 PR。仅版本解析逻辑本身就需要处理四种不同的 tag 格式——这充分说明了上游项目在版本规范上的多样性。
versions.json:唯一可信来源
让我们仔细看看 versions.json 的结构。四个依赖项各自遵循一套明确定义的 schema:
{
"op_node": {
"tag": "op-node/v1.16.11",
"commit": "cba7aba0c98aae22720b21c3a023990a486cb6e0",
"tagPrefix": "op-node",
"owner": "ethereum-optimism",
"repo": "optimism",
"tracking": "release"
}
}
| 字段 | 用途 |
|---|---|
tag |
克隆时使用的 Git tag——人类可读的版本标识符 |
commit |
该 tag 对应的预期 SHA——用于供应链验证 |
tagPrefix |
单体仓库 tag 的可选前缀(例如 op-node/v1.16.11 中的 op-node) |
owner / repo |
GitHub 组织名和仓库名 |
tracking |
版本选择策略:release、tag 或 branch |
tracking 字段决定了更新器认为哪些上游版本是有效的:
release:仅限稳定版本(不含预发布后缀)。当前四个依赖均使用此模式。tag:稳定版本加上 Release Candidate(-rc1、-rc.2)。适用于跟踪预发布版本链。branch:跟踪指定分支上的最新提交,完全忽略 tag。通过branch字段指定要跟踪的分支。
flowchart TD
T{"tracking mode"} -->|release| R["Only stable versions<br/>v0.7.0 ✅<br/>v0.7.1-rc1 ❌<br/>v0.7.1-synctest.0 ❌"]
T -->|tag| RC["Stable + RC versions<br/>v0.7.0 ✅<br/>v0.7.1-rc1 ✅<br/>v0.7.1-synctest.0 ❌"]
T -->|branch| BR["Latest commit on branch<br/>No tag filtering<br/>Tracks HEAD of branch"]
跨四种格式的版本解析
四个上游依赖使用四种不同的版本规范。version.go 通过一套标准化流水线统一处理。
| 依赖项 | 示例 Tag | 格式 |
|---|---|---|
| base_reth_node | v0.7.0 |
标准 v 前缀 semver |
| nethermind | 1.36.2 |
裸 semver(无 v 前缀) |
| op_geth | v1.101702.0 |
v 前缀,但次版本号较为特殊 |
| op_node | op-node/v1.16.11 |
单体仓库前缀 tag |
ParseVersion 函数通过三个步骤来处理这些差异:
flowchart LR
A["Input:<br/>op-node/v1.16.11-rc1"] -->|"Step 1: Strip prefix"| B["v1.16.11-rc1"]
B -->|"Step 2: Normalize RC"| C["v1.16.11-rc.1"]
C -->|"Step 3: Parse semver"| D["Version{1, 16, 11, rc.1}"]
第一步:去除 tag 前缀。 如果配置了 tagPrefix,则连同后面的斜杠一起移除。op-node/v1.16.11 变为 v1.16.11。
第二步:规范化 RC 格式。 normalizeRCFormat 函数使用正则表达式,将所有 RC 格式变体统一转换为 semver 兼容形式:
var rcPattern = regexp.MustCompile(`(?i)-rc[.-]?(\d+)`)
func normalizeRCFormat(version string) string {
return rcPattern.ReplaceAllString(version, "-rc.$1")
}
这一个正则表达式就能处理 -rc1、-rc.1、-rc-1 和 -RC1,统一转换为 -rc.1。(?i) 标志使其大小写不敏感。version_test.go 中的测试覆盖了所有这些变体。
第三步:使用 Masterminds/semver 解析。 标准化后的字符串传递给 Masterminds 库的 semver.NewVersion() 函数,该函数会自动处理 v 前缀并生成结构化的版本对象。
提示: op-geth 的
v1.101702.0格式看起来奇怪,但它是合法的 semver。次版本号101702编码了 Optimism 协议版本信息。semver 库将其视为普通整数处理,无需任何特殊逻辑。
版本比较与防降级保护
版本解析完成后,更新器需要判断新版本是否真的是一次升级。ValidateVersionUpgrade 函数强制执行严格的单向前进规则:
func ValidateVersionUpgrade(currentTag, newTag, tagPrefix string) error {
if currentTag == "" {
_, err := ParseVersion(newTag, tagPrefix)
return err
}
currentVersion, err := ParseVersion(currentTag, tagPrefix)
if err != nil {
_, newErr := ParseVersion(newTag, tagPrefix)
return newErr
}
newVersion, err := ParseVersion(newTag, tagPrefix)
if err != nil {
return fmt.Errorf("new version %q is not a valid semver: %w", newTag, err)
}
if newVersion.LessThan(currentVersion) {
return fmt.Errorf("version downgrade detected: %s -> %s", currentTag, newTag)
}
return nil
}
防降级逻辑简洁,但对边界情况考虑周全:
- 当前版本为空:任何合法的新版本均可接受(用于首次初始化)
- 当前版本无法解析:只要新 tag 是合法的 semver,就允许更新
- RC 版本排序:RC 版本在排序上低于对应的稳定版本(
v0.3.0-rc2 < v0.3.0)
flowchart TD
A["v0.2.2 (current)"] --> B{"Is v0.3.0-rc1 an upgrade?"}
B -->|"0.3.0-rc1 > 0.2.2 ✅"| C["Valid upgrade"]
C --> D{"Is v0.3.0-rc2 an upgrade?"}
D -->|"0.3.0-rc2 > 0.3.0-rc1 ✅"| E["Valid upgrade"]
E --> F{"Is v0.3.0 an upgrade?"}
F -->|"0.3.0 > 0.3.0-rc2 ✅"| G["Valid: RC → stable"]
G --> H{"Is v0.3.0-rc2 an upgrade?"}
H -->|"0.3.0-rc2 < 0.3.0 ❌"| I["BLOCKED: downgrade!"]
style I fill:#ff6666
version_test.go 第 71-122 行 的测试套件非常完整,覆盖了升级路径、降级场景、无法解析版本的边界情况,乃至跨前缀验证(防止将 rollup-boost/v0.7.11 "升级"为 websocket-proxy/v0.0.2)。
更新流程:从 GitHub API 到 Pull Request
主要的更新逻辑位于 dependency_updater.go。每个依赖项的处理流程如下:
flowchart TD
A["Read versions.json"] --> B["For each dependency"]
B --> C{"tracking mode?"}
C -->|"release/tag"| D["Paginate GitHub Tags API"]
C -->|"branch"| E["Get latest commit on branch"]
D --> F["Filter by tagPrefix"]
F --> G["Filter by tracking mode<br/>(release-only or release+RC)"]
G --> H["Validate: is this an upgrade?"]
H --> I["Find maximum valid version"]
I --> J{"Version changed?"}
J -->|Yes| K["Update versions.json"]
J -->|No| L["Skip dependency"]
E --> M{"Commit changed?"}
M -->|Yes| K
M -->|No| L
K --> N["Regenerate versions.env"]
N --> O["Create commit with diff URL"]
getVersionAndCommit 中基于 tag 的更新逻辑设计得相当精巧。它不会在找到第一个有效升级版本时立即返回,而是收集所有页面中的全部有效 tag,再从中找出最大版本:
// Collect all valid tags across all pages, then find the max version
var validTags []*github.RepositoryTag
for {
tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, options)
// ... filter by prefix, tracking mode, upgrade validation ...
if resp.NextPage == 0 {
break
}
options.Page = resp.NextPage
}
// Find the maximum version among valid tags
for _, tag := range validTags {
if selectedTag == nil {
selectedTag = tag
continue
}
cmp, _ := CompareVersions(*tag.Name, *selectedTag.Name, tagPrefix)
if cmp > 0 {
selectedTag = tag
}
}
这种"先分页收集、再取最大值"的方式至关重要,原因在于 GitHub Tags API 并不按语义化版本顺序返回 tag,而是按时间或字典序排列。如果只取第一页结果,很可能会遗漏最高版本。
该函数还会为提交信息生成一个 diff URL:https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag},让 PR 审阅者可以直接查看上游的变更内容。
对于 branch 追踪模式(第 284-307 行),逻辑更简单:查询指定分支上的最新提交,比较 SHA 哈希值。如果提交发生了变化,则生成新旧提交之间的 diff URL。
所有 API 调用都使用 Optimism SDK 中的 retry.Do0 进行了封装(第 100 行),最多重试 3 次,固定间隔 1 秒,以应对 GitHub API 的瞬时故障。
versions.env 生成
更新完 versions.json 后,更新器通过 createVersionsEnv 函数重新生成 versions.env:
func createVersionsEnv(repoPath string, dependencies Dependencies) error {
envLines := []string{}
for dependency := range dependencies {
repoUrl := generateGithubRepoUrl(dependencies, dependency) + ".git"
dependencyPrefix := strings.ToUpper(dependency)
envLines = append(envLines, fmt.Sprintf("export %s_%s=%s",
dependencyPrefix, "TAG", dependencies[dependency].Tag))
envLines = append(envLines, fmt.Sprintf("export %s_%s=%s",
dependencyPrefix, "COMMIT", dependencies[dependency].Commit))
envLines = append(envLines, fmt.Sprintf("export %s_%s=%s",
dependencyPrefix, "REPO", repoUrl))
}
slices.Sort(envLines)
// ... write to file
}
命名规则是确定性的:依赖项的键名转为大写(op_geth → OP_GETH),再加上 _TAG、_COMMIT 或 _REPO 后缀。写入前按字母顺序排序,这样当只有一个依赖项更新时,diff 会保持整洁。
flowchart LR
VJ["versions.json<br/>op_geth.tag = v1.101702.0<br/>op_geth.commit = d0734fd..."] -->|createVersionsEnv| VE["versions.env<br/>export OP_GETH_TAG=v1.101702.0<br/>export OP_GETH_COMMIT=d0734fd...<br/>export OP_GETH_REPO=https://..."]
VE -->|"COPY into Dockerfile"| DF["RUN . /tmp/versions.env && ..."]
提示:
versions.json和versions.env都会提交到仓库中。.env文件是从.json文件派生出来的产物。如果两者不同步,运行更新器即可重新对齐。永远不要手动编辑versions.env——始终修改versions.json,然后重新生成。
Docker 构建中的提交哈希验证
每个 tag 旁边存储的提交哈希不只是参考信息,而是一项主动的安全控制。正如我们在第 3 篇中看到的,每个 Dockerfile 都会验证克隆仓库的 HEAD 是否与预期提交匹配。让我们梳理完整的验证链路:
sequenceDiagram
participant GH as GitHub Tags API
participant UP as dependency_updater
participant VJ as versions.json
participant DF as Dockerfile (build time)
participant REPO as Upstream Repo
UP->>GH: ListTags(owner, repo)
GH-->>UP: tags with commit SHAs
UP->>UP: Find max valid version
UP->>VJ: Write tag + commit SHA
Note over DF: At build time...
DF->>REPO: git clone --branch TAG
DF->>DF: git rev-parse HEAD
DF->>DF: Compare HEAD == expected COMMIT
alt Match
DF->>DF: Proceed to build
else Mismatch
DF->>DF: BUILD FAILS
end
更新器查询 GitHub Tags API 时,每个 tag 对象都包含它所指向的提交 SHA。更新器将该 SHA 写入 versions.json。构建时,Dockerfile 按 tag 克隆仓库,再将 HEAD 与存储的 SHA 进行比对。若不匹配,构建立即失败。
这一机制防范的是一种具体威胁:上游维护者(或遭到入侵的账户)可能将某个 tag 移动到指向另一个提交。若没有 SHA 校验,Dockerfile 会直接构建 tag 当前指向的任意代码。有了这层检查,构建会立刻失败,提醒运维人员注意异常。
通过 GitHub Actions 实现每日自动化
整个更新流水线通过 update-dependencies.yml 全自动运行:
on:
schedule:
- cron: '0 13 * * *' # Daily at 1 PM UTC
workflow_dispatch:
工作流程很直接:检出仓库、构建 Go 更新器、运行它,并在版本有变化时创建 PR:
flowchart TD
A["Daily cron at 1 PM UTC"] --> B["Checkout repository"]
B --> C["cd dependency_updater && go build"]
C --> D["Run updater with --github-action"]
D --> E{"Any versions updated?"}
E -->|Yes| F["Updater writes TITLE and DESC<br/>to GITHUB_OUTPUT"]
F --> G["peter-evans/create-pull-request<br/>creates PR with diff links"]
E -->|No| H["Workflow exits cleanly"]
在 GitHub Actions 模式下(--github-action true),更新器使用 GitHub 的多行输出语法将提交标题和描述写入 GITHUB_OUTPUT(第 385-407 行)。随后,peter-evans/create-pull-request action 使用更新器的输出创建 PR:
- name: create pull request
if: ${{ steps.run_dependency_updater.outputs.TITLE != '' }}
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
title: ${{ steps.run_dependency_updater.outputs.TITLE }}
body: "${{ steps.run_dependency_updater.outputs.DESC }}"
branch: run-dependency-updater
delete-branch: true
PR 标题遵循 chore: updated op-geth, op-node 的格式,正文包含每个更新依赖项的 diff 链接。delete-branch: true 会在合并后自动清理分支。
注意 if 条件:如果更新器的 TITLE 输出为空,则不会创建 PR。这种情况发生在所有依赖项都已是最新版本时。
完整的供应链流水线
退一步来看,完整的供应链安全流水线如下:
- 每日触发:GitHub Actions 运行依赖更新器
- 更新器:查询 GitHub API → 查找最新版本 → 验证升级合法性 → 捕获提交 SHA → 更新文件 → 创建 PR
- PR 审查:人工审阅 diff 链接并批准
- 合并:更新后的
versions.json和versions.env合入main分支 - CI 构建:Docker 构建在固定 tag 处克隆并验证提交哈希
- 运行时:容器运行经过验证的二进制文件
这条链路的每个环节都包含验证步骤。更新器确保版本不会降级;Dockerfile 验证提交哈希是否匹配;CI 流水线使用固定 action SHA(而非版本 tag)来保障自身的供应链安全。整个系统以每日为周期运行,确保 Base 节点始终与上游保持同步。
下一步
依赖更新器负责创建 PR,但这些 PR 在合并前需要经过验证。在第 5 篇中,我们将深入探讨 CI/CD 流水线——它如何为三种执行客户端跨两种 CPU 架构构建 Docker 镜像,并采用"构建 → 上传摘要 → 合并 manifest"的精妙模式。我们还会了解 asm-keccak 等平台专属优化是如何按条件应用的,以及流水线如何通过固定 action SHA 和 harden-runner 实现纵深防御。