版本约束、锁文件与不可变引用:读懂一次更新真正换了什么
PR 只改了一行版本,为什么实际换了几十个制品
维护者看到一个“patch 更新”PR:package.json 只改了一个直接依赖,CI 也通过,于是快速合并。故障发生后再看完整 diff,锁文件里一批传递依赖、下载地址与完整性哈希同时变化;另一个仓库恰好相反,Docker tag 完全没变,但 digest 已指向新镜像。两次评审都被界面上的版本标签骗了。
版本约束回答“哪些候选允许参与解析”,锁文件回答“解析器这一次选了什么”,不可变引用回答“执行时究竟获取哪一份内容”。三者不是强弱递进的同一种字段。约束可以保持不变而解析结果刷新;显示版本可以保持不变而 digest 改变;锁文件可以增加平台校验和而 provider 版本不变。架构评审要判断的是状态变化,不是数几个 + 和 -。
一条可靠更新链应保持这个不变量:合并后的声明、派生锁、不可变身份与验证证据描述同一组可重建输入。 只改声明不重建锁、只改可读 tag 不固定内容、手工改锁而不经原生解析器,都会让这四者分叉。
先把四层状态分开
第一层是声明约束。^1.4.0、~> 5.0、Chart 的版本范围、Feature 的 major tag 都表达兼容意图;精确版本也只是版本身份,Registry 若允许同版本制品被替换,它仍未必是内容身份。约束改变会扩大或收窄未来候选,但不必然说明当前安装内容已经变化。
“SemVer”也不能替各工具解释范围。SemVer 2.0.0 规范定义 MAJOR.MINOR.PATCH、先行版本和构建元数据的含义与排序,但不定义 npm 的 ^、~,也不定义 Terraform/OpenTofu 的 pessimistic operator ~>;SemVer 规范文本采用 CC BY 3.0,具体范围语法仍以解析器文档为准。尤其是 0.y.z:规范明确 0 主版本用于初始开发,公共 API 不应视为稳定,因此“patch 自动低风险”不是规范承诺。先行版本也不会因为看起来更“新”就自动进入所有稳定范围。
第二层是解析选择。包管理器从直接和传递约束求出一个可安装图;Terraform/OpenTofu 从所有模块的 provider 约束选出具体 provider;Helm 从 Chart.yaml 选择子 Chart。解析结果通常进入工具维护的锁文件。锁文件应由原生解析器生成并提交,让本地、CI 与机器人审查同一个图,而不是把它当缓存删掉。
第三层是内容身份。包完整性哈希、OCI digest、Git 完整 commit SHA、provider 包校验和用于判断取回内容是否为预期对象。身份固定不代表内容可信:第一次接受错误 digest,后续只会稳定复现错误;仍需来源、签名、attestation、审查与扫描建立信任。
第四层是执行证据。npm ci、docker build --pull、workflow required checks、terraform/tofu init、helm dependency build、Dev Container lock 生成与容器创建证明声明和身份能被实际消费。更新 PR 只有前三层 diff、没有执行证据,仍不能证明项目兼容。
D 不是单一文件:包依赖通常落在 lockfile,镜像落在 digest,Action 落在完整 SHA,provider、Chart 与 Feature 又各有自己的派生锁。它们共同回答“当前执行身份”,但不能互相替代。
开始实验前先固定权威工具链,而不是安装任意“最新版本”。记录 Node.js 与 npm/pnpm、Terraform 或 OpenTofu、Helm、Dev Container CLI 的实际版本和校验来源,并让机器人、开发机与 CI 使用同一发行线。npm 当前文档主线已是 12,而 GitHub 当前的 Dependabot 支持矩阵列出 npm 7 至 11;Helm 官方同时维护 4.x 与 3.x 文档。托管机器人支持某生态,不代表它已经支持你本地刚升级的解析器。遇到这种窗口,应先固定旧的受支持工具链或改用经验证的自托管更新器,不能让机器人用一种算法写 lock、CI 再用另一种算法重排。
Manifest 与 lock:允许集合不等于当前图
以 npm 为例,package.json 中的范围是根约束,package-lock.json 描述解析后的依赖树、来源与完整性信息。npm 12 的 npm ci 要求已有 lock,并在 lock 与 manifest 不一致时退出,而不是替你改写;官方行为见 npm ci 和 package-lock.json。其他生态字段不同,但评审问题相同:直接依赖是否改变,传递图是否改变,来源 host 是否改变,peer、optional、平台选择是否改变,生命周期脚本或原生二进制是否改变,完整性身份是否改变。
锁文件维护与普通直接升级也不同。声明约束不变时,重新解析可能把允许范围内的传递依赖推进到较新版本;这仍是一项真实供应链变更,应进入独立 PR、运行完整安装与测试,不能标成“无业务变化”。反过来,只改 manifest 而 lock 没变,可能意味着 PR 根本没有改变 CI 的实际安装图。
项目接入时把两种命令分开:更新命令允许写锁文件,验证命令只读并在漂移时失败。CI 不应运行会静默改锁的安装入口后继续测试,否则未提交的新锁只存在于 Runner。一个通用门禁是:执行原生冻结安装或 build,然后执行 git diff --exit-code -- <manifest> <lock> <project-config>;前者证明可消费,后者证明仓库已保存完整解析结果。npm 官方还特别说明,若生成 lock 时使用了 --legacy-peer-deps、--install-links 等会改变依赖树形状的选项,npm ci 必须使用相同配置;因此应把这些值写入项目级 .npmrc 并一同审查,而不是藏在机器人命令行。
lock 也不等于完整可重复构建。它通常不固定运行时、包管理器、操作系统仓库、DNS/TLS 信任、环境变量、源码生成器、编译器和构建时间输入;平台相关 optional 包还可能在不同 OS/CPU 上选择不同节点。可靠结论应缩小为“在已声明工具链、平台、来源和配置下重建出同一输入图”,再用产物摘要或可重复构建验证证明输出是否相同。
Docker tag 与 digest:轨道名和内容地址
镜像 tag 是 Registry 中的可移动名字,digest 是内容寻址身份。app:stable 能自动跟随轨道,却让相同 Git 提交在不同时间构建出不同基础镜像;app@sha256:... 可复现,却不会自动获得后续修复。常见折中是保留 tag@digest:tag 给人兼容语义,digest 给运行时内容身份,更新机器人刷新 digest 并在注释或 PR 元数据中说明轨道。
Docker 官方说明按 digest pull 会固定具体镜像版本,见 Image digests。但多平台镜像还要区分 manifest list/index digest 与平台 manifest digest:前者固定平台集合,后者固定某个 OS/CPU 变体;构建和部署验证应覆盖目标架构,不能只在开发者笔记本证明某个平台可拉取。镜像更新单元通常是 Dockerfile/Compose/部署清单中的引用,加上 SBOM、扫描和至少一次目标架构构建证据。
tag 未变而 digest 改变不是“无版本升级”,而是内容身份改变;digest 未变而只改 tag 注释通常不改变执行内容,却可能改变未来自动更新轨道。评审机器人应把二者标成不同事件。
GitHub Action tag 与完整 SHA:执行第三方代码的边界
owner/action@v4 与分支一样可被引用方移动;完整 commit SHA 指向不可变 Git 对象。GitHub 现在还提供 immutable releases,使与 release 绑定的 tag 和 assets 发布后不可修改,但未绑定 immutable release 的 major/minor tag 仍可被维护者移动,见 Immutable releases 与 Action release tags。消费方不能从 @v4 字符串判断发布者是否启用了该能力,因此高信任 workflow 仍通常写完整 SHA:
- uses: actions/checkout@0123456789012345678901234567890123456789 # v4注释 v4 是人类可读轨道,不参与 GitHub 的执行解析;真正身份是 @ 后完整 SHA。更新时 SHA 与注释应同一个 PR 改变,required checks 重新运行。仅更新注释会制造虚假版本信息,仅更新到另一个 tag 会重新引入可移动引用。组织允许的 Action owner、fork、网络访问和 Secret 暴露仍由平台策略控制;固定 SHA 不会自动让 Action 代码安全。
Terraform 与 OpenTofu provider lock:版本、来源和平台哈希
根模块中的 required_providers 声明 source address 与版本约束,.terraform.lock.hcl 保存具体 provider 选择和可接受的包校验和。Terraform 官方说明当前 lock 跟踪 provider,不锁远程 module;terraform init -upgrade 才会忽略旧选择并在约束内重新选择,见 Terraform dependency lock。OpenTofu 使用同名 lock,根模块可包含 .tf 或 .tofu 配置,其选择与校验行为见 OpenTofu dependency lock。两者文件格式相近但默认 Registry、签名校验和兼容承诺并非同一产品合同,不能把一次 CLI 生成结果当作另一 CLI 的证据。
三个字段要分别审:version 是选中的 provider 版本;constraints 是当时考虑过的约束说明,不是安装时重新决策的唯一权威;hashes 是不同包/平台可接受的校验和集合。只新增哈希而版本未变,可能是为新平台补全合法包身份,不等同 provider 功能升级;但它扩大了可接受制品集合,仍要确认来源与签名者。Terraform 把首次接受校验和描述为 trust on first use;OpenTofu 可用 OPENTOFU_ENFORCE_GPG_VALIDATION=true 收紧为要求签名校验。无论哪种工具,lock 证明“与已接受集合一致”,不自动证明发布者值得信任。
团队跨 Linux、macOS 与 Windows 时,不要让每台机器首次运行后零散追加哈希。使用 terraform providers lock -platform=... 或 tofu providers lock -platform=... 按受支持目标预填,并审查签名信息;Terraform 的 providers lock 与 OpenTofu 的 providers lock 都提供相应入口。使用私有镜像时还要证明镜像包匹配已接受的上游校验和。
Terraform 与 OpenTofu 不能因为共享文件名就混用同一 PR 的生成证据。项目必须声明权威 CLI、Registry 来源和目标平台矩阵;更新机器人调用同一 CLI 生成 lock,CI 用该 CLI 执行只读 init、validate 和 plan。远程 module 没有进入 provider lock 时,应单独固定精确版本或不可变 Git ref,并作为另一类更新面管理。
Helm Chart.lock:update 会重谈,build 只重建
Chart.yaml 的 dependencies 保存名称、repository 与版本约束;helm dependency update 在约束内选择依赖、更新 charts/ 并生成 lock。helm dependency build 从 Chart.lock 重建,不重新协商;若没有 lock,它会退回类似 update 的行为。两条命令的官方边界见 Helm 4 的 helm dependency update 与 helm dependency build;仍运行 Helm 3 的项目应切换到官网 3.x 文档并用该主版本生成和验证,不能拿 4.x 帮助文本替代 3.x 现场证据。
所以 CI 应要求 Chart.lock 已存在,再运行 build 和 helm lint/helm template。把 build 当 update 使用会让缺失 lock 的仓库在不同时间解析到不同子 Chart。一个 Chart 更新 PR 应同时审查约束、lock 中的具体版本和 digest、下载来源,以及模板渲染和 schema 变化;charts/ 是否提交由团队制品策略决定,但不能同时把 vendored 包和 lock 都当成无人负责的缓存。
Dev Container lock:Feature tag 之外还有 OCI 解析身份
Dev Container Feature 通常在 devcontainer.json 中以 OCI 引用声明,例如 major tag;对应 lock 可记录解析后的 version、resolved digest 与 integrity。位置随配置入口变化:根目录 .devcontainer.json 对应根目录 .devcontainer-lock.json,.devcontainer/devcontainer.json 对应同目录 devcontainer-lock.json。不要只凭文件名猜位置,应由项目固定的 CLI 在 workspace folder 上生成并提交实际产物。Dev Container 官方的 Dependabot 示例展示了根目录配置与 .devcontainer-lock.json 同步更新,见 Dependabot integration;参考 CLI 的 build/up 默认生成 lock,--frozen-lockfile 强制使用现有 lock,见 Dev Container CLI。该 CLI 目前采用 MIT 许可且仍标注 active development;规范能力、CLI 实现和 VS Code/Codespaces 托管行为要分别验证。
这个 lock 只覆盖支持的 Dev Container 依赖语义,不会自动锁住 Dockerfile 内所有安装脚本、apt 仓库、curl 下载或基础镜像。更新单元应包含配置与 lock,并实际创建容器验证 Feature 安装、用户权限、架构和初始化命令。CI 只做 JSON schema 校验,无法证明 OCI 内容可拉取或 Feature 可执行。
用一组 diff 看清六种变化
下面的 Node.js 实验不访问 Registry,也不声称替代原生工具。它生成一组精简 before/after 文件,用语义门禁检查 manifest/lock、tag/digest、Action SHA、provider lock、Chart lock 与 Dev Container lock 是否同步。需要受支持的 Node.js 和 Git;只在临时目录写合成文件,不需要云账号、Registry 凭据或仓库写权限。
把代码保存为 reference-diff-lab.mjs:
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const root = path.resolve(process.argv[2] || "reference-diff-lab");
const before = path.join(root, "before");
const after = path.join(root, "after");
const bad = process.argv.includes("--bad");
const marker = path.join(root, ".reference-diff-lab");
if (root === process.cwd() || root === path.parse(root).root) {
throw new Error("workdir must be a disposable child directory");
}
if (fs.existsSync(root) && !fs.existsSync(marker)) {
throw new Error(`refusing to delete unmarked directory: ${root}`);
}
function write(base, name, body) {
const file = path.join(base, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body.endsWith("\n") ? body : body + "\n");
}
fs.rmSync(root, { recursive: true, force: true });
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(marker, "generated by reference-diff-lab.mjs\n");
const pkg = version => JSON.stringify({ dependencies: { "demo-lib": `^${version}` } }, null, 2);
const pkgLock = version => JSON.stringify({ packages: {
"node_modules/demo-lib": { version, integrity: `sha512-${version.replaceAll(".", "")}` }
}}, null, 2);
write(before, "package.json", pkg("1.4.0"));
write(before, "package-lock.json", pkgLock("1.4.2"));
write(after, "package.json", pkg("1.5.0"));
write(after, "package-lock.json", pkgLock(bad ? "1.4.2" : "1.5.1"));
write(before, "Dockerfile", "FROM registry.example.invalid/app:stable@sha256:" + "1".repeat(64));
write(after, "Dockerfile", "FROM registry.example.invalid/app:stable@sha256:" + "2".repeat(64));
write(before, ".github/workflows/ci.yml", "steps:\n - uses: actions/checkout@" + "a".repeat(40) + " # v4");
write(after, ".github/workflows/ci.yml", "steps:\n - uses: actions/checkout@" + "b".repeat(40) + " # v4");
const tf = constraint => `terraform {
required_providers {
demo = { source = "example.invalid/acme/demo", version = "${constraint}" }
}
}`;
const tfLock = version => `provider "example.invalid/acme/demo" {
version = "${version}"
constraints = "~> 2.3"
hashes = ["h1:${version.replaceAll(".", "")}EXAMPLE="]
}`;
write(before, "infra/versions.tf", tf("~> 2.3"));
write(before, "infra/.terraform.lock.hcl", tfLock("2.3.4"));
write(after, "infra/versions.tf", tf("~> 2.3"));
write(after, "infra/.terraform.lock.hcl", tfLock("2.3.7"));
const chart = range => `dependencies:
- name: demo
version: "${range}"
repository: https://charts.example.invalid`;
const chartLock = version => `dependencies:
- name: demo
repository: https://charts.example.invalid
version: ${version}
digest: sha256:${version.replaceAll(".", "")}example`;
write(before, "chart/Chart.yaml", chart("~1.8.0"));
write(before, "chart/Chart.lock", chartLock("1.8.2"));
write(after, "chart/Chart.yaml", chart("~1.9.0"));
write(after, "chart/Chart.lock", chartLock("1.9.1"));
const dev = major => JSON.stringify({ features: {
[`registry.example.invalid/features/tools:${major}`]: {}
}}, null, 2);
const devLock = (major, version, digest) => JSON.stringify({ features: {
[`registry.example.invalid/features/tools:${major}`]: {
version,
resolved: `registry.example.invalid/features/tools@sha256:${digest}`,
integrity: `sha256:${digest}`
}
}}, null, 2);
write(before, ".devcontainer/devcontainer.json", dev(1));
write(before, ".devcontainer/devcontainer-lock.json", devLock(1, "1.7.0", "3".repeat(64)));
write(after, ".devcontainer/devcontainer.json", dev(2));
write(after, ".devcontainer/devcontainer-lock.json", devLock(2, "2.1.0", "4".repeat(64)));
function read(name) { return fs.readFileSync(path.join(after, name), "utf8"); }
const checks = [
["manifest-lock", /\^1\.5\.0/.test(read("package.json")) && /1\.5\.1/.test(read("package-lock.json"))],
["docker-digest", /app:stable@sha256:[0-9a-f]{64}/.test(read("Dockerfile"))],
["action-full-sha", /uses:\s*[^@]+@[0-9a-f]{40}\s+#\s+v4/.test(read(".github/workflows/ci.yml"))],
["provider-lock", /version = "2\.3\.7"/.test(read("infra/.terraform.lock.hcl")) && /hashes/.test(read("infra/.terraform.lock.hcl"))],
["chart-lock", /~1\.9\.0/.test(read("chart/Chart.yaml")) && /version: 1\.9\.1/.test(read("chart/Chart.lock"))],
["devcontainer-lock", /features\/tools:2/.test(read(".devcontainer/devcontainer.json")) && /"version": "2\.1\.0"/.test(read(".devcontainer/devcontainer-lock.json"))]
];
for (const [name, ok] of checks) console.log(`${ok ? "OK" : "FAIL"} ${name}`);
const diff = spawnSync("git", ["diff", "--no-index", "--", before, after], { encoding: "utf8" });
process.stdout.write(diff.stdout);
process.stderr.write(diff.stderr);
if (diff.error || ![0, 1].includes(diff.status)) {
throw diff.error || new Error(`git diff failed with status ${diff.status}`);
}
if (checks.some(([, ok]) => !ok)) process.exitCode = 2;运行正向实验:
node reference-diff-lab.mjs reference-diff-lab开头应依次出现六行 OK。随后 diff 展示六种不同语义:包 manifest 与 lock 同改;镜像 tag stable 不变而 digest 改变;Action 注释 v4 不变而完整 SHA 改变;provider 约束不变而 lock 在允许范围内前进;Chart 约束与 Chart.lock 同改;Dev Container Feature major、解析版本和 digest 同改。git diff --no-index 在发现差异时本身可能返回非零,这不是实验失败;脚本以语义检查结果决定最终退出码。
再运行反向实验:
node reference-diff-lab.mjs reference-diff-lab --bad这次 package.json 已允许 ^1.5.0,package-lock.json 却仍保存 1.4.2。预期第一行是 FAIL manifest-lock,其余对象仍为 OK,脚本退出码为 2。失败证据定位在“声明与派生锁分叉”,修复不是重跑所有 CI,而是使用项目权威包管理器重新解析并提交 lock,再执行冻结安装和 git diff --exit-code。去掉 --bad 重跑后六行恢复为 OK。
这个静态实验只证明评审规则能够区分状态,没有下载真实制品。接入真实项目后用原生工具补完证据:
npm ci --ignore-scripts
docker build --pull -t reference-check .
terraform -chdir=infra init -lockfile=readonly && terraform -chdir=infra validate
# OpenTofu 项目使用:tofu -chdir=infra init -lockfile=readonly && tofu -chdir=infra validate
helm dependency build ./chart && helm lint ./chart
devcontainer build --workspace-folder . --frozen-lockfile
git diff --exit-code -- package-lock.json .npmrc infra/.terraform.lock.hcl chart/Chart.lock .devcontainer/devcontainer-lock.json--ignore-scripts 适合先验证解析与完整性,但不能替代需要生命周期脚本的真实构建;Docker 命令会执行 Dockerfile 中代码,应在隔离环境运行;IaC 验证不要携带生产 apply 权限;Helm 私有仓库凭据不要写进命令行历史。Dev Container Feature 的 lock 生成和容器创建应使用项目已固定的 Dev Container CLI 入口,并在 CI 中限制 Registry 与网络权限。
从失败 diff 反推哪一层坏了
声明改变、lock 不变:更新工具没有运行原生解析器,运行目录错误,私有源下载失败,或 PR 把派生文件排除。先运行冻结安装;若它报告 manifest/lock 不一致,证据已足够,不要手工编辑 lock。
lock 改变、声明不变:可能是合法的传递依赖刷新、provider 允许范围内升级或补全平台哈希,也可能是缓存、Registry 镜像或工具版本漂移。检查解析器版本、来源 host、目标平台和更新命令;不能自动把它归为低风险 patch。
tag 不变、digest 改变:Registry 中轨道内容前进,执行身份已经变化。查看镜像 provenance、SBOM、漏洞与目标架构构建;若该 tag 本不应移动,暂停更新并调查发布权限。
Action 仍是 tag 或 branch:PR 没有建立不可变执行身份。改为完整 SHA,并保留可读版本注释;随后验证仓库/组织 Action allowlist、Secret 权限与 required checks。
provider 版本相同、hashes 改变:确认是新增目标平台的官方/受信签名校验和,还是来源 Registry、镜像或缓存改变。校验和不匹配应阻断;删除 lock 或启用破坏 lock 的缓存选项会绕过本来用于发现替换制品的证据。
Chart.lock 或 Dev Container lock 缺失:原生 build 可能重新协商依赖,开发环境或部署渲染因此随时间漂移。先生成并提交 lock,再在干净缓存中重建;不要用已有 charts/ 或本机 OCI 缓存证明仓库可复现。
权限、凭据和敏感数据怎样进入这条链
更新身份需要读取版本元数据和制品,生成 lock 还可能访问不同下载主机;仓库写权限只用于更新分支。把 Registry 元数据读、制品下载、Git 分支写和合并授权拆成不同能力。Terraform/OpenTofu plan、Helm 集群连接和 Dev Container 初始化脚本不需要在“重建锁”阶段获得生产凭据。
锁文件可能泄露私有 Registry hostname、包路径、Git source 或内部 module 地址。提交前按数据分级审查,但不要通过删除 lock 来隐藏架构;优先使用不含凭据的规范地址、仓库级访问控制与日志脱敏。Token、用户名、CA 私钥和带认证参数的 URL 不得进入 manifest、lock、机器人配置或 diff 评论。
第三方安装脚本、包生命周期脚本、Docker build、Action 与 Dev Container Feature 都可能执行代码。更新 Runner 应无生产 Secret、限制网络出口、使用临时工作区,并在完成后销毁下载缓存或按可信域分区缓存。固定 digest/SHA 缩小了漂移窗口,不会缩小执行代码本身的权限。
容量、成本与长期治理
锁文件刷新会放大工作:一个根约束可能重算大量传递节点;多平台 provider lock 会增加 Registry 请求与哈希;多架构镜像构建会消耗构建分钟;Chart 与 Feature 更新会下载 OCI/Chart 制品。分组策略要围绕兼容单元与共享验证,而不是把所有“patch”塞进一个巨型 PR。大型 diff 应输出直接变化、传递变化、来源变化、身份变化和验证成本摘要,帮助 reviewer 把注意力放在供应链边界。缓存键至少包含 lock 摘要、权威工具版本和目标平台;否则旧缓存可能让错误 lock 假通过,跨信任域共享缓存还会扩大投毒面。
团队应维护权威解析器及其版本入口、每类 lock 的 owner、目标平台矩阵、允许的 Registry/Action owner、签名与 provenance 策略、更新窗口和回滚证据。机器人升级自身解析器时,先用合成 diff 与代表仓库 dry run 比较;若 lock 大面积重排但业务约束没有变化,先解释格式或算法迁移,再扩大上线。弃用项按“警告出现、替代项可用、旧行为移除”三阶段治理:警告期建立迁移 PR 和双版本验证,移除前切换所有生成者,切换后确认旧 CLI 不再能重写权威文件。
回滚必须恢复完整更新单元:manifest、lock、digest/SHA、生成配置和相应验证基线一起回退。只把可读版本号改回去,却保留新 digest 或新 lock,得到的是从未评审过的混合状态。紧急回滚后重新运行冻结安装、目标架构构建、IaC validate/plan、Helm render 或开发容器创建,证明旧身份仍可获取;Registry 删除旧制品时,源码回滚并不等于供应链回滚可用。
本地实验可这样清理:
rm -rf reference-diff-lab reference-diff-lab.mjs
# PowerShell: Remove-Item -Recurse -Force reference-diff-lab; Remove-Item reference-diff-lab.mjs执行清理前先确认当前目录下的 reference-diff-lab 含脚本生成的 .reference-diff-lab 标记,且其中没有需要保留的文件;不要把脚本的工作目录参数指向仓库根目录或已有业务目录。
迁移更新产品时不要让新工具重新发明 lock 语义。先固定权威原生命令和目标平台,让新旧工具对同一提交生成 diff;结果一致后切换唯一写入者,再撤销旧身份和分支。长期审计不问“版本号有没有变”,而问四件事:允许集合是否变了、实际解析图是否变了、不可变内容身份是否变了、这些变化是否由相同验证链证明。四个答案齐全,更新 PR 才真正可审查、可复现、可回滚。
