大规模重构治理:从候选冻结到发布回切
一次跨仓改名怎样从小改动变成生产事故
支付平台曾把 submitOrder 改名为 placeOrder。提供方仓库完成了重命名,IDE 的引用查找也显示旧符号已经归零;平台脚本随后给二十多个消费者创建 PR,CI 全绿,于是旧入口和旧事件字段在同一个发布窗口被删除。发布后,网页结算正常,夜间补偿任务却持续失败:它使用的是几周前生成的 SDK;另一个归档仓库仍在构建维护分支;还有一个消费者通过字符串注册处理器,根本没有出现在符号引用结果里。
值班人员先回滚提供方,却发现旧版本已经读不懂迁移后的消息字段。为了止损,团队临时恢复兼容入口、重发 SDK、逐仓回退消费者,并人工补偿部分事件。真正导致事故的不是改名语法,而是三次错误等价:把“当前工作区零引用”等价成“组织内零消费者”,把“PR 已合并”等价成“制品已发布并被实例采用”,又把“代码可回滚”等价成“数据和协议也可逆”。
架构师治理大规模重构时,管理的不是一堆补丁,而是一组持续变化的事实:哪些输入已经被冻结,哪个规则获准处理哪些形状,仓库之间怎样依赖,哪一批已经合并、发布和生效,什么信号必须叫停,以及旧路径何时才有资格删除。只要其中一个状态没有 owner 和证据,自动化越快,事故扩散也越快。迁移开始前应把这些事实压成同一份变更契约:基线定义现在怎样,SLO 保护什么,兼容窗口何时进入和退出,canary 与后续波次怎样放行,代码怎样回滚,数据与协议怎样回切或补偿。
冻结候选集,先回答什么才是权威事实
“搜到了 143 个仓库”只是一次查询输出,不是迁移输入。查询执行期间默认分支仍在变化,索引可能落后,操作者的身份也可能看不到受限仓库。候选冻结的含义,是把每个结论绑定到可重放的坐标:代码宿主、仓库、分支或标签、基线提交、查询或 recipe 版本、索引新鲜度、执行身份、排除项和 owner。冻结后新增的仓库不应悄悄混入当前批次,而要进入增量队列;基线提交变化后,也必须重新判断原补丁是否仍成立。
权威语料通常不止源码默认分支。API 改名至少要核对接口定义、生成器模板、已发布 SDK、构建清单、维护分支、部署清单和运行时调用指标。消息字段迁移还要加入 Schema Registry 或 IDL、死信样本和重放工具;数据库迁移还要加入 DDL、回填作业、CDC、报表与离线任务。代码索引负责发现候选,制品库说明消费者实际拿到了什么,运行指标说明线上仍在调用什么。三类事实互相补证,任何一类都不能代替其余两类。
可以把冻结结果保存为机器可读清单。下面的演示值强调字段关系,生产清单应由仓库目录、代码宿主 API、制品库和观测系统共同生成:
change_id: order-api-rename
rule_version: rename-submit-order/v3
frozen_at: T0
authority:
provider:
repository: platform/order-api
branch: main
commit: "<provider-sha>"
consumers:
- repository: checkout/web
branch: main
commit: "<web-sha>"
owner: checkout-team
dependency: npm:@company/order-client
- repository: settlement/worker
branch: maintenance
commit: "<worker-sha>"
owner: settlement-team
dependency: event:order.submitted.v1
generated_sources:
source: api/order.yaml
output: packages/order-client
excluded:
- selector: "archive:true"
reason: "等待业务 owner 判定是否退役"
owner: developer-platform
evidence:
search_snapshot: artifacts/order-api-rename/search.jsonl
artifact_inventory: artifacts/order-api-rename/artifacts.json
runtime_window: "T0 前一个完整业务周期"
baseline:
build_success_rate: "按仓库类型记录"
ci_queue_p95: "按同类流水线记录"
api_error_rate: "按消费者与入口记录"
api_latency_p95: "按同一流量分层记录"
slo:
protected_slis: [availability, error_rate, latency_p95]
release_budget_source: service-slo/order-api
compatibility_window:
opens_when: "兼容提供方与新 SDK 已发布且回切探针通过"
closes_when: "旧调用无未解释来源、所有候选有终态、数据恢复演练通过"
rollback:
code_owner: release-team
data_owner: order-data-team
code_action: "回切上一制品并反向提交"
data_action: "停新写、核对双写差异、恢复旧读路径或执行补偿"冻结不是禁止业务继续开发,而是建立偏差处理规则。候选仓库在基线后发生变更时,协调器将它标记为 STALE,不再自动套用旧补丁;owner 可以重放规则并形成新基线,也可以给出不迁移的业务理由。无人认领、无法构建、权限不足和已归档不是“零命中”,而是四种不同阻塞状态,必须进入状态记录和停止阈值。基线也不是一个全局平均数:错误率与延迟要按入口、消费者、流量等级和业务周期分层,CI 排队要按同类流水线比较。否则低流量 canary 的正常值会掩盖关键消费者退化,夜间批处理也会被日间平均值吞掉。
用正反样本决定规则能不能碰真实仓库
规则准入要同时证明两件事:应该改的形状确实被改,不应该改的同名文本保持不动。只准备正样本会鼓励宽泛替换,只准备反样本又无法证明召回率。一个实用 fixture 集至少包含直接导入、别名导入、重导出、同名业务方法、注释、字符串、动态属性、生成文件和语法错误;类型敏感迁移还要加入重载、继承与缺失 classpath。
下面的本地实验只需要 PowerShell、Git 与 Node.js。它故意把正反样本放在同一个临时 Git 仓库,便于用 diff 保存失败证据。先创建实验目录:
$lab = Join-Path $env:TEMP "refactor-governance-lab"
if (Test-Path -LiteralPath $lab) { Remove-Item -LiteralPath $lab -Recurse -Force }
New-Item -ItemType Directory -Force "$lab/fixtures", "$lab/rules" | Out-Null
@'
import { submitOrder } from "@company/order-client";
export const checkout = (order) => submitOrder(order);
'@ | Set-Content -LiteralPath "$lab/fixtures/direct.mjs" -Encoding utf8
@'
import { submitOrder as send } from "@company/order-client";
export const retry = (order) => send(order);
'@ | Set-Content -LiteralPath "$lab/fixtures/alias.mjs" -Encoding utf8
@'
const metricName = "submitOrder.latency";
export function submitOrderAudit(record) { return metricName + record.id; }
// submitOrder is an external dashboard label, not an API call.
'@ | Set-Content -LiteralPath "$lab/fixtures/negative.mjs" -Encoding utf8
git -C $lab init --quiet
git -C $lab add fixtures
git -C $lab -c user.name=lab -c user.email=lab@example.invalid commit -m "baseline" --quiet
git -C $lab status --short预期最后一条命令没有输出,表示样本基线已经固定。先故意运行一个错误规则,它把所有文本都替换掉:
Get-ChildItem -LiteralPath "$lab/fixtures" -Filter "*.mjs" | ForEach-Object {
$text = Get-Content -LiteralPath $_.FullName -Raw -Encoding utf8
$text.Replace("submitOrder", "placeOrder") |
Set-Content -LiteralPath $_.FullName -Encoding utf8
}
git -C $lab diff -- fixturesdiff 中 negative.mjs 的指标名、审计函数和注释都会变成 placeOrder。这三行就是稳定的失败证据:规则命中了字符,却没有理解符号身份。此时规则状态只能是 REJECTED,即使编译仍能通过,也不能拿去生成跨仓 PR。用 git -C $lab restore --worktree fixtures 回到基线。
接着给规则写明确的准入契约。这个小规则只接受 JavaScript ESM 的命名导入,并迁移直接调用;别名调用只改导入源符号,局部名字 send 保持不变。真实工程应换成能理解目标语言 AST 和类型的引擎,这里的重点是让准入证据可以独立执行:
// rules/rename-order-api.mjs
export function transform(source) {
const imported = source.replace(
/import\s*\{\s*submitOrder(\s+as\s+[A-Za-z_$][\w$]*)?\s*\}\s*from\s*(["'])@company\/order-client\2;/g,
(_, alias = "", quote) =>
`import { placeOrder${alias} } from ${quote}@company/order-client${quote};`,
);
return imported.replace(/\bsubmitOrder\s*\(/g, "placeOrder(");
}// rules/rename-order-api.test.mjs
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { transform } from "./rename-order-api.mjs";
const fixture = (name) =>
readFile(new URL(`../fixtures/${name}`, import.meta.url), "utf8");
test("迁移直接导入和直接调用", async () => {
const output = transform(await fixture("direct.mjs"));
assert.match(output, /import \{ placeOrder \}/);
assert.match(output, /=> placeOrder\(order\)/);
});
test("迁移来源符号但保留别名调用", async () => {
const output = transform(await fixture("alias.mjs"));
assert.match(output, /placeOrder as send/);
assert.match(output, /=> send\(order\)/);
});
test("保持同名字符串、函数和注释", async () => {
const input = await fixture("negative.mjs");
assert.equal(transform(input), input);
});把两个脚本保存到实验目录的 rules 后执行 node --test "$lab/rules/rename-order-api.test.mjs"。预期输出包含三项通过、零项失败;任何 not ok、非零退出码或 negative.mjs diff 都是准入失败。这个通过结果也只授权规则处理已声明的 ESM 形状,不能推断它能处理 CommonJS、反射、字符串注册或其他语言。
最后追加一个动态样本来验证漏命中是否可见:
@'
import * as orderClient from "@company/order-client";
export const dispatch = (name, order) => orderClient[name](order);
'@ | Set-Content -LiteralPath "$lab/fixtures/dynamic.mjs" -Encoding utf8
rg -n 'orderClient\[' "$lab/fixtures"
node --test "$lab/rules/rename-order-api.test.mjs"测试仍可能全绿,但残留搜索会报告 dynamic.mjs。这不是测试矛盾,而是两种证据回答了不同问题:fixture 证明已声明形状的精度,残留查询暴露尚未归类的候选。只要动态调用还没有 owner 判定,规则就不能获得“全量自动迁移”资格,只能进入受限试点。
用 expand-contract 保住兼容路径
跨仓库迁移最稳妥的状态不是“所有仓库同时切换”,而是提供方在一段窗口内同时支持新旧契约。expand 先新增 placeOrder,旧的 submitOrder 仍委托给同一实现,并在旧入口记录可区分的调用指标;消费者随后逐批迁移;contract 只有在所有候选都有终态、旧调用没有未解释来源、SLO 仍在预算内、回切与数据恢复演练通过后,才删除旧入口。
兼容窗口不是一个日历截止日,而是一组进入与退出条件。进入条件包括兼容提供方、可回退制品、新 SDK、消费者识别标签、旧入口指标和回切探针全部可用;窗口内明确旧版本的支持责任、缺陷修复策略、最长允许采用版本与例外 owner;退出条件则绑定完整业务周期、离线消费者、维护分支和第三方契约。到了计划结束点但证据不够,应延长兼容或明确阻断旧客户端,不能为了如期收尾直接删除旧路径。
兼容层必须薄而确定。两个入口若各自复制业务逻辑,就会形成双实现漂移;双写数据库或事件时,如果第二次写入失败,还会产生半迁移状态。更可靠的设计是让新旧入口尽快汇聚到一个核心实现,并为写操作提供幂等键、去重记录或单调状态转换。旧入口的弃用日志只记录调用方标识与计数,不应把请求正文、Token 或客户数据写进迁移日志。
// provider/order-api.mjs
export async function placeOrder(order, context) {
return persistOrder(order, context.idempotencyKey);
}
export async function submitOrder(order, context) {
migrationMetrics.increment("order_api_old_entry_total", {
consumer: context.consumerId,
});
return placeOrder(order, context);
}下面的依赖图展示为什么发布顺序从提供方扩展开始,而删除顺序必须反过来。生成 SDK 是契约的可发布制品,不是可以忽略的构建副产物:
对于 HTTP、消息和数据字段,expand-contract 的对象不同。HTTP 可以先增加新字段并继续接受旧字段;事件可以先让消费者容忍新旧版本,再让生产者发送新版本;数据库可以先加可空列或新表、回填并双读,最后停止旧写。关键不变量始终相同:旧版本实例与新版本实例在混跑期间必须能交换有效数据。
依赖图决定批次,不是仓库名字排序
把一百个仓库平均切成十批,看似公平,却可能把提供方和消费者放进错误顺序。批次应从依赖图导出:节点是仓库、制品、协议或数据作业,边表示“后者发布前依赖前者具备兼容能力”。有向无环部分可以拓扑排序;出现环时,先用兼容适配器、协议双读或临时桥接切断环,再谈自动化发布。
canary 批次优先选择调用链短、流量可控、测试完整、owner 在线且能独立回切的消费者。它的任务是校准规则、CI 负载和运行指标,不是追求仓库数量,也不能只选没有真实流量的样板仓。canary 至少要覆盖一条真实读链、一条真实写链和一个可观测失败路径;达不到时只能证明工具链可运行,不能证明生产波次可放行。
后续波次同时受四种容量限制:规则置信度、评审吞吐、CI runner/制品库容量、发布与值班容量。每一波都应声明输入仓库、依赖前置、SLO 观察窗口、最大并发、放行 owner、停止后的处置和回切坐标;只有上一波的制品、部署与运行证据闭合,下一波才能生成或合并。任何一项容量饱和都应缩批,而不是继续创建等待中的 PR。
每个仓库在控制面中至少经历 DISCOVERED → APPROVED → PATCHED → CI_PASSED → MERGED → RELEASED → OBSERVED。关闭 PR、基线漂移、owner 拒绝和回切则进入独立终态,不能从总数里消失。状态来源也要明确:搜索快照产生 DISCOVERED,owner 审批产生 APPROVED,代码宿主产生 PR 状态,CI 产生验证结果,制品库或部署平台证明 RELEASED,运行指标证明 OBSERVED。
batches:
- id: canary-consumers
depends_on: [provider-compatible-sdk]
repositories: [checkout/web, sandbox/order-simulator]
max_open_prs: 2
required_owners: [checkout-team, developer-platform]
- id: settlement-workers
depends_on: [canary-consumers]
repositories: [settlement/worker, settlement/replay]
max_open_prs: 2
release_after: "旧入口错误率与延迟保持基线"
- id: contract-old-entry
depends_on: [settlement-workers, dynamic-call-review]
repositories: [platform/order-api]
action: remove-submitOrder让 PR、CI、合并和发布各自证明一件事
PR 证明补丁可审查,不证明它已经发布;CI 证明给定提交在给定环境通过,不证明运行实例正在使用该制品;合并证明默认分支接纳了变化,不证明维护分支或离线客户端已经迁移。控制面应保留 commit SHA → CI run → artifact digest → deployment revision 的链路,避免拿另一个提交的绿灯为当前制品背书。
一次批次的推荐顺序是:先发布提供方兼容版本,再发布生成 SDK;消费者 PR 从低风险批次开始,CI 通过后由仓库 owner 合并;合并提交构建不可变制品,部署系统按批次灰度;观察完成后才放行下一批。旧入口删除必须是独立 PR 和独立发布,不与最后一批消费者迁移捆绑。这样遇到故障时,可以暂停收缩而不撤销已成功的消费者迁移。
CI 门禁至少包含格式和 diff 检查、编译或类型检查、单元测试、契约测试、规则幂等、残留候选扫描。对生成代码,还要验证生成器输入与输出一致,不能手改产物后跳过再生成。对维护分支,则用该分支自己的构建基线验证,不能把默认分支的 CI 结果复用过去。
自动合并只能消费明确状态。规则置信度、CODEOWNERS 审批、必需检查、基线未漂移和发布窗口同时成立时才允许进入队列;队列中的 PR 若因上游合并重新基线化,必须重跑 CI。对共享 runner,限制同时打开和同时执行的 PR 数,避免迁移流量把日常修复饿死。
停止阈值要早于事故阈值
停止阈值的目的不是宣布迁移失败,而是在证据变差时阻止继续扩大影响。它分三层:生成层停止创建新补丁,合并层停止进入主分支,发布层停止向更多实例扩散。已经在途的任务应进入可解释状态,不能粗暴取消后丢失日志、临时分支和制品坐标。
下面是一份可执行的批次策略示例。数字是实验值,真正阈值要根据仓库基线、SLO、CI 容量和完整业务周期测量确定:
policy:
baseline_ref: order-api/migration-baseline
slo_ref: service-slo/order-api
generation:
stop_when:
unowned_candidates: "> 0"
parser_or_build_failures: "> 0 in canary"
manual_fix_ratio: "> 0.10"
merge:
stop_when:
required_check_failures: "> 0"
stale_baselines: "> 0"
ci_queue_p95: "> 2 * baseline"
release:
stop_when:
error_rate: "> baseline + remaining_error_budget"
latency_p95: "> latency_slo_budget for observation window"
old_entry_calls: "increases after a consumer release"
rollback_probe: "failed"
on_stop:
create_new_changesets: false
preserve_logs_and_patches: true
notify: [migration-owner, affected-repository-owners, release-owner]运行指标要能解释迁移状态,而不只是展示总 PR 数。迁移流指标包括候选仓库总数、已归类比例、无人认领数、各状态停留时长、人工修正率、规则重跑后新增 diff、CI 失败类型和队列年龄。运行面指标包括新旧入口调用量、按消费者拆分的旧入口比例、错误率、延迟、重试、死信、幂等冲突、双读差异和回切探针。容量面还要观察 runner 饱和度、制品库请求、索引延迟、补丁存储与 API 限流。
停止阈值必须从服务 SLO 和迁移基线推导。可用性、错误率和延迟等 SLI 先绑定目标服务的 SLO 与剩余错误预算,再规定本波次能消费多少预算、观察多久、由谁放行;CI 队列、人工修正率和解析失败则绑定迁移基线。baseline + error_budget 不是把整个错误预算一次性花完,而是迁移获得的预算切片。阈值触发后默认冻结新生成、合并或发布,恢复条件必须包含根因解释、修复证据和 owner 批准,不能在指标回落一瞬间自动继续扩散。
“旧入口调用为零”也必须带解释。采样日志可能漏掉低频调用,短窗口可能错过月末或批处理任务,标签缺失可能把调用归入 unknown。删除前至少跨过一个完整业务周期,并确认所有已知消费者都升级到目标制品;对离线客户端和第三方集成,则要依据版本采用率、契约到期策略和明确的阻断决定,而不是等待一个永远无法证明的绝对零。
数据与协议越过不可逆点后,代码回滚已经不够
纯代码改名在兼容入口仍存在时通常可逆;数据和协议变更则可能越过不可逆点。删除旧列、覆盖原值、改变枚举含义、让新事件进入旧消费者无法读取的主题、使用不可降级的序列化格式,都会让旧代码即使重新部署也无法恢复服务。架构评审必须在发布前标出这些点,并规定谁有权批准跨越。
可以把变更分为三类。第一类是可直接回退的代码与配置,反向提交并重新发布即可;第二类是可补偿状态,例如新列已回填但旧列仍保留,需要先停写、校验差异再回切读路径;第三类是不可逆副作用,例如外部通知已发送、旧数据已物理删除或第三方已消费新语义,只能前向修复和业务补偿。把三类都写成 git revert 会制造虚假的安全感。
回滚与回切也不是同一个动作。回滚撤销某个代码或配置版本;回切把流量、读取或生产路径切回上一个兼容状态。事故现场通常先回切止损,再判断哪些提交需要反向变更。代码轨的最小坐标是可重新部署的 artifact digest、配置版本和反向提交;数据轨的最小坐标是事实源、写入水位、Schema 版本、回填批次、双写差异和补偿队列。两条轨必须各有 owner,并在同一个演练中证明顺序兼容。
一个可演练的顺序是:冻结新波次和自动合并,停止高风险写入,切回旧入口或旧读路径,验证探针与关键指标,隔离在途事件,按水位重放或补偿,最后再逐仓回滚代码。若旧代码不能读取已经写入的新格式,就不能先部署旧制品;应先恢复兼容读、转换数据或前向修复。每一步都要记录开始状态、完成证据和执行 owner,演练还要证明重复执行不会重复扣款、重复发消息或覆盖较新的数据。
对于双写,回切前必须回答“哪一边是事实源”。若新旧存储都可能被独立更新,简单切换读取会丢失较新的状态。更稳妥的迁移让写入经过一个权威入口,携带版本或幂等键,并持续计算双读差异;差异未收敛时禁止删除旧结构。协议迁移则保留旧消费者可理解的字段或独立版本通道,直到重放、死信和延迟消费者都完成验证。
用第二个本地实验验证停批与回切
规则测试只能证明单文件变换,不能证明批次控制会在失败时停下。继续使用前面的临时目录,创建一个极简状态清单和门禁脚本:
{
"batch": "canary-consumers",
"repositories": [
{ "name": "checkout/web", "owner": "checkout-team", "ci": "passed", "released": true },
{ "name": "settlement/worker", "owner": "", "ci": "passed", "released": false }
],
"runtime": {
"errorRateDelta": 0.001,
"waveErrorBudget": 0.002,
"oldEntryCalls": 14,
"oldEntryCallsPrevious": 20,
"codeRollbackProbe": "passed",
"dataRecoveryProbe": "passed"
}
}// rules/batch-gate.mjs
import { readFile } from "node:fs/promises";
const raw = await readFile(process.argv[2], "utf8");
const state = JSON.parse(raw.replace(/^\uFEFF/, ""));
const failures = [];
if (state.repositories.some((repo) => !repo.owner)) {
failures.push("UNOWNED_CANDIDATE");
}
if (state.repositories.some((repo) => repo.ci !== "passed")) {
failures.push("CI_NOT_PASSED");
}
if (state.runtime.errorRateDelta > state.runtime.waveErrorBudget) {
failures.push("ERROR_BUDGET_EXCEEDED");
}
if (state.runtime.oldEntryCalls > state.runtime.oldEntryCallsPrevious) {
failures.push("OLD_ENTRY_CALLS_INCREASED");
}
if (state.runtime.codeRollbackProbe !== "passed") {
failures.push("CODE_ROLLBACK_PROBE_FAILED");
}
if (state.runtime.dataRecoveryProbe !== "passed") {
failures.push("DATA_RECOVERY_PROBE_FAILED");
}
if (failures.length) {
console.error(JSON.stringify({ decision: "STOP", failures }));
process.exitCode = 2;
} else {
console.log(JSON.stringify({ decision: "CONTINUE" }));
}把 JSON 保存为 $lab/batch-state.json,脚本保存为 $lab/rules/batch-gate.mjs,执行:
node "$lab/rules/batch-gate.mjs" "$lab/batch-state.json"
"exit=$LASTEXITCODE"预期标准错误包含 {"decision":"STOP","failures":["UNOWNED_CANDIDATE"]},退出码为 2。这证明无人认领候选会阻止扩大批次,即使 CI 和运行错误率都正常。给 settlement/worker 填入 owner 后重跑,预期输出 CONTINUE 且退出码为 0。
再把 oldEntryCalls 改成大于 oldEntryCallsPrevious,门禁应以 OLD_ENTRY_CALLS_INCREASED 停止;把 errorRateDelta 改成大于 waveErrorBudget,应出现 ERROR_BUDGET_EXCEEDED。最后分别把 codeRollbackProbe 与 dataRecoveryProbe 改成 failed,应得到 CODE_ROLLBACK_PROBE_FAILED 与 DATA_RECOVERY_PROBE_FAILED。这些反向实验分别模拟新发布重新引入旧调用、波次耗尽获批的 SLO 预算切片、旧制品无法回切和数据恢复路径失效;任一项都必须停批,不能用其余绿灯抵消。实验结束后先确认 $lab 指向临时目录,再执行 Remove-Item -LiteralPath $lab -Recurse -Force 清理。
Owner、权限、敏感数据和容量要进入长期制度
迁移 owner 对跨仓状态与停止决定负责,规则 owner 对 fixture、版本和误漏命中负责,仓库 owner 对业务语义与合并负责,发布 owner 对制品和部署顺序负责,数据或协议 owner 对不可逆点负责,安全 owner 对身份、凭证和审计负责。角色可以由同一个人承担,但责任不能在“平台团队”这个模糊名称里消失。长尾仓库超过约定时间仍无人认领时,迁移必须停在兼容状态,由服务目录或管理链路决定接管、归档还是豁免。
搜索读取身份、批量创建 PR 的写身份、合并身份和生产发布身份应当分离。规则执行器通常只需要读取代码并写临时工作区,不应同时持有组织级 push Token 和生产凭证。服务账号按仓库白名单授权,Token 放在秘密存储中并定期轮换;日志、patch、PR 正文和失败制品不得回显凭证。Fork、临时分支、执行器缓存和离职人员授权都需要清理期限与审计记录。
集中索引和批量执行会接触私有源码、配置、客户样本和可能误提交的密钥。搜索结果与 patch 采用与源码同等级的访问控制,日志只保存必要片段并做脱敏,制品设置保留期,跨区域传输和 SaaS 数据驻留经过安全评审。发现真实密钥时应走密钥撤销与轮换流程,不能因为补丁随后删除了字符串就认为风险已经消失。
容量成本也不是最后才看的账单。每轮迁移要记录仓库数、源码体积、候选数、规则执行时长、峰值内存、clone 与制品流量、CI 分钟、队列等待、评审时长、失败重试和补丁存储。平台据此限制并发与批次,给日常 CI 留出保底容量。自托管系统还要预算索引、执行器、缓存和备份;托管平台则核对席位、API 限流、数据驻留与退出清除。工具价格和能力会变化,采购时以目标部署方式的官方条款为准。
长期保留的不是一次迁移看板,而是可复用资产:带版本的权威语料清单、正反 fixture、规则与校验和、依赖图、批次状态事件、指标定义、不可逆点审批和回切演练记录。规则引擎、parser、构建镜像或代码宿主升级时,先在这些样本上回归;连续多个批次都满足候选稳定、人工修正率不恶化、CI 与运行指标回到基线、回切探针有效,才扩大自动化权限。
当所有候选都有明确终态,旧入口在完整业务周期内没有未解释调用,数据与协议仍保留经过验证的恢复路径,临时凭证和执行资源已经清理,迁移才算退出。这个终点不是“旧字符串搜不到”,而是兼容目标、运行不变量和责任链同时成立。
