依赖更新策略编译:风险分级、稳定期、分组与调度窗口
周一早上,团队同时收到几十个依赖更新 PR。测试队列被占满,真正的业务修复等不到 Runner;维护者为了止血,把机器人全局暂停。几天后,一个生产运行时的安全修复也没有出现。复盘时,每个人都记得“低风险更新可以合并,高风险更新要人工看”,却没有人能从配置解释:某个候选版本为何被归入这个组、为何在这个时段创建、为何获得这个并发配额,以及冻结期间什么例外仍能通行。
问题不在于机器人太勤快,而在于自然语言策略没有被编译成确定的机器决策。版本号只提供候选变化的一部分信息;依赖是否位于运行时路径、是否处在 0.x、是否来自不可变 digest、是否跨 major、发布时间是否足够久、是否属于必须同步升级的兼容单元,都会改变风险。调度窗口、并发上限和冻结规则再把“能升级”变成“现在能否创建分支或 PR”。如果这些层次混在一条通配规则里,最后命中的字段看似合理,组合结果却可能完全相反。
先看清策略编译器保存了哪些状态
一次更新运行并不是“扫描到新版就开 PR”。机器人先从 manifest、lockfile、镜像标签或其他引用中抽取当前值,再由 datasource 查询候选版本;版本约束和版本比较器过滤不合法候选;规则为剩余候选附加风险、稳定期、分组、优先级与启停状态;调度器判断当前运行是否落在允许窗口;最后,分支和 PR 配额决定哪些候选进入平台。
这条链上至少有四种不同的“没有 PR”。没有候选,说明版本源或约束过滤后为空;稳定期未满,说明候选存在但仍是 pending;窗口关闭,说明策略允许更新但当前运行不能创建;配额耗尽,说明候选已经排队。把这四种状态都解释成“机器人没工作”,会让限流、Registry 故障和规则误杀长期混在一起。
Renovate 的 packageRules 会评估全部匹配规则,并合并它们的配置;同一字段冲突时,后面的规则可能覆盖前面的规则,因此官方建议从低优先级规则排到高优先级规则,见 packageRules 配置参考。Dependabot 的 groups 则是首个匹配组获得该更新,后续组不再接收它,GitHub 在安全更新分组说明和版本更新分组示例中都明确了顺序影响。两者都“看顺序”,但一个是多规则合并后的后者覆盖,一个是分组归属的首个命中;不能用同一种心智模型审配置。
把版本风险分成机器可判定的轴
团队常说 patch 低风险、major 高风险,这只能作为第一层。SemVer 约定 major 表达不兼容变化,但 0.x 依赖可以在 minor 甚至 patch 中破坏接口;Renovate 也在 matchUpdateTypes 文档中特别提醒要为 0.x 设计额外规则,见更新类型匹配说明。镜像 digest 更新通常不改变可读标签,却替换了实际供应链身份。开发依赖的 major 可能只影响 lint,运行时 SDK 的 patch 却可能改变重试或序列化行为。
因此风险分类至少要投影到这些机器字段:
变化幅度:major、minor、patch、digest、pinDigest、lockFileMaintenance,它描述版本比较器看到的变化,而不是业务影响。依赖路径:生产运行时、构建工具、测试工具、基础镜像、部署控制面。它决定失败会在编译期、CI、启动期还是在线流量中暴露。成熟度:稳定 major、0.x、预发布版本、没有发布时间戳的来源。它决定稳定期证据是否可信。
兼容耦合:独立库、同一上游仓库的一组包、客户端与代码生成器、镜像与 Terraform module 等跨生态单元。它决定应单独审查还是原子分组。紧急性:普通版本更新、安全修复、供应链撤回或组织冻结。它决定能否绕过常规窗口,但不能自动绕过验证。
风险不是一个手写标签,而是这些轴编译出的结果。例如“生产运行时 + major”进入 risk:high,单独 PR、需要 owner 审查;“开发依赖 + stable minor/patch”进入 risk:low,可以小组化;“任意 0.x 更新”至少提升一级;“digest 变化”附加 supply-chain-identity,即使标签没变也不自动合并。这样,当业务问“为什么它没进低风险组”时,日志能够回答命中了哪一轴,而不是让维护者猜通配符。
用 Renovate 把政策编译成规则
示例按 Renovate 43.266.0 的 schema 校准。Renovate 发布频率很高,生产环境不应追随浮动 latest,而应固定 CLI 版本或容器 digest,并在升级机器人本身时先跑配置验证和影子 dry run。Renovate CLI 采用 AGPL-3.0 许可;Mend 同时提供免费的云托管 Community App、免费的 Community 自托管入口和付费 Enterprise,三者的运行、升级、日志与支持责任不同,不能把“源码免费”等同于“托管服务没有成本”。版本入口见官方 Releases,许可文本见Renovate license,托管选择见官方运行方式。
仓库已安装 Renovate App 时,仓库侧在默认分支提交 renovate.json,平台侧 App 负责调度与身份;自托管时还要由平台管理员安排进程运行、升级镜像、保留日志,并提供读取仓库与版本源、创建机器人分支和 PR 所需的最小权限。策略维护者不需要组织管理 Token,机器人也不需要合并或绕过分支保护。本地配置验证只读取文件:先把团队批准的 Renovate 固定在仓库工具依赖或受控工具镜像中,再运行 npx --no-install renovate-config-validator renovate.json。--no-install 让本机缺少固定工具时直接失败,避免验证命令临时下载一个未经评审的新版本;真正的 dryRun=full 仍需读取目标仓库元数据和候选版本源,但不需要写分支或合并权限。权限边界见Security and Permissions,配置层级与仓库文件发现见配置概览。
下面是一份可作为小团队起点的配置。时间、并发和稳定期都是演示值,生产值应由 Runner 容量、平均 PR 服务时间、发布节奏和风险预算推导。字段后的解释比数字更重要。
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"timezone": "Asia/Shanghai",
"schedule": ["* 1-5 * * 1-5"],
"prConcurrentLimit": 4,
"prHourlyLimit": 2,
"dependencyDashboard": true,
"packageRules": [
{
"description": ["普通候选先经过稳定期"],
"matchDatasources": ["npm"],
"minimumReleaseAge": "3 days",
"minimumReleaseAgeBehaviour": "timestamp-required",
"prCreation": "not-pending",
"internalChecksFilter": "strict"
},
{
"description": ["非 major 的开发工具按低风险组处理"],
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "development tools non-major",
"addLabels": ["dependencies", "risk:low"]
},
{
"description": ["运行时 major 保持独立并要求人工批准"],
"matchDepTypes": ["dependencies"],
"matchUpdateTypes": ["major"],
"groupName": null,
"dependencyDashboardApproval": true,
"addLabels": ["dependencies", "risk:high"]
},
{
"description": ["0.x 不继承低风险自动化"],
"matchCurrentVersion": "/^0\\./",
"groupName": null,
"automerge": false,
"dependencyDashboardApproval": true,
"addLabels": ["risk:pre-stable"]
},
{
"description": ["冻结生产依赖的普通版本更新"],
"matchDepTypes": ["dependencies"],
"matchUpdateTypes": ["major", "minor", "patch"],
"matchBaseBranches": ["main"],
"enabled": false
},
{
"description": ["有 owner 和到期日的冻结例外"],
"matchPackageNames": ["example-runtime"],
"matchDepTypes": ["dependencies"],
"matchUpdateTypes": ["major", "minor", "patch"],
"matchBaseBranches": ["main"],
"enabled": true,
"dependencyDashboardApproval": true,
"addLabels": ["freeze-exception", "owner:runtime"]
}
]
}minimumReleaseAge 等待的是每个候选版本自己的发布时间,不是等待上游停止发布。候选未满稳定期时,Renovate 会产生 renovate/stability-days 状态;datasource 必须能提供受支持的发布时间戳,否则 timestamp-required 会继续阻断。若希望稳定期内连分支和 PR 都不创建,需要像示例一样配合 prCreation: "not-pending" 与严格内部检查过滤,具体状态变化见minimumReleaseAge 官方说明。稳定期降低“刚发布即采用”的暴露,不证明版本兼容,更不能替代 CI。它也只约束 Renovate 已识别的候选;包管理器重算 lockfile 时新引入的传递依赖可能不经过同一候选检查,因此支持冷却策略的包管理器还应独立配置相同底线,Renovate 的稳定期机制说明明确提示了这条供应链缺口。
schedule 是仓库允许创建或更新分支的窗口,不是自托管进程的触发器。进程必须在窗口内至少运行一次,才有机会处理候选;窗口之外 Renovate 仍可查版,而已有分支是否继续更新还受 updateNotScheduled 影响,所以“关窗”等于“所有机器人活动停止”是错误预期。仓库内 schedule 也不提供精确到分钟的保证,见调度语义。prHourlyLimit 限制新 PR 的产生速率,prConcurrentLimit 限制同时打开的 PR 数;前者不能代替后者。已有分支的 rebase 仍可能触发 CI,因此只控制 PR 数不等于控制全部 Runner 消耗,prHourlyLimit 说明还区分了更严格的提交速率限制。若目标是安静时段完全不占 Runner,应同时校准自托管触发器、仓库 schedule、updateNotScheduled、rebase 策略和平台队列,而不是只改一条 cron。
冻结规则故意排在普通风险规则后面,例外又排在冻结规则之后。这样 example-runtime 的运行时版本更新会先被风险规则分类、再被 enabled:false 冻结、最后由包名、依赖类型、更新类型和目标分支共同限定的规则恢复为 enabled:true;标签是可合并数组,审批字段则保持开启。例外没有自动合并,也不会放行 digest、其他依赖类型或其他分支,只恢复这段冻结范围内“可以创建候选”的资格。冻结例外还应在代码评审系统中绑定 owner、工单和到期条件,因为 Renovate 配置本身不会替你执行组织审批。
Dependabot 中同一政策如何表达
Dependabot Version Updates 由默认分支上的 .github/dependabot.yml 启用,配置语法固定写 version: 2;这不是 Dependabot 服务或包管理器的版本号。托管服务由 GitHub 运行,公开的 dependabot-core 当前发布线为 0.378.0、采用 MIT 许可,但自行运行 core library 并不等价于复制 GitHub 的凭据代理、调度、PR 服务和安全边界。标准 GitHub-hosted 或 Dependabot self-hosted runner 的更新作业不消耗包含的 Actions 分钟,larger runner 按常规费率计费;self-hosted runner 只是把作业放进受控网络,不是把 Dependabot 服务整体变成自托管。边界见Dependabot on Actions与dependabot-core 仓库。
提交配置需要仓库写权限;私有 Registry 凭据必须放入 Dependabot secrets,而不是 Actions secrets 或 YAML 明文。Dependabot 发起的 pull_request 等工作流默认拿只读 GITHUB_TOKEN,只注入 Dependabot secrets,不会得到普通 Actions secrets;需要额外权限的发布、签名或集成测试应拆成受保护的后续作业,不能为了让机器人 PR 通过就把仓库级 Token 全局放宽。私有网络源可使用带 dependabot 标签的 self-hosted runner,启用前先确认 Runner 在线,否则任务会一直排队。完整字段见Dependabot options reference,凭据与 Runner 见私有 Registry 接入。
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 4
cooldown:
default-days: 3
groups:
runtime-family:
applies-to: version-updates
patterns:
- "@example/runtime-*"
update-types:
- "minor"
- "patch"
development-tools:
applies-to: version-updates
dependency-type: "development"
patterns:
- "*"
exclude-patterns:
- "@example/runtime-*"
update-types:
- "minor"
- "patch"
ignore:
- dependency-name: "example-frozen-runtime"
update-types:
- "version-update:semver-major"groups 的第一命中规则决定归属,因此窄的 runtime-family 必须放在宽的 development-tools 前面,后者还显式排除运行时家族。GitHub 托管的 Version Updates 当前默认已有三天冷却,示例把 default-days: 3 显式写出,是为了把组织政策留在代码评审里;可配置值为 1 到 90 天。cooldown 不影响安全更新,安全修复若要分组,需要另建一个名字不同且声明 applies-to: security-updates 的组;安全 PR 由 advisory 触发,也不服从版本更新 schedule。allow 与 ignore 同时命中时由 ignore 获胜,这个过滤顺序在选项参考中有明确说明。
Dependabot 的 open-pull-requests-limit 是版本更新的开放 PR 容量,不等价于 Renovate 的每小时创建速率;版本更新默认容量为 5,而安全更新另有独立容量,不会因版本 PR 占满而共用同一队列。它也没有把任意组织冻结、审批流程和自动合并策略全部编进一个字段。复杂冻结可以通过 ignore 暂停指定更新,或将某生态的 limit 设为零暂停版本 PR;安全更新是否继续、分支保护如何执行,必须单独验证。长期无人处理时 Dependabot 还会自动暂停创建和 rebase,Dashboard 或 job log 中的 paused 状态必须纳入“没有 PR”排查。GitHub 的停用版本更新说明与自动暂停说明分别描述了人为停用和不活跃停用。
正向实验:逐步看见每条规则留下的状态
下面的 Python 3 脚本不连接 GitHub、Registry 或真实仓库,只模拟最容易误判的规则合并。它把中间 trace 打出来,使“最后为何 enabled”成为可审计证据。保存为临时目录中的 policy_lab.py。
import argparse
import fnmatch
import json
RULES_GOOD = [
{"name": "runtime-major", "dep_type": "runtime", "update": "major",
"set": {"risk": "high", "approval": True, "group": None}},
{"name": "freeze-runtime", "dep_type": "runtime",
"set": {"enabled": False, "reason": "release-freeze"}},
{"name": "owned-exception", "package": "example-runtime",
"set": {"enabled": True, "approval": True,
"reason": "exception-with-owner-and-expiry"}},
]
def matches(rule, dep):
checks = {
"package": fnmatch.fnmatch(dep["package"], rule.get("package", "*")),
"dep_type": rule.get("dep_type", dep["dep_type"]) == dep["dep_type"],
"update": rule.get("update", dep["update"]) == dep["update"],
}
return all(checks.values())
def compile_policy(rules, dep):
state = {"enabled": True, "risk": "normal", "approval": False,
"group": "default", "reason": "baseline"}
trace = []
for rule in rules:
if matches(rule, dep):
before = dict(state)
state.update(rule["set"])
trace.append({"rule": rule["name"], "before": before,
"after": dict(state)})
return state, trace
def main(mode):
dep = {"package": "example-runtime", "dep_type": "runtime",
"update": "major"}
rules = list(RULES_GOOD)
if mode == "bad-order":
rules = [RULES_GOOD[0], RULES_GOOD[2], RULES_GOOD[1]]
state, trace = compile_policy(rules, dep)
print(json.dumps({"mode": mode, "trace": trace, "final": state}, indent=2))
expected_enabled = mode == "good"
if state["enabled"] != expected_enabled:
raise SystemExit("POLICY_ASSERTION_FAILED")
print("POLICY_ASSERTION_OK")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=["good", "bad-order"])
main(parser.parse_args().mode)先运行正向路径:
python policy_lab.py good预期 trace 依次出现 runtime-major、freeze-runtime、owned-exception。最终状态应保留 risk: high 和 approval: true,同时 enabled: true,理由是带 owner 与到期管理的精确例外。末行应为:
POLICY_ASSERTION_OK这个输出证明的是规则合并顺序,不证明 Renovate 已成功访问 Registry 或创建 PR。把真实配置提交前还要运行 schema validator;自托管环境可用 dryRun=full 和 debug 日志观察抽取、查版、分支与 PR 决策,但 dry run 的级别和日志位置应以自托管 dryRun 参考为准。
反向实验:把例外放错一行,合法升级被静默冻结
运行:
python policy_lab.py bad-order这次 trace 中 owned-exception 先把 enabled 改为 true,随后宽泛的 freeze-runtime 又把它覆盖为 false。脚本把这种错误当作反向实验的预期结果,所以末行仍是 POLICY_ASSERTION_OK;关键证据是最终 JSON:
{
"enabled": false,
"risk": "high",
"approval": true,
"group": null,
"reason": "release-freeze"
}真实系统里的表象通常是“这个包很久没有 PR”,不是配置语法错误。修复方法不是删除冻结,而是把宽泛基线放前、冻结放后、精确且可审计的例外放最后,然后重新运行 validator 与 dry run,在日志中确认包名同时命中三条规则且最终 enabled=true。Dependabot 的同类错误则表现为包被宽泛首组提前拿走;修复时把窄组前移,并用 exclude-patterns 从宽组显式剔除,再观察下一次 update job 的分组摘要。
稳定期、窗口和并发要解决不同的竞争
稳定期在与上游发布时间竞争:候选越新,撤回、补发和生态兼容信息越少。窗口在与团队变更能力竞争:即使候选已成熟,也可能正值发布冻结或无人值守时段。并发在与 CI、评审和主分支更新速率竞争:过多开放 PR 会互相 rebase,重复消耗测试容量。三者不能相互替代。
容量可以用一个简单守恒关系评估:长期平均进入率不能持续高于合并或关闭率。若每个 PR 的平均 CI 时间增长、失败重跑增多,单纯提高 prConcurrentLimit 只会放大在制品数量和主分支竞争。应先按兼容单元降低无意义 PR 数,再按 Runner 可并行槽位设置分支/提交速率,最后用 PR 年龄与队列等待时间验证。生产阈值不要照抄示例数字;稳定状态应表现为积压不随运行轮次单调增长,高风险 PR 不被低风险组吞并,业务 PR 的等待时间不因机器人运行持续恶化。
冻结也不是 enabled:false 的永久墓地。冻结记录必须有 owner、原因、开始条件、退出条件和最长复核周期;例外必须缩小到包、更新类型和目标分支,并保留必要检查。安全更新可走紧急通道,但“紧急”只允许缩短等待和优先排队,不能让未知供应链身份跳过锁文件差异、构建、测试和 owner 判断。Renovate 的 vulnerability alert PR 会绕过部分常规 schedule 与并发限制,官方在vulnerability alerts 配置中明确说明了这种插队语义;这更要求平台分支保护承担最终门禁。
接入真实项目时先影子编译,再逐级放量
第一次接入不要直接让机器人改整个仓库。先建立更新面清单,确认 manifest、lockfile、Dockerfile、Actions、Terraform、Helm 与自定义引用哪些已被 manager 发现;再在默认关闭或 Dashboard 审批模式下运行一轮,让每个候选都有分类证据。验证顺序可以沿着“发现数、候选数、pending 数、分组后分支数、开放 PR 数”逐层对账,任何一层突然归零都回到上一层查原因。
接着只开放一类可逆的开发依赖非 major 更新,观察至少若干完整运行周期:分组大小是否稳定、CI 是否因 rebase 抖动、平均等待是否回落、失败能否定位到单个成员。再开放生产 patch、生产 minor,最后才评估 major 与受控自动合并。若一个大组失败,先从失败日志定位成员,把它通过精确排除拆出单独 PR,而不是立即取消全部分组。GitHub 的Dependabot 分组故障排查也建议用 exclude-patterns 隔离导致组失败的依赖。
项目配置应由代码 owner 评审,组织 preset 由平台团队维护。仓库可以收紧组织基线,不应通过晚置通配规则静默放宽高风险策略。每次修改规则都保留一组“必须允许”和“必须拒绝”的合成样例,像上面的脚本一样比较最终状态与 trace;仅验证 JSON/YAML 语法不能发现顺序语义回归。
凭据、日志、成本与长期治理
策略文件不应包含 Registry Token、真实内部包名或带认证参数的 URL。Renovate 自托管凭据应由平台级 secret 注入 hostRules,仓库配置只引用允许的主机策略;Dependabot 使用独立的 Dependabot secrets。机器人身份只需要读取目标依赖源、读取仓库内容、创建自己的分支和 PR,不应绕过受保护分支,也不应读取无关仓库 Secret。debug 日志进入集中平台前,要脱敏 URL 查询参数、包名和仓库路径;高基数的“每包每版本”指标应聚合成风险组与状态,不把内部依赖图复制到外部观测系统。
成本主要来自版本源 API、分支重算、lockfile 解析、CI 分钟、制品和评审注意力。治理指标因此不能只数 PR:还要观察发现覆盖率、候选到 PR 的状态分布、稳定期等待年龄、窗口错过次数、配额等待、分组失败率、rebase 次数、合并服务时间和冻结例外年龄。指标出现“长期没有 PR”时,先区分没有候选、规则禁用、凭据失败、API 限流、窗口未命中和配额耗尽。
每条规则都要有 owner 和可删除条件。临时冻结、包级 ignore、旧 preset 和例外若没有到期复核,会把历史事故永久编译进系统。退出或更换机器人时,先把旧工具置为 enabled:false 并让它完成一次清理,再撤销 App、Token 和 webhook;确认旧分支与 Dashboard 已处理后再启用新工具。Renovate 官方说明仓库级 enabled:false 后再次运行会清理其开放 Issue、PR 与分支,见enabled 选项。Dependabot 则要分别处理 Version Updates 配置和 Security Updates 设置,不能只删除 YAML 就假定所有自动更新都已停止。
最终不变量很简单:同一个候选在同一份配置和同一组输入下必须得到可解释的唯一政策;高风险规则不能被宽泛低风险规则降级;冻结例外只能缩小范围,不能扩大权限;队列在稳定负载下能够收敛;任何“没有 PR”都有可观测状态可以解释。做到这些,依赖机器人不再是定时制造 diff 的账号,而是一台可以审计、演练和回滚的升级策略编译器。
