在 GitHub 生态中,Dependabot 已成为依赖管理的代名词。大多数开发者将其视为一个智能机器人,持续监控仓库并在更新可用时自动创建 Pull Request。然而,这种认知掩盖了其真实的工程架构:Dependabot 并非单一智能体,而是一个 330,000 行 Ruby 代码的stateless 库,被 GitHub 的专有基础设施包裹,形成完整的工作流自动化系统。
架构真相:Stateless 库与专有协调层的分离
2024 年 5 月,GitHub 将dependabot-core从 Prosperity Public License 重新许可为 MIT,这一变化揭示了其核心设计哲学。开源的部分仅涵盖更新逻辑:解析清单文件、检查注册表、生成文件变更。而调度、状态跟踪和协调等使 Dependabot 作为服务运行的关键组件,仍保留在 GitHub 的专有基础设施中。
这种分离设计意味着自托管 Dependabot 需要重建整个协调层。正如 Andrew Nesbitt 在《How Dependabot Actually Works》中所指出的:"The codebase is a stateless Ruby library that knows nothing between runs, wrapped by proprietary GitHub infrastructure that handles all the coordination."
核心四类工作流:从文件获取到 PR 生成的完整链条
每个包生态系统在 dependabot-core 中实现四个核心类,形成完整的依赖更新工作流:
1. FileFetcher:仓库文件下载
负责从仓库下载清单文件和锁文件。对于复杂的项目结构,如 monorepo 或多模块项目,FileFetcher 需要处理嵌套目录和特殊文件布局。
2. FileParser:依赖提取
解析清单文件并提取依赖信息。复杂度因生态系统而异:GitHub Actions 的 FileParser 仅 194 行,而 Gradle 的 FileParser 达到 615 行。npm 生态系统最为复杂,需要处理 package.json、yarn.lock、package-lock.json、pnpm-lock.yaml 等多种格式。
3. UpdateChecker:注册表查询
查询包注册表以检查新版本。这里涉及版本语义解析、兼容性判断和更新策略选择。dependabot-core 通过用户代理字符串dependabot-core/#{VERSION} ... (+https://github.com/dependabot/dependabot-core)标识自身。
4. FileUpdater:文件变更生成
生成实际的 PR 文件变更。这是最复杂的部分,需要处理依赖解析、冲突检测和文件格式保持。npm 生态系统的file_updater_spec.rb测试文件单独就达到 4,000 行,反映了其复杂性。
生态系统复杂性:多版本支持与猴子补丁
Python 的多版本考古学
Python 生态系统的 Dockerfile 长达 209 行,因为它需要支持六个 Python 版本(3.9 到 3.14)。旧版本使用 zstd 压缩存储以节省空间。更复杂的是,许多 Python 包包含需要编译的原生扩展,因此 Rust 工具链也被打包进来。
npm 的版本考古学
npm 生态系统维护着版本考古学:仍然包含 npm 6,同时支持较新的 @npmcli/arborist(来自 npm 8+)。他们还维护 Yarn 1.x 的分支,发布为@dependabot/yarn-lib。对 pacote 的补丁添加了GIT_CONFIG_GLOBAL到允许的环境变量中。
Bundler 的猴子补丁
Bundler 接收了大量猴子补丁:
- 将
git@github.com:SSH URL 转换为 HTTPS,因为 Dependabot 运行时没有 SSH 密钥 - 操作
$LOAD_PATH以防止在评估 gemspec 时加载有问题的 gem - 注入假的 Ruby 版本元数据到解析过程中,使其在没有目标 Ruby 版本实际安装的情况下工作
测试策略:Silent 生态系统与 txtar 格式
测试套件包含一个名为 "silent" 的假包生态系统,它不进行网络调用。它从本地 JSON 文件读取可用版本,使用txtar 格式。这使得团队可以在没有真实注册表的情况下测试更新机制。
NuGet 生态系统将实际的 NuGet.Client 仓库作为git 子模块引入,固定到release-6.12.x。他们还子模块化了 dotnet-core。
作业定义:Stateless 设计的核心体现
Dependabot-core 完全 stateless,每次运行都从零开始。作业定义必须提供所有上下文:
job:
package-manager: bundler
source:
provider: github
repo: owner/repo
directory: "/"
commit: abc123
existing-pull-requests:
- - dependency-name: "lodash"
dependency-version: "4.17.21"
security-advisories:
- dependency-name: sinatra
affected-versions:
- ">= 2.0.0, < 2.2.3"
updating-a-pull-request: false
关键点:
existing-pull-requests:Dependabot 无法查询之前创建的 PR,GitHub 基础设施找到开放的 Dependabot PR 并传递列表security-advisories:库不维护漏洞数据库,GitHub 从 Advisory Database 获取并注入相关 CVEupdating-a-pull-request:刷新现有 PR 时设置为 true
遗憾的是,这个作业定义不会在生成的 PR 中公开。要提取哪些包被更新,需要编写400 行正则解析来从 PR 标题和描述中反向工程包名和版本。
调度挑战:轮询低效与事件驱动更新的可能性
轮询模型的效率问题
当前 Dependabot 使用轮询模型:按计划扫描仓库,检查所有依赖。对于一个有 500 个依赖、每天调度的仓库,每年大约进行 182,000 次注册表查找。大多数日子没有任何变化,但它仍然解析每个清单并检查每个注册表,只是为了发现没有变化并丢弃所有结果。
事件驱动更新的潜力
替代方案是事件驱动更新。如果维护跨仓库的依赖索引,可以翻转模型:
- 当 lodash 4.17.22 发布到 npm 时,查询哪些仓库使用低于该版本的 lodash 并仅更新那些
- 当 express 的 CVE 发布时,立即检查哪些仓库有受影响版本
- 当推送更改 package.json 时,仅解析该仓库
这需要知道哪些依赖存在而无需解析。需要:
- 注册表监视器订阅 npm、RubyGems、PyPI 的新版本发布
- 依赖索引映射包名到仓库
- Webhook 接收器过滤到清单文件的 git 推送事件
依赖索引的现状
在ecosyste.ms,我们跟踪数百万仓库和数十个生态系统的依赖。事件驱动更新所需的数据已经存在:哪些仓库使用哪些包及其版本。缺少的是将其连接到注册表源和可以在变化时触发 dependabot-core 的协调器。
自托管挑战:协调层的重建
dependabot-gitlab 的启示
dependabot-gitlab展示了重建协调层所需的内容。这是一个 Rails 应用程序,为 GitLab 实现缺失的协调器。其 PostgreSQL 模式揭示了超出 dependabot-core 所需的状态:
projects:跟踪 GitLab 仓库及其访问令牌和最后运行状态configurations:存储每个项目解析的 dependabot.ymlupdate_jobs:包含 cron 表达式、next_run_at和last_scheduled_at时间戳update_runs:记录执行历史,包括状态和计时merge_requests:跟踪开放的合并请求:哪个依赖、从 / 到版本、状态、自动合并设置vulnerabilities:本地缓存 GitHub 的 Advisory Databasevulnerability_issues:在 GitLab 中创建的安全问题
调度逻辑
DynamicJobSchedulerJob在 cron 上运行,查询next_run_at <= now的更新作业,并使用行级锁定(FOR UPDATE SKIP LOCKED)将它们加入队列以防止重复调度。VulnerabilityUpdateJob通过 GraphQL 将其本地数据库与 GitHub 的 Advisory Database 同步,按生态系统分页遍历所有建议。
合并请求服务在创建新请求之前检查现有的开放合并请求,处理冲突时的重新基于与重新创建,可以自动批准和自动合并,并在出现新版本时关闭被取代的合并请求。
工程实践:配置优化与监控要点
1. 分组策略优化
使用groups配置选项将依赖集分组(按包生态系统)。Dependabot 然后提出单个 PR,尽可能同时更新组中尽可能多的依赖到最新版本。
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
react-packages:
patterns:
- "react*"
- "@types/react*"
2. 开放 PR 限制控制
通过open-pull-requests-limit控制最大开放 PR 数量。初始启用时,Dependabot 最多打开 5 个 PR 开始将依赖更新到最新版本。
3. 依赖排除策略
使用ignore排除特定依赖或版本范围:
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
ignore:
- dependency-name: "webpack"
versions: ["5.x"]
4. 监控指标
关键监控指标包括:
- 更新成功率与失败率
- 平均 PR 创建时间
- 注册表查询延迟
- 冲突检测准确率
- 安全更新响应时间
未来方向:混合模型的可能性
理想的依赖更新系统可能采用混合模型:
- 事件驱动主路径:注册表发布和仓库变更触发即时更新
- 轮询后备路径:定期完整扫描确保没有遗漏
- 增量解析:仅解析变更的文件而非整个仓库
- 智能批处理:相关依赖的协调更新
这种混合模型将结合事件驱动的响应性与轮询的完整性,同时减少不必要的注册表查询。
总结
Dependabot 的架构揭示了现代依赖管理系统的核心挑战:如何在 stateless 的可靠性与有状态的协调之间取得平衡。330,000 行 Ruby 代码的 dependabot-core 提供了强大的更新机制,但真正的工程价值在于协调层 —— 这正是 GitHub 保持专有的部分。
对于工程团队而言,理解这种分离至关重要。它解释了为什么自托管 Dependabot 如此复杂,为什么轮询模型效率低下,以及事件驱动更新的潜力所在。随着依赖管理变得越来越关键,我们需要更智能、更高效的更新策略 —— 而理解现有系统的局限性是构建更好系统的第一步。
资料来源: