依赖更新面盘点:从声明位置到可验证 PR 单元
一个补丁已经发布,仓库却安静得像什么都没发生
安全平台指出某个基础镜像需要更新,团队等了一天,没有更新 PR。维护者查看 package.json,依赖机器人运行正常;再往下追才发现,真正进入生产的版本写在 services/api/Dockerfile,预发布环境又从 Helm values 覆盖了一次,夜间任务使用的 GitHub Action 还固定在另一个可移动 tag。机器人不是“坏了”,而是从来没有拥有这几处引用。
这种事故不能靠多装一个机器人解决。自动更新的起点不是产品配置,而是一张仓库事实图:版本声明在哪里,由谁解析,从哪个版本源查询,当前字符串是否可移动,哪个字段记录不可变身份,是否还有工具派生的锁文件,一次 PR 应同时改变哪些文件,以及什么命令能证明解析结果真的一致。少一列,后续策略就可能建立在不存在的覆盖率上。
盘点的运行不变量是:每一个会进入构建、测试、部署或开发环境的外部版本引用,都只有一个明确的更新 owner,并且能从声明追到不可变身份和验证命令。 “扫描到了很多文件”不是不变量;未知引用数稳定归零、重复 owner 被消除、每种更新单元都有验证证据才是。
先把“依赖”从包名扩展为外部身份
应用包只是最显眼的一层。package.json、pyproject.toml 或构建文件声明包约束,包管理器把直接和传递依赖解析进锁文件;Dockerfile 的 FROM、Compose 与部署清单选择镜像;.github/workflows/*.yml 的 uses: 选择会在 CI 身份下执行的 Action;Terraform/OpenTofu 根模块选择 provider 和远程 module;Chart.yaml 选择子 Chart;devcontainer.json 选择镜像和 Feature;安装脚本、Makefile、工具版本文件与内部 YAML 还可能藏着自定义引用。
盘点时应同时画三张相连的图。解析图从直接约束走到传递包、peer/optional 选择和来源;工具链图记录 Node.js/JDK/Python、npm/pnpm/Gradle、Terraform/OpenTofu、Helm、编译器、构建插件与 Runner 镜像,因为它们会改变解析算法或产物;执行图记录基础镜像、部署镜像、Action、远程 module、Chart、Dev Container Feature 和下载脚本。只导出 npm outdated --depth=0 之类的直接依赖清单,会同时漏掉受漏洞影响的传递节点、决定 lock 格式的包管理器版本,以及真正进入交付链的镜像和 Action。
这些对象不能用同一条正则表达式解释。Renovate 把“从文件抽取依赖”的组件称为 manager,把“查询可用版本”的组件称为 datasource;两者是不同责任,官方的 Manager 模块与 Datasource 模块分别维护其行为。Dependabot 则按 package-ecosystem + directory/directories + schedule.interval 建立更新块,并非扫描仓库所有看起来像版本号的字符串;.github/dependabot.yml 的 version: 2 是配置语法版本,不是 Dependabot 产品版本,配置入口见 About the dependabot.yml file。因此盘点必须记录“谁真的理解该语法”,不能只记录“哪个机器人已启用”。
官方支持矩阵也必须作为动态边界保存链接,而不是抄成永久能力表。GitHub 当前的 Dependabot supported ecosystems 分别列出 npm、pnpm、Docker、Helm、Dev Containers、GitHub Actions、Terraform 与 OpenTofu 等入口及受支持工具版本;“能建版本更新 PR”不等于依赖图能识别全部传递依赖,也不等于安全更新、私有 Registry 和 vendoring 都受支持。每次增加生态或升级工具链时,应重新核对这张矩阵及对应 manifest,而不是沿用首次接入结论。
一条可执行盘点记录至少含八个字段:
声明位置:路径、行号和字段,例如 Dockerfile:1/FROM,不能只记仓库名。解析器:npm、Helm、Terraform/OpenTofu、Docker、GitHub Actions manager 或团队自定义 parser。版本源:包 Registry、OCI Registry、Git 仓库、provider Registry、Chart Repository;同名对象换源就是另一身份。
可移动引用:版本范围、tag、branch、channel 等未来可能指向不同内容的名字。不可变身份:解析后的包版本与完整性哈希、镜像 digest、完整 commit SHA、provider 包校验和。派生锁:由解析器生成并应随声明审查的 package-lock.json、.terraform.lock.hcl、Chart.lock、.devcontainer-lock.json 等。
PR 更新单元:必须原子改变的文件和引用;拆开会产生不可构建或不可复现的中间状态。验证命令:用原生解析器重建、只读安装、渲染或检查,并证明没有未提交差异。
这条链把“发现”和“更新”隔开:manager 识别语法后,datasource 才有资格查询候选;不可变身份与派生锁决定 PR 边界;验证失败时状态停在更新分支,不能因机器人有写权限就继续合并。
这里有一个关键分界:不可变身份不一定直接写在声明旁。FROM example/app:stable 只有可移动 tag;Registry 当次返回的 digest 才是内容身份。反过来,image@sha256:... 已固定内容,但如果没有保留可读 tag 或升级策略,维护者仍难判断它属于哪个兼容轨道。盘点要同时保存“人想跟随的轨道”和“机器实际执行的身份”。Docker 官方把 digest pull 定义为固定具体镜像版本,见 docker image pull。
在只读权限下跑第一轮库存
第一次扫描不需要机器人写仓库。使用受支持的 Node.js 运行时、Git 和仓库只读权限即可;先执行 node --version 与 git --version 确认团队环境中的实际版本。若扫描器要查询候选版本,再单独授予指定 Registry 的元数据读取权限;不要在发现阶段给它分支写入、PR、workflow 或生产 Registry 推送权限。私有源凭据通过进程环境或密钥代理短时注入,示例和日志只保留主机类别与状态码。
把下面脚本保存为临时目录中的 inventory-lab.mjs。它不会访问网络;seed 会生成一个合成仓库,scan 输出八个维度并在存在未接管的版本样字符串时失败。示例使用 example.invalid,不会触碰真实项目或凭据。
import fs from "node:fs";
import path from "node:path";
const root = path.resolve(process.argv[3] || "inventory-lab-repo");
const command = process.argv[2] || "scan";
function assertLabRoot() {
const parent = path.dirname(root);
if (parent !== process.cwd() || !path.basename(root).startsWith("inventory-lab-")) {
throw new Error("lab root must be a direct child named inventory-lab-*");
}
}
function write(name, body) {
const file = path.join(root, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body);
}
function seed(hidden = false) {
assertLabRoot();
fs.rmSync(root, { recursive: true, force: true });
write("package.json", JSON.stringify({
private: true,
dependencies: { "demo-lib": "^1.4.0" }
}, null, 2) + "\n");
write("package-lock.json", JSON.stringify({
lockfileVersion: 3,
packages: { "node_modules/demo-lib": {
version: "1.4.2",
resolved: "https://registry.example.invalid/demo-lib/-/demo-lib-1.4.2.tgz",
integrity: "sha512-EXAMPLE"
}}
}, null, 2) + "\n");
write("Dockerfile", "FROM registry.example.invalid/runtime:stable@sha256:1111111111111111111111111111111111111111111111111111111111111111\n");
write(".github/workflows/ci.yml", "steps:\n - uses: actions/checkout@0123456789012345678901234567890123456789 # v4\n");
write("infra/versions.tf", `terraform {
required_providers {
demo = { source = "example.invalid/acme/demo", version = "~> 2.3" }
}
}\n`);
write("chart/Chart.yaml", "dependencies:\n - name: demo\n version: \"~1.8.0\"\n repository: https://charts.example.invalid\n");
write(".devcontainer/devcontainer.json", JSON.stringify({
features: { "registry.example.invalid/features/tools:1": {} }
}, null, 2) + "\n");
write("tools/runtime.env", hidden
? "RUNTIME_RELEASE=7.3.1\n"
: "# dep-source:generic dep-name:runtime value:7.3.1\n");
}
const rules = [
{ kind: "package-manifest", file: /package\.json$/, match: /"demo-lib"\s*:\s*"([^"]+)"/, parser: "npm", source: "npm-registry", movable: true, immutable: "package-lock version+integrity", derived: "package-lock.json", pr: "manifest+lock", verify: "npm ci --ignore-scripts" },
{ kind: "container-image", file: /Dockerfile$/, match: /FROM\s+(\S+)/, parser: "dockerfile", source: "oci-registry", movable: true, immutable: "sha256 digest (tag is a movable track; digest is the executed identity)", derived: null, pr: "Dockerfile", verify: "docker build --pull ." },
{ kind: "github-action", file: /\.github[\\/]workflows[\\/].+\.ya?ml$/, match: /uses:\s*([^\s#]+)/, parser: "github-actions", source: "git-repository", movable: false, immutable: "full commit SHA", derived: null, pr: "workflow", verify: "workflow syntax+required checks" },
{ kind: "provider", file: /\.(tf|tofu)$/, match: /version\s*=\s*"([^"]+)"/, parser: "terraform-or-tofu", source: "provider-registry", movable: true, immutable: "lock version+hashes", derived: ".terraform.lock.hcl", pr: "constraint+provider lock", verify: "init -upgrade then validate" },
{ kind: "helm-chart", file: /Chart\.yaml$/, match: /version:\s*["']?([^\s"']+)/, parser: "helm", source: "chart-repository", movable: true, immutable: "Chart.lock version+digest", derived: "Chart.lock", pr: "Chart.yaml+Chart.lock", verify: "helm dependency build && helm lint" },
{ kind: "devcontainer-feature", file: /devcontainer\.json$/, match: /"([^"\n]+\/features\/[^"\n]+:[^"\n]+)"/, parser: "devcontainer", source: "oci-registry", movable: true, immutable: "lock resolved+integrity", derived: ".devcontainer-lock.json", pr: "devcontainer config+lock", verify: "devcontainer build --workspace-folder . --frozen-lockfile" },
{ kind: "custom", file: /runtime\.env$/, match: /dep-source:(\S+).*dep-name:(\S+).*value:(\S+)/, parser: "annotated-custom", source: "declared-by-annotation", movable: true, immutable: null, derived: null, pr: "owner-defined", verify: "owner-defined" }
];
function files(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name);
if (entry.name === ".git" || entry.name === "node_modules") return [];
return entry.isDirectory() ? files(full) : [full];
});
}
function scan() {
const records = [];
const unresolved = [];
for (const file of files(root)) {
const relative = path.relative(root, file).replaceAll("\\", "/");
const text = fs.readFileSync(file, "utf8");
let claimed = false;
for (const rule of rules) {
if (!rule.file.test(relative)) continue;
const hit = text.match(rule.match);
if (!hit) continue;
claimed = true;
const offset = hit.index || 0;
records.push({
declaration: `${relative}:${text.slice(0, offset).split("\n").length}`,
parser: rule.parser,
versionSource: rule.source,
movableReference: rule.movable,
immutableIdentity: rule.immutable,
derivedLock: rule.derived,
prUpdateUnit: rule.pr,
verification: rule.verify
});
}
if (!claimed && /(?:VERSION|RELEASE|IMAGE|CHART)[_A-Z-]*\s*[=:]\s*["']?v?\d+\.\d+/i.test(text)) {
unresolved.push(relative);
}
}
console.log(JSON.stringify({ records, unresolved }, null, 2));
if (unresolved.length) process.exitCode = 2;
}
if (command === "seed") seed(false);
else if (command === "seed-hidden") seed(true);
else if (command === "scan") scan();
else throw new Error("use: seed | seed-hidden | scan");先跑正向链路:
node inventory-lab.mjs seed inventory-lab-repo
node inventory-lab.mjs scan inventory-lab-repo预期 JSON 中 records 有包声明、镜像、Action、provider、Chart、Dev Container Feature 和自定义引用,unresolved 是空数组,进程退出码为零。重点不是记录数量,而是每条记录同时带有声明位置、解析器、版本源、可移动性、不可变身份、派生锁、PR 单元和验证命令。脚本只是教学扫描器:它故意不承诺完整理解所有生态,真实仓库应由原生解析器或更新机器人的 discovery/dry-run 日志来补强。
镜像记录中的 movableReference: true 不是说构建会忽略 digest。runtime:stable@sha256:... 同时包含一个可移动的候选轨道和一个不可变的执行身份:运行时按 digest 取内容,更新工具仍可沿 stable 查询下一枚 digest。若库存只有一个布尔字段,至少保留这种双重语义说明;成熟实现应拆成 tracking_ref、resolved_identity 和 target_platforms,避免把“轨道会移动”误报成“本次构建会漂移”。
再制造遗漏:
node inventory-lab.mjs seed-hidden inventory-lab-repo
node inventory-lab.mjs scan inventory-lab-repo这次 tools/runtime.env 把版本藏在普通变量中,没有可识别的 owner 注解。预期输出包含:
{
"unresolved": ["tools/runtime.env"]
}并以退出码 2 结束。这个失败证据比“机器人没有开 PR”更有用:它发生在候选查询之前,说明问题是发现缺口,不是版本源无新版本、调度窗口未到或 CI 失败。修复方式是恢复 # dep-source:generic dep-name:runtime value:7.3.1 注解,并让团队的自定义 manager 或脚本接管该格式;重新运行 scan 后 unresolved 应回到空数组。Renovate 的正则自定义 manager 需要明确 datasource、depName 与当前值,官方配置语义见 Custom manager。
从静态库存走到机器人发现证据
静态扫描只能回答“仓库里可能有什么”。要进入项目,应把结果与机器人实际抽取日志连接起来。对每个根目录保存一份机器可读库存,字段至少包括 declaration_id、owner、parser、source_class、update_unit、verify_command 与 last_discovered_run;declaration_id 使用规范化路径、字段和依赖源生成,不要用行号做唯一身份,因为格式化会移动行号。
机器人 discovery 或 dry run 每次输出已抽取集合,与库存做差:
库存存在、机器人未抽取:manager 文件匹配、语法、忽略规则或目录配置有缺口。机器人抽取、库存不存在:出现新更新面,必须先分配 owner 和验证命令,再允许建 PR。同一声明被两个机器人抽取:属于重复管理,可能生成竞态 PR 或互相改写锁文件。
声明已删除、库存仍存在:陈旧资产会制造虚假覆盖率,应由合并后的清理任务移除。
不要把所有更新面强塞进同一个 PR。PR 单元由“共同解析、共同验证、共同回滚”决定:package.json 与其 lockfile 通常原子更新;一个 Helm 子 Chart 的约束和 Chart.lock 一起更新;Terraform/OpenTofu provider 约束与根模块 lock 一起审查;共享基础镜像若会同时改变几十个服务,应根据兼容边界和 CI 容量分批,而不是仅因版本相同就全仓成组。反过来,同一个兼容单元被拆成多个 PR,会让每个 PR 都暂时红灯并占满队列。
验证命令也不能写成一句“跑 CI”。至少分三层:解析器验证证明声明与派生锁一致;构建验证证明不可变身份可获取、校验和正确且产物可生成;项目验证证明测试、镜像扫描、IaC plan、Chart 渲染或开发容器创建符合预期。更新机器人负责生成差异,不应获得跳过这些门禁的权限。
失败时先定位状态,不要先重跑
“没有 PR”至少有六种不同状态。仓库未发现时看安装范围和目录;声明未抽取时看 manager/parser;抽取成功但没有候选时看 datasource、Registry 与版本约束;候选存在却被过滤时看稳定期、忽略、调度和分组策略;分支生成失败时看锁文件重建和凭据;PR 已创建但没有推进时看 required checks、owner 与并发上限。重复点击重跑会消耗 API 与 CI,却不会改变错误状态。
一个常见反例是私有包元数据查询成功,锁文件更新却失败。前者只证明机器人能读版本列表,后者还可能需要下载 tarball、验证签名、执行包管理器或访问另一个制品主机。应把版本源、下载源和完整性源分别建模,并在日志中只记录脱敏 host class、HTTP 状态、parser 阶段和 correlation ID。完整 URL 可能包含内部路径或认证查询参数,不应进入外部日志平台。
另一个反例是 digest 或 SHA 已固定,于是团队认为“不需要更新”。固定只消除了同一提交下的漂移,没有消除过期。盘点必须同时给不可变身份配置更新 owner、候选轨道和最大滞后策略,否则固定引用会永久停留在已知身份上。
权限、容量与成本从盘点阶段就开始约束
发现进程默认只读仓库和 Registry 元数据;生成分支的身份只写目标仓库;合并权限交给平台保护规则,而不是直接给机器人管理员权限。扫描 fork 或不可信变更时,不向其脚本暴露私有 Registry 凭据。自定义解析器只解析文本,不执行仓库中的 shell;必须执行原生 lockfile 工具时,在无生产网络、受限文件系统和无部署凭据的隔离 Runner 中运行。
托管方式改变的是责任,不是上述最小权限。Dependabot 的更新任务由 GitHub 托管,也可在满足产品条件时使用带 dependabot 标签的自托管 Runner;Dependabot 触发的工作流默认得到只读 GITHUB_TOKEN,普通 Actions secrets 不会自动提供,应使用专用 Dependabot secrets,详见 Dependabot on GitHub Actions。标准 GitHub 托管或自托管 Dependabot Runner 当前不计入 Actions 分钟,larger runner 按常规费率计费;自托管 Runner 的补丁、隔离、磁盘清理、并发和内网出口由组织承担。
Renovate 可以使用 Mend 托管 App,也可以自托管 CLI。托管 App 代管 bot 版本、调度与平台令牌;自托管要自行固定并升级 CLI/容器版本、保护平台凭据、配置缓存和并发、采集日志并验证弃用项。Renovate CLI 源码当前采用 AGPL-3.0-only,云托管与商业能力另有服务条款;选型时应分别审查开源许可与所用服务合同,不能把“源码可自托管”写成“所有托管能力都按同一许可免费”。官方的 GitHub platform guide 也明确区分了 Mend 托管 App 与自托管 GitHub App/PAT 的责任。
更新面数量决定容量模型。每个引用都会产生 Registry 查询,派生锁更新会下载制品,候选可能生成分支和 CI。应观测 discovered_total、unowned_total、duplicate_owner_total、lookup_error_total、lock_regeneration_error_total 与按更新单元聚合的 CI 分钟;仓库名、内部包名和路径不要直接作为外部指标标签。API 限流时保留上次成功库存与游标,标记结果陈旧,不能把“本轮未查到”覆盖成“没有更新”。
长期 owner 至少分为仓库平台 owner、生态 parser owner、版本源/凭据 owner 与业务验证 owner。parser 规则升级后用合成 fixtures 回归;仓库模板新增一种引用语法时同步新增库存规则;每个更新单元持续观察“声明存在但最后发现时间停止前进”的停摆信号。覆盖率的分母来自库存,而不是机器人已经认识的对象,否则未知面永远不会进入指标。
机器人本身也是工具链依赖。配置校验应记录实际运行版本和发行渠道,升级前检查 release notes、弃用告警与 manager/datasource 变更;发现旧字段只被兼容读取时,应在兼容期内迁移并用 dry run 比较抽取集合。不要在正文或策略里永久钉死某个“最新版本”,而要固定组织批准的版本或镜像 digest,并给它独立更新 owner。这样上游删除旧选项、停止某生态支持或改变默认值时,库存会产生可审查变化,而不是静默缩小覆盖面。
接管、回滚与退出都要保住库存
接入顺序应是只读库存、dry run 对账、少量目录生成分支、人工审查 PR、扩大覆盖,最后才评估受控自动合并。每一步都保留 declaration_id -> bot -> rule -> verification 的映射。出现误更新时,先暂停对应更新单元,关闭或回退 PR,再恢复旧声明与旧派生锁;不要全局关闭机器人来掩盖一个 parser 错配。
从一种产品迁移到另一种产品时,库存是中立交接物。先让新工具只读发现并与旧工具对账,消除差集;再按生态或目录切换唯一 owner,确认旧工具不再生成该单元分支;最后撤销旧 App、Token、Webhook 与缓存。两个机器人同时拥有同一声明,不叫高可用,而叫双写。
本地实验清理只需删除合成目录和脚本。先确认当前目录下的目标名称,再执行删除:
parent="$(pwd -P)"
lab="$(cd -- "$parent/inventory-lab-repo" && pwd -P)"
test "$lab" = "$parent/inventory-lab-repo" || exit 1
rm -rf -- "$lab"
rm -f -- "$parent/inventory-lab.mjs"$lab = (Resolve-Path -LiteralPath .\inventory-lab-repo).Path
if ((Split-Path -Leaf $lab) -ne 'inventory-lab-repo' -or (Split-Path -Parent $lab) -ne (Get-Location).Path) { throw 'unexpected lab path' }
Remove-Item -LiteralPath $lab -Recurse -Force
Remove-Item -LiteralPath .\inventory-lab.mjs -Force生产退出时不要删除历史库存和审计映射;保留策略版本、最后成功发现、已关闭 PR 与迁移交接证据,删除明文凭据、下载缓存和临时分支。只要仍有一个进入交付链的版本字符串没有 parser、owner、不可变身份或验证命令,仓库就还没有完成依赖更新面盘点。
