Hotdry.
systems-engineering

Dependabot工作流自动化架构:Stateless库与专有协调层的工程解耦

深入解析Dependabot依赖更新工作流的自动化架构,揭示stateless库与专有协调层的分离设计,以及轮询模型与事件驱动更新的工程权衡。

在 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 获取并注入相关 CVE
  • updating-a-pull-request:刷新现有 PR 时设置为 true

遗憾的是,这个作业定义不会在生成的 PR 中公开。要提取哪些包被更新,需要编写400 行正则解析来从 PR 标题和描述中反向工程包名和版本。

调度挑战:轮询低效与事件驱动更新的可能性

轮询模型的效率问题

当前 Dependabot 使用轮询模型:按计划扫描仓库,检查所有依赖。对于一个有 500 个依赖、每天调度的仓库,每年大约进行 182,000 次注册表查找。大多数日子没有任何变化,但它仍然解析每个清单并检查每个注册表,只是为了发现没有变化并丢弃所有结果。

事件驱动更新的潜力

替代方案是事件驱动更新。如果维护跨仓库的依赖索引,可以翻转模型:

  1. 当 lodash 4.17.22 发布到 npm 时,查询哪些仓库使用低于该版本的 lodash 并仅更新那些
  2. 当 express 的 CVE 发布时,立即检查哪些仓库有受影响版本
  3. 当推送更改 package.json 时,仅解析该仓库

这需要知道哪些依赖存在而无需解析。需要:

  • 注册表监视器订阅 npm、RubyGems、PyPI 的新版本发布
  • 依赖索引映射包名到仓库
  • Webhook 接收器过滤到清单文件的 git 推送事件

依赖索引的现状

ecosyste.ms,我们跟踪数百万仓库和数十个生态系统的依赖。事件驱动更新所需的数据已经存在:哪些仓库使用哪些包及其版本。缺少的是将其连接到注册表源和可以在变化时触发 dependabot-core 的协调器。

自托管挑战:协调层的重建

dependabot-gitlab 的启示

dependabot-gitlab展示了重建协调层所需的内容。这是一个 Rails 应用程序,为 GitLab 实现缺失的协调器。其 PostgreSQL 模式揭示了超出 dependabot-core 所需的状态:

  • projects:跟踪 GitLab 仓库及其访问令牌和最后运行状态
  • configurations:存储每个项目解析的 dependabot.yml
  • update_jobs:包含 cron 表达式、next_run_atlast_scheduled_at时间戳
  • update_runs:记录执行历史,包括状态和计时
  • merge_requests:跟踪开放的合并请求:哪个依赖、从 / 到版本、状态、自动合并设置
  • vulnerabilities:本地缓存 GitHub 的 Advisory Database
  • vulnerability_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 创建时间
  • 注册表查询延迟
  • 冲突检测准确率
  • 安全更新响应时间

未来方向:混合模型的可能性

理想的依赖更新系统可能采用混合模型:

  1. 事件驱动主路径:注册表发布和仓库变更触发即时更新
  2. 轮询后备路径:定期完整扫描确保没有遗漏
  3. 增量解析:仅解析变更的文件而非整个仓库
  4. 智能批处理:相关依赖的协调更新

这种混合模型将结合事件驱动的响应性与轮询的完整性,同时减少不必要的注册表查询。

总结

Dependabot 的架构揭示了现代依赖管理系统的核心挑战:如何在 stateless 的可靠性与有状态的协调之间取得平衡。330,000 行 Ruby 代码的 dependabot-core 提供了强大的更新机制,但真正的工程价值在于协调层 —— 这正是 GitHub 保持专有的部分。

对于工程团队而言,理解这种分离至关重要。它解释了为什么自托管 Dependabot 如此复杂,为什么轮询模型效率低下,以及事件驱动更新的潜力所在。随着依赖管理变得越来越关键,我们需要更智能、更高效的更新策略 —— 而理解现有系统的局限性是构建更好系统的第一步。

资料来源

  1. How Dependabot Actually Works - Andrew Nesbitt
  2. GitHub Docs: Dependabot version updates
  3. dependabot-core GitHub repository
  4. dependabot-gitlab project
查看归档