[{"data":1,"prerenderedAt":1819},["ShallowReactive",2],{"\u002F2026-05-06":3,"\u002F2026-05-06-rel":396},{"id":4,"title":5,"body":6,"column":379,"date":380,"description":12,"extension":381,"hero_image":382,"meta":383,"navigation":384,"path":385,"seo":386,"series_id":382,"severity":382,"stem":387,"summary":388,"tags":389,"__hash__":395},"posts\u002F2026-05-06-更新包的信任链.md","更新包，能往客户机写任何文件",{"type":7,"value":8,"toc":368},"minimark",[9,13,18,21,24,31,39,42,52,75,78,82,85,89,92,95,102,105,112,119,158,161,164,186,189,192,196,199,214,284,287,290,345,348,361,364],[10,11,12],"p",{},"OTA 在客户机上解包并替换文件，这是一条高权限路径。如果源被投毒或包被构造，后果是任意文件写入或目录穿越。我采用三层独立的防御机制，每一层都针对不同的威胁模型，且它们互相不可替代。",[14,15,17],"h2",{"id":16},"第一层签名验证链","第一层：签名验证链",[10,19,20],{},"每次 OTA 的起点是 release manifest（描述什么版本、文件列表、发布时间）和一个 zip 包体。两者都用 minisign 签名，公钥内嵌在客户端里。验签失败立即终止，后续步骤都走不到。",[10,22,23],{},"这一层的目的是确保来源可信。即使后续步骤再严密，如果源头被投毒了，也挡不住。",[10,25,26,30],{},[27,28,29],"strong",{},"信任链的完整性","。签名验证看似简单，但细节决定能否拦住真实的攻击。曾在 dress rehearsal 时遇到这样的故障：",[10,32,33,34,38],{},"release manifest 的签名文件（release-manifest.sig）生成时缺少 ",[35,36,37],"code",{},"publishedAt"," 字段。launcher 的验证代码强制检查这个字段——它不仅验证签名本身有效，还校验签名的 trusted comment 包含 publishedAt 且与 manifest 内容一致。这样设计是为了确保签名的上下文完整：不只验证\"这个文件确实由 A 签的\"，还验证\"这个文件在时间 T 由 A 签的\"。",[10,40,41],{},"缺少 publishedAt 导致验签必失败：",[43,44,49],"pre",{"className":45,"code":47,"language":48},[46],"language-text","[launcher] Stage 6 OTA check error: trusted_comment missing publishedAt\n","text",[35,50,47],{"__ignoreMap":51},"",[10,53,54,55,58,59,62,63,66,67,70,71,74],{},"排查过程：看 sig 文件实际内容，trusted comment 只有 ",[35,56,57],{},"kind=release_manifest version=0.14.0 signedAt=...","，缺少 ",[35,60,61],{},"publishedAt=...","。跟踪 sign CLI 的代码，",[35,64,65],{},"release-manifest"," 子命令调用 ",[35,68,69],{},"sign::sign_file"," 时 extra_fields 传空数组，没有 publishedAt。修复是给 sign 命令加 ",[35,72,73],{},"--published-at \u003CRFC3339>"," 参数，从 manifest.json 的 publishedAt 字段抠出来。",[10,76,77],{},"这个故障说明了签名链的脆弱面：签名生成端和验证端的合约必须完全一致，否则合法的包也会被拒。反过来说，这个严格的检查正是设计想要的效果——它提高了被攻击的成本。如果攻击者改了包的内容，要伪造一个新签名，不仅要破解私钥，还要用 launcher 能接受的格式生成 trusted comment。",[14,79,81],{"id":80},"第二层解压边界","第二层：解压边界",[10,83,84],{},"即使签名合法，包的内容也可能被精心构造来攻击解压逻辑。这一层用四个独立的检查阻止两类常见攻击：zip bomb 和目录穿越。",[86,87,88],"h3",{"id":88},"解压大小限制",[10,90,91],{},"单个文件大小上限已经存在：4 GiB。但攻击者可以用多个小文件堆积，总解压体积溢出磁盘。N 个 sub-4GiB 的文件累积可以填满任意容量。这是 CVE-2003-1564 级别的威胁。",[10,93,94],{},"防御方法是加总解压上限：10 GiB。这个阈值的选择基于实际情况——完整的 release 包括 panel 应用、多个引擎、二进制工具、数据文件，当前实际体积远低于 10 GiB，留足了安全裕度。",[10,96,97,98,101],{},"大小检查的时机很关键：在解压前累加 entry 的 uncompressed size（从 zip header 读取），一旦超限立即 bail。这样不需要真正解压 10 GiB 的数据就能拦住攻击。当然，zip header 的 size 字段本身可能被伪造——攻击者可以声明 size=0 但实际写入数据。这靠第二个检查兜底：",[35,99,100],{},"io::copy"," 的实际写入字节数仍受 zip stream 的物理限制，不会让你写出超过压缩数据本身的内容。",[86,103,104],{"id":104},"拒绝符号链接",[10,106,107,108,111],{},"zip 格式可以记录条目的 unix 权限模式，其中包括 mode bit 0o120000 表示符号链接。当前代码会调用 ",[35,109,110],{},"File::create(&dest)"," 写入条目，对于 symlink-mode 条目，zip 库不会真创建符号链接，而是当作普通文件写出——这是\"意外安全\"。",[10,113,114,115,118],{},"问题在于这种安全不是明确的。zip 库升级或 API 改变都可能破坏这个假设。防御方法是显式 reject：检查 ",[35,116,117],{},"entry.unix_mode() & 0o170000 == 0o120000","，如果是 symlink mode 直接 bail。",[43,120,124],{"className":121,"code":122,"language":123,"meta":51,"style":51},"language-rust shiki shiki-themes github-light github-dark","if let Some(mode) = entry.unix_mode() {\n    if mode & 0o170000 == 0o120000 {\n        bail!(\"OTA_ZIP_SYMLINK_REJECTED: entry `{}` has symlink mode bit\", entry_name);\n    }\n}\n","rust",[35,125,126,134,140,146,152],{"__ignoreMap":51},[127,128,131],"span",{"class":129,"line":130},"line",1,[127,132,133],{},"if let Some(mode) = entry.unix_mode() {\n",[127,135,137],{"class":129,"line":136},2,[127,138,139],{},"    if mode & 0o170000 == 0o120000 {\n",[127,141,143],{"class":129,"line":142},3,[127,144,145],{},"        bail!(\"OTA_ZIP_SYMLINK_REJECTED: entry `{}` has symlink mode bit\", entry_name);\n",[127,147,149],{"class":129,"line":148},4,[127,150,151],{},"    }\n",[127,153,155],{"class":129,"line":154},5,[127,156,157],{},"}\n",[10,159,160],{},"这样即使 zip 库在未来支持真正创建符号链接，launcher 也会拒绝。当前生产的 OTA 包不应该包含 symlink，如果未来需要支持，那是 spec 变更后的事，明确决策即可。",[86,162,163],{"id":163},"路径越界检查",[10,165,166,167,170,171,174,175,174,178,181,182,185],{},"这个已经存在——",[35,168,169],{},"safe_join"," 函数拒绝 ",[35,172,173],{},"Component::ParentDir"," \u002F ",[35,176,177],{},"Component::Prefix",[35,179,180],{},"Component::RootDir","，防止路径遍历攻击。zip 的 entry name 比如 ",[35,183,184],{},"..\u002F..\u002Fetc\u002Fpasswd"," 会被拒绝。",[86,187,188],{"id":188},"这些检查为什么互补",[10,190,191],{},"大小检查防止资源耗尽（存储满、内存爆炸）；symlink 拒绝防止权限提升（比如创建指向系统敏感文件的链接）；路径越界检查防止文件覆盖（比如解出文件到安装目录外）。单有其中一个都不够——大小检查无法阻止精心放置的 symlink 攻击，路径检查也无法阻止大量小文件堆积。",[14,193,195],{"id":194},"第三层版本单调性","第三层：版本单调性",[10,197,198],{},"已安装的版本号不能倒退。这防止被强推旧的、已知有漏洞的版本。",[10,200,201,202,205,206,209,210,213],{},"检查逻辑在 ",[35,203,204],{},"version_ge"," 函数里：当前版本 >= 要求版本才允许降级逻辑执行。最初的实现用简单的数字拆分比较，后来改用 ",[35,207,208],{},"semver::Version::parse"," 以支持正式的语义版本号，包括预发布版本的顺序（",[35,211,212],{},"1.0.0-rc.2 > 1.0.0-rc.1","）和预发布 \u003C 正式版的规则。",[43,215,217],{"className":121,"code":216,"language":123,"meta":51,"style":51},"fn version_ge(current: &str, required: &str) -> bool {\n    let parse = |s: &str| {\n        semver::Version::parse(s.trim_start_matches('v'))\n    };\n    match (parse(current), parse(required)) {\n        (Ok(c), Ok(r)) => c >= r,\n        _ => {\n            eprintln!(\"[launcher] WARN: version_ge parse failed\");\n            false  \u002F\u002F 解析失败保守判定为 false，走 RunChain 重新 OTA\n        }\n    }\n}\n",[35,218,219,224,229,234,239,244,250,256,262,268,274,279],{"__ignoreMap":51},[127,220,221],{"class":129,"line":130},[127,222,223],{},"fn version_ge(current: &str, required: &str) -> bool {\n",[127,225,226],{"class":129,"line":136},[127,227,228],{},"    let parse = |s: &str| {\n",[127,230,231],{"class":129,"line":142},[127,232,233],{},"        semver::Version::parse(s.trim_start_matches('v'))\n",[127,235,236],{"class":129,"line":148},[127,237,238],{},"    };\n",[127,240,241],{"class":129,"line":154},[127,242,243],{},"    match (parse(current), parse(required)) {\n",[127,245,247],{"class":129,"line":246},6,[127,248,249],{},"        (Ok(c), Ok(r)) => c >= r,\n",[127,251,253],{"class":129,"line":252},7,[127,254,255],{},"        _ => {\n",[127,257,259],{"class":129,"line":258},8,[127,260,261],{},"            eprintln!(\"[launcher] WARN: version_ge parse failed\");\n",[127,263,265],{"class":129,"line":264},9,[127,266,267],{},"            false  \u002F\u002F 解析失败保守判定为 false，走 RunChain 重新 OTA\n",[127,269,271],{"class":129,"line":270},10,[127,272,273],{},"        }\n",[127,275,277],{"class":129,"line":276},11,[127,278,151],{},[127,280,282],{"class":129,"line":281},12,[127,283,157],{},[10,285,286],{},"这一层防的是一种很具体的操作事故：手误把已撤回的旧版本重新发布。如果没这一层，客户机会被自动推回旧版本；有了这一层，旧包虽然验签合法、解压也通过了，但版本号检查会拒绝它。",[14,288,289],{"id":289},"为什么三层都必要",[291,292,293,309],"table",{},[294,295,296],"thead",{},[297,298,299,303,306],"tr",{},[300,301,302],"th",{},"防御",[300,304,305],{},"什么时候失效",[300,307,308],{},"后果",[310,311,312,324,335],"tbody",{},[297,313,314,318,321],{},[315,316,317],"td",{},"签名验证",[315,319,320],{},"私钥泄露",[315,322,323],{},"任何内容都能合法签名，后续检查都白搭",[297,325,326,329,332],{},[315,327,328],{},"解压边界",[315,330,331],{},"被绕过",[315,333,334],{},"zip bomb 填满磁盘、symlink 提权、文件覆盖",[297,336,337,340,342],{},[315,338,339],{},"版本单调",[315,341,331],{},[315,343,344],{},"被推回已知有漏洞的旧版本",[10,346,347],{},"签名验证保证来源可信——这是前提。但\"来自信任方\"的包仍可能是：",[349,350,351,355,358],"ul",{},[352,353,354],"li",{},"意外生成的恶意内容（比如临时文件被打进 zip）",[352,356,357],{},"被中间人修改过（虽然验签会失败，但某些场景下可能有变量窗口）",[352,359,360],{},"通过合法渠道发布的有漏洞的旧版本（这时签名完全合法）",[10,362,363],{},"解压边界独立处理包内容的风险，不依赖于包的签名状态。版本单调性独立处理版本回退风险。三个不同的威胁模型需要三层不同的防御。",[365,366,367],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":51,"searchDepth":136,"depth":136,"links":369},[370,371,377,378],{"id":16,"depth":136,"text":17},{"id":80,"depth":136,"text":81,"children":372},[373,374,375,376],{"id":88,"depth":142,"text":88},{"id":104,"depth":142,"text":104},{"id":163,"depth":142,"text":163},{"id":188,"depth":142,"text":188},{"id":194,"depth":136,"text":195},{"id":289,"depth":136,"text":289},"交付与更新","2026-05-06","md",null,{},true,"\u002F2026-05-06",{"title":5,"description":12},"2026-05-06-更新包的信任链","构建三层防御阻止 OTA 恶意包注入：签名验证、解压边界、版本单调性。",[390,391,317,392,393,394],"OTA","minisign","zip bomb","解压安全","版本管理","NGp6JVDpg3UAbc1LQKtBhL5j8LjKoVKpxrzTxJEqd-8",[397,899,1252],{"id":398,"title":399,"body":400,"column":379,"date":887,"description":404,"extension":381,"hero_image":382,"meta":888,"navigation":384,"path":889,"seo":890,"series_id":382,"severity":382,"stem":891,"summary":892,"tags":893,"__hash__":898},"posts\u002F2026-06-16-把高风险发版固化成skill.md","不让 AI 碰发版，不等于靠人记住每一步",{"type":7,"value":401,"toc":881},[402,405,409,412,415,434,445,448,452,455,458,586,593,596,603,633,636,643,647,650,653,660,730,741,803,806,821,828,831,848,855,858,861,872,875,878],[10,403,404],{},"发版从来不应该由人工记忆驱动。5 月那一轮 OTA 系列故障（FA-005 到 FA-008）和跨版本步进导致的漏 DLL 事故反复说明了这一点：发版是不可逆的、影响全部用户的、细节繁琐的操作，任何一个环节遗漏或顺序错误就导致客户端卡死、文件不一致、激活页死循环。我现在不是让 AI 执行发版——那是禁地——而是把 5 个已经踏过所有坑的发版流程全部固化成 skill，让执行者用最小脑力成本跑完完整链路，而不是依赖文档翻译和记忆。",[14,406,408],{"id":407},"触发词不该触发时绝对不触发","触发词：不该触发时绝对不触发",[10,410,411],{},"一个 skill 最危险的时刻是被错误调用。\"现在是什么版本\"听起来像发版，\"改一下版本号\"听起来像发版，但它们完全不是。",[10,413,414],{},"Wakou 发版 skill 的触发词清单（必读触发词段）就是为了卡死这条线：",[349,416,417,423],{},[352,418,419,420],{},"触发：",[35,421,422],{},"发版 \u002F 发布 \u002F release \u002F 打个新版本 \u002F OTA \u002F OTA 死循环 \u002F 版本 bump \u002F \u002Fwakou-release",[352,424,425,426,429,430,433],{},"不触发：",[35,427,428],{},"改一下 package.json 版本号","（用户只想改文件），",[35,431,432],{},"看看现在是什么版本","（只查询）",[10,435,436,437,440,441,444],{},"反面教材我都经历过。改一下配置文件、改一个环境变量、查一个版本号，因为没有明确的触发词界限，最后莫名其妙走到了发版脚本的某一步。Connector OTA skill 同样列明了不触发场景：",[35,438,439],{},"\"Connector 现在是什么版本\"(只查询)"," ",[35,442,443],{},"\"改 tauri.conf 版本号\"(用户只想改文件)"," — 这些都不触发，即使命令里含了 version、release 这类关键字。",[10,446,447],{},"这条最反直觉但最重要：不该触发时被触发，等于主动送一次误操作机会给自动化流程。",[14,449,451],{"id":450},"步骤不可跳过验证链条硬卡","步骤不可跳过：验证链条硬卡",[10,453,454],{},"一旦 skill 确定要执行，每一步都必须有验证关卡，前一步没通过绝不进下一步。我用 Wakou 全自动发版流程作例子。",[10,456,457],{},"预检（Step 1）是整个流程的闸门。跑这些 Bash 检查：",[43,459,463],{"className":460,"code":461,"language":462,"meta":51,"style":51},"language-bash shiki shiki-themes github-light github-dark","cd \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\ngit status --short                                          # 工作区干净\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass                           # ssh 工具\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign                          # 签名工具\ntest -x \u003C签名 CLI 路径>                                      # 内部签名工具就位\ntest -f \u003C发版签名私钥路径>                                   # 私钥就位\ntest -f \u003CCDN 上传凭据路径>                                   # CDN 凭据就位\n","bash",[35,464,465,475,491,505,517,546,565],{"__ignoreMap":51},[127,466,467,471],{"class":129,"line":130},[127,468,470],{"class":469},"sj4cs","cd",[127,472,474],{"class":473},"sZZnC"," \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\n",[127,476,477,481,484,487],{"class":129,"line":136},[127,478,480],{"class":479},"sScJk","git",[127,482,483],{"class":473}," status",[127,485,486],{"class":469}," --short",[127,488,490],{"class":489},"sJ8bj","                                          # 工作区干净\n",[127,492,493,496,499,502],{"class":129,"line":142},[127,494,495],{"class":469},"test",[127,497,498],{"class":469}," -x",[127,500,501],{"class":473}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass",[127,503,504],{"class":489},"                           # ssh 工具\n",[127,506,507,509,511,514],{"class":129,"line":148},[127,508,495],{"class":469},[127,510,498],{"class":469},[127,512,513],{"class":473}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign",[127,515,516],{"class":489},"                          # 签名工具\n",[127,518,519,521,523,527,530,533,536,540,543],{"class":129,"line":154},[127,520,495],{"class":469},[127,522,498],{"class":469},[127,524,526],{"class":525},"szBVR"," \u003C",[127,528,529],{"class":473},"签名",[127,531,532],{"class":473}," CLI",[127,534,535],{"class":473}," 路",[127,537,539],{"class":538},"sVt8B","径",[127,541,542],{"class":525},">",[127,544,545],{"class":489},"                                      # 内部签名工具就位\n",[127,547,548,550,553,555,558,560,562],{"class":129,"line":246},[127,549,495],{"class":469},[127,551,552],{"class":469}," -f",[127,554,526],{"class":525},[127,556,557],{"class":473},"发版签名私钥路",[127,559,539],{"class":538},[127,561,542],{"class":525},[127,563,564],{"class":489},"                                   # 私钥就位\n",[127,566,567,569,571,573,576,579,581,583],{"class":129,"line":252},[127,568,495],{"class":469},[127,570,552],{"class":469},[127,572,526],{"class":525},[127,574,575],{"class":473},"CDN",[127,577,578],{"class":473}," 上传凭据路",[127,580,539],{"class":538},[127,582,542],{"class":525},[127,584,585],{"class":489},"                                   # CDN 凭据就位\n",[10,587,588,589,592],{},"任何一条 fail，流程停止并告诉用户修什么。如果 git 工作区脏，不是自动 stash，而是问用户：工作区有未提交改动，要先 commit 还是 stash？发版会自动 bump 三个版本文件并 commit。这样做的目的很明确——让用户",[27,590,591],{},"清晰意识到","发版会改动工作区，而不是闭着眼睛走入自动流程。",[10,594,595],{},"收三个密码（Step 2）也有安全约定。不要把密码记到内存或 commit 进文件，只读 shell 的 export。如果用户在过去几分钟内说过这些密码（在 conversation context 里能直接拿到），可以直接传 env 跑；否则必须用户主动导入。",[10,597,598,599,602],{},"跑 release.sh（Step 3）用 ",[35,600,601],{},"run_in_background=true"," 避免 5-15 分钟阻塞主线，把输出 tee 到日志。然后（Step 4）用 Monitor 工具监控 10 个 stage 的进度：",[604,605,606,609,612,615,618,621,624,627,630],"ol",{},[352,607,608],{},"preflight",[352,610,611],{},"detect FROM_VERSION",[352,613,614],{},"tar + scp source to Win build host",[352,616,617],{},"build on Win",[352,619,620],{},"stage + diff-pack OTA full.zip",[352,622,623],{},"minisign full.zip + release-manifest.json",[352,625,626],{},"upload to CDN",[352,628,629],{},"admin: create release + PATCH rollout",[352,631,632],{},"git tag + commit version bump",[10,634,635],{},"每个 stage 完成给用户报一句。出现 ✗ FAILED 立刻 surface 错误。没有\"继续试试能不能救\"，就是暴露失败。",[10,637,638,639,642],{},"这整套链条的目的是",[27,640,641],{},"让人工审核点布满全流程","。不是机器自动发版，而是 AI 陪着用户一步步走完发版，每步都看得清、验得出。",[14,644,646],{"id":645},"故障处置内联把坑写在流程里","故障处置内联：把坑写在流程里",[10,648,649],{},"OTA 系列故障的教训不应该沉在 bug 复盘里。我把它们直接铺进 skill 的执行逻辑。",[10,651,652],{},"Wakou 发版 skill 有整整一章叫「🚨 阻塞性 bug 处置 SOP」。列出了从 G19 到 G31 的 11 个已知 bug：",[10,654,655,656,659],{},"G19 是 OTA modal 反复弹。原因是 state base 残留 stale ",[35,657,658],{},"download.zip.minisig","，当时的修复是：",[43,661,663],{"className":460,"code":662,"language":462,"meta":51,"style":51},"# 预检 sanity#3: data\u002F 不删\nsanity#3 强制阻断 deletes 命中 data\u002F。某次发版失败提示 sanity#3 一定是 build 步骤搞坏了 data\u002F 目录的同步，不要绕过 sanity check 去强发，先 debug 为什么 data\u002F 出现在 deletes 列表。\n",[35,664,665,670],{"__ignoreMap":51},[127,666,667],{"class":129,"line":130},[127,668,669],{"class":489},"# 预检 sanity#3: data\u002F 不删\n",[127,671,672,675,678,681,684,687,690,693,696,699,702,705,708,711,714,717,720,722,725,727],{"class":129,"line":136},[127,673,674],{"class":479},"sanity#3",[127,676,677],{"class":473}," 强制阻断",[127,679,680],{"class":473}," deletes",[127,682,683],{"class":473}," 命中",[127,685,686],{"class":473}," data\u002F。某次发版失败提示",[127,688,689],{"class":473}," sanity#3",[127,691,692],{"class":473}," 一定是",[127,694,695],{"class":473}," build",[127,697,698],{"class":473}," 步骤搞坏了",[127,700,701],{"class":473}," data\u002F",[127,703,704],{"class":473}," 目录的同步，不要绕过",[127,706,707],{"class":473}," sanity",[127,709,710],{"class":473}," check",[127,712,713],{"class":473}," 去强发，先",[127,715,716],{"class":473}," debug",[127,718,719],{"class":473}," 为什么",[127,721,701],{"class":473},[127,723,724],{"class":473}," 出现在",[127,726,680],{"class":473},[127,728,729],{"class":473}," 列表。\n",[10,731,732,733,736,737,740],{},"G25 是跨用户机器后 chat 权限错。根因是 ",[35,734,735],{},"sessions\u002Faudit"," 含绝对路径，guardian sync 扩散。这不是\"修了以后的故事\"，而是",[27,738,739],{},"发版前防御 checklist"," 的一部分：",[43,742,744],{"className":460,"code":743,"language":462,"meta":51,"style":51},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\nssh CHANCHING@\u003C构建机> 'powershell -Command \"\n  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n    Where-Object { $_.Length -lt 200KB } |\n    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n    Select-Object FullName\n\"'\n",[35,745,746,751,756,778,783,788,793,798],{"__ignoreMap":51},[127,747,748],{"class":129,"line":130},[127,749,750],{"class":489},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n",[127,752,753],{"class":129,"line":136},[127,754,755],{"class":489},"# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\n",[127,757,758,761,764,767,770,773,775],{"class":129,"line":142},[127,759,760],{"class":479},"ssh",[127,762,763],{"class":473}," CHANCHING@",[127,765,766],{"class":525},"\u003C",[127,768,769],{"class":473},"构建",[127,771,772],{"class":538},"机",[127,774,542],{"class":525},[127,776,777],{"class":473}," 'powershell -Command \"\n",[127,779,780],{"class":129,"line":148},[127,781,782],{"class":473},"  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n",[127,784,785],{"class":129,"line":154},[127,786,787],{"class":473},"    Where-Object { $_.Length -lt 200KB } |\n",[127,789,790],{"class":129,"line":246},[127,791,792],{"class":473},"    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n",[127,794,795],{"class":129,"line":252},[127,796,797],{"class":473},"    Select-Object FullName\n",[127,799,800],{"class":129,"line":258},[127,801,802],{"class":473},"\"'\n",[10,804,805],{},"任何一项 fail → 不要 GA，修了再 bump 一个 patch 版本。",[10,807,808,809,812,813,816,817,820],{},"Connector OTA skill 的故障字典（§4）则是一张病症与病因的对照表。\"signature verification failed\" 对应的处置是检查 ",[35,810,811],{},"TAURI_SIGNING_PRIVATE_KEY"," 是不是 .sec 文件",[27,814,815],{},"base64 编码后","的内容。\"OTA 不跨级但用户期望直跳\" 的处置是调整 ",[35,818,819],{},"findNextVersion"," 逻辑或在灰度模式放上一个 manifest。",[10,822,823,824,827],{},"关键在",[27,825,826],{},"处置是写死在 skill 里","，不是留在事后复盘让人去翻。OpenClaw Runtime 发版 skill 的故障字典甚至铺了 10 条常见陷阱：BuildKit context 缓存不清导致改动没编进去、gwbridge IP 池满导致 service 无法起、孤儿 docker-proxy 占着 host port、磁盘满导致 openclaw.json 被写成 0 字节。",[10,829,830],{},"每一条都带着：\"现象是什么、根因是什么、怎么修\"。拿 BuildKit 缓存那条：",[832,833,834],"blockquote",{},[10,835,836,837,840,841,844,845,847],{},"症状：rsync\u002FSFTP 把新源推上 prod repo，源文件 grep 确认是新的，但 docker build 出来的镜像里还是旧 bundle。根因：",[35,838,839],{},"--no-cache-filter"," 不可靠，BuildKit 的 build context 层仍可能命中缓存。修法：clawpanel 源改动后，必须用整体 ",[35,842,843],{},"--no-cache","，不要用 ",[35,846,839],{},"。代价：单次 ~20-30 分钟。但这是唯一能确保改动真编进去的方式。",[10,849,850,851,854],{},"这不是\"高级技巧\"或\"经验之谈\"。这是",[27,852,853],{},"发版的必读须知","，写进 skill 里才能确保每次都不遗漏。",[14,856,857],{"id":857},"为什么这样做有效",[10,859,860],{},"高风险操作的安全性来自流程的确定性，不来自执行者的谨慎。人会忘、会打错、会跳步。但固化的流程不会：",[349,862,863,866,869],{},[352,864,865],{},"触发词明确化杀死了\"我不是故意调用发版\"的那类误操作。",[352,867,868],{},"步骤验证链条把每一步的前置条件和验收标准写成 Bash 检查，没法绕过。",[352,870,871],{},"故障处置内联把一个多月里积累的坑提前封死在流程里，新一轮发版不会重蹈覆辙。",[10,873,874],{},"5 个 skill 现在覆盖了：Wakou panel OTA、Connector 桌面端 OTA、OpenClaw runtime 容器全量发布、Codex 助手静默更新、加上客服远程调试的风险边界。它们没有一个是让 AI 执行发版的——都是让 AI 陪着用户，一步步走完一个铺满护栏的流程。",[10,876,877],{},"这是把\"不要让 AI 碰发版\"这条禁区，变成了\"AI 执行发版的框架，确保每一步都验证、每个坑都已知、每种故障都有救法\"的可行路径。",[365,879,880],{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":51,"searchDepth":136,"depth":136,"links":882},[883,884,885,886],{"id":407,"depth":136,"text":408},{"id":450,"depth":136,"text":451},{"id":645,"depth":136,"text":646},{"id":857,"depth":136,"text":857},"2026-06-16",{},"\u002F2026-06-16-skill",{"title":399,"description":404},"2026-06-16-把高风险发版固化成skill","发版是明确不交给 AI 自主执行的操作。通过触发词精确化、步骤验证链条、故障处置内联，把流程固化到 skill，让高风险操作失败概率趋零。",[894,390,895,896,897],"发版流程","自动化","Tauri","Docker Swarm","WbjJuf61wSEx4d5Iov2KhPdG1N6hik51RAl8rAGgug8",{"id":900,"title":901,"body":902,"column":379,"date":1240,"description":906,"extension":381,"hero_image":382,"meta":1241,"navigation":384,"path":1242,"seo":1243,"series_id":382,"severity":382,"stem":1244,"summary":1245,"tags":1246,"__hash__":1251},"posts\u002F2026-06-15-Connector两次发版复盘.md","两天连发两版，第二版是为了补第一版",{"type":7,"value":903,"toc":1230},[904,907,911,914,921,932,935,961,971,975,978,983,990,1005,1016,1021,1024,1039,1050,1055,1062,1065,1070,1085,1088,1092,1099,1102,1113,1117,1120,1123,1126,1131,1134,1139,1150,1155,1158,1162,1169,1175,1178,1197,1200,1207,1210,1217,1227],[10,905,906],{},"桌面端连接组件在一周内连发两版。1.0.10 修了 WebSocket 端点未被消费导致的 17 分钟周期断线；1.0.11 处理紧随其后暴露的僵尸令牌问题与发版脚本漏洞。这两次发版既是问题的递进修复，也是工程实践上从被动补救走向主动设计的转折。",[14,908,910],{"id":909},"_101017-分钟的周期","1.0.10：17 分钟的周期",[10,912,913],{},"用户反馈从五月中旬开始稳定出现：Connector 约每 17 分钟自动断线一次，断后立即重连。现象本身明确，但根因指向了一个被忽视的设计缺陷。",[10,915,916,917,920],{},"服务端在五月下旬已切换 WebSocket 入口地址。原来的端点经 CDN 反代，该 CDN 的 idle 超时配置在 4-9 分钟，时间不固定。一旦断开，客户端用相同的令牌重连，再次击中 idle 超时，形成周期。这本该不是问题——服务端在 ",[35,918,919],{},"\u002Fauto-pair"," 响应里已返回新的 WebSocket 地址，客户端只需消费这个字段切换端点即可。",[10,922,923,924,927,928,931],{},"但客户端代码从未读取过这个字段。ws_runner 始终硬拼 base_url 生成连接地址，App.tsx 只提取了 ",[35,925,926],{},"device_token","，对 ",[35,929,930],{},"ws_endpoint"," 视而不见。结果是服务端返了新地址，客户端装作没看见，继续走旧路径。旧路径经 CDN，CDN idle 触发，17 分钟周期形成。",[10,933,934],{},"修复需要四处联动：",[349,936,937,943,952,955],{},[352,938,939,940,942],{},"在 AppState 加一个 ",[35,941,930],{}," 字段，用 RwLock 包装以便运行时读取",[352,944,945,948,949,951],{},[35,946,947],{},"set_device_token"," 命令新增参数，收到服务端返的 ",[35,950,930],{}," 时校验并持久化到 config.json",[352,953,954],{},"ws_runner 启动与每次重连前都从 RwLock 读取最新地址，不再拼装",[352,956,957,958,960],{},"App.tsx 消费 ",[35,959,930],{}," 字段，传给 invoke",[10,962,963,964,967,968,970],{},"兼容性处理也做了：env 变量 ",[35,965,966],{},"CPA_WS_ENDPOINT"," 可强制指定（灰度用），服务端没返 ",[35,969,930],{}," 时退化到旧路径拼装，config.json 校验失败时保留现有值不覆盖。",[86,972,974],{"id":973},"_1010-发版中的五个坑","1.0.10 发版中的五个坑",[10,976,977],{},"代码改好了，但发版流程成了另一个故事。",[10,979,980],{},[27,981,982],{},"坑 1：假 1.0.10",[10,984,985,986,989],{},"发版脚本在 Windows 构建机上只 scp 了 tauri.conf.json 这一个文件，没有同步整个仓源码。构建机的 git HEAD 还停在 1.0.8 的 commit，Cargo.toml workspace.package.version 还是 ",[35,987,988],{},"0.1.0","。结果是 cargo build 出来的二进制是 1.0.8 代码，但 NSIS 打包时用了 tauri.conf.json 里的\"1.0.10\"字符串当文件名——实际上是 1.0.8 代码套上 1.0.10 的皮。",[10,991,992,993,996,997,1000,1001,1004],{},"应急是用 ",[35,994,995],{},"git ls-files | tar over ssh"," 把真 1.0.10 源码同步过去，清掉 macOS metadata 的 ",[35,998,999],{},"._*"," 文件（tar 直接打了这些，Windows 上 tauri-build 把 ",[35,1002,1003],{},"capabilities\u002F._default.json"," 当 JSON 解析失败），重新 build。",[10,1006,1007,1008,1011,1012,1015],{},"1.0.11 的发版脚本改成了完整源码同步加 ",[35,1009,1010],{},"tar --exclude='._*'"," 和 Win 端 ",[35,1013,1014],{},"Remove-Item '._*'"," 双保险。",[10,1017,1018],{},[27,1019,1020],{},"坑 2：ssh exit code 谜团",[10,1022,1023],{},"phase B 跑完整发版脚本时，step 3（Win build）完成了——NSIS bundle 和 minisign 签名都成功写出来了——但 ssh 仍然返 exit code 1，导致脚本静默退出，step 4-10 全跳过。",[10,1025,1026,1027,1030,1031,1034,1035,1038],{},"当时推测是 ",[35,1028,1029],{},"ssh ... | tail -10"," 触发 SIGPIPE，但这个推测被现场查证打脸了：tail 会完整消费 stdin，不会向上游发 SIGPIPE。真根因当时没查清楚，可能是 PowerShell 某个 cmdlet 设了非零 ",[35,1032,1033],{},"$LASTEXITCODE","，也可能是 ssh 在大量 escape sequence 输出时本身返非零，也可能是脚本框架的 ",[35,1036,1037],{},"pipefail"," 组合出了问题。",[10,1040,1041,1042,1045,1046,1049],{},"1.0.11 用了一个兜底方案：脚本监测 build 成功的标志——",[35,1043,1044],{},"Finished 1 bundle at:"," 和 ",[35,1047,1048],{},"Finished 1 updater signature at:"," 双命中才认为成功，忽略 ssh exit code。这个兜底已经把\"ssh exit code 非零\"这条问题路径变成了已知但可控的状态：即使 ssh 返错，只要 marker 出现了，就继续执行后续步骤。1.0.11 发版时 log 记录了这次抗性：「ssh exit=1 非零，但 build marker 命中，视为成功」。",[10,1051,1052],{},[27,1053,1054],{},"坑 3：gitlink 边界",[10,1056,1057,1058,1061],{},"发版脚本 step 10 尝试 ",[35,1059,1060],{},"git add cpa-connector\u002F...\u002Ftauri.conf.json","，但因为 cpa-connector 是 gitlink（160000 mode）不是真 submodule，git 会静默跳过这条跨边界的路径。结果 tauri.conf.json 没被 add 进 root 仓的 commit。",[10,1063,1064],{},"这里需要在 cpa-connector 子仓内完成一次独立 commit（包含 Cargo.toml、Cargo.lock、package.json、tauri.conf.json），然后回到 root 仓 add 整个 gitlink 指针。1.0.11 的发版脚本改成了这个流程。",[10,1066,1067],{},[27,1068,1069],{},"坑 4 和 5：参数漏传与版本号不同步",[10,1071,1072,1073,1076,1077,1080,1081,1084],{},"Task 9 重构了前端的 ",[35,1074,1075],{},"tryAutoPairOnce"," 这个 helper 时，body 从 ",[35,1078,1079],{},"{ hostname, os, connector_version }"," 意外退化成了 ",[35,1082,1083],{},"{}","，导致服务端拿不到客户端版本号。同时 package.json 里的 version 字段还停留在 1.0.0，跟 Cargo.toml 的 1.0.9 完全脱节。",[10,1086,1087],{},"这两个都算是发版前的信息丢失，1.0.10 发版后才被指出。",[86,1089,1091],{"id":1090},"_1010-的验证与回滚","1.0.10 的验证与回滚",[10,1093,1094,1095,1098],{},"1.0.10 在 5 月 27 日上午 10:34 开始滚动更新。测试机验证了 23 分钟无断线（vs 之前每 17 分钟必断），服务端日志开始出现 ",[35,1096,1097],{},"host=\u003C直连 WS 域名>"," 的访问。4 小时窗口内，新 WS 入口命中 992 次，旧入口仍有 209566 次（因为 1.0.9 客户端的 OTA 是自然滚动，不是强推）。",[10,1100,1101],{},"但随后用户报了一个投诉，初看像是 1.0.10 的问题。经过排查发现是 openclaw-runtime 1.0.23 的一个独立 schema 问题，与 1.0.10 无关。这件事触发了一个 P0，但因为根因被快速定位到另一个模块，没有对 1.0.10 造成滚动阻断。",[10,1103,1104,1105,1108,1109,1112],{},"1.0.10 本身的回滚预案是改 ",[35,1106,1107],{},"apps\u002Fapi\u002Fsrc\u002Fconnector\u002Fupdates.ts"," 的 RELEASE_CHAIN 数组，删掉 ",[35,1110,1111],{},"'1.0.10'"," 这一项，再重部署服务端。已升级的客户端不会自动回退（Tauri updater 不支持降级），但新检查的 1.0.9 用户就不会继续升级了。这一设计决策埋下了一个伏笔：一旦版本链里出现过某个版本，再想把它从升级路径上彻底抹掉就很困难，因为已升级的用户成了\"污染源\"，他们可能带着该版本的各种遗留问题继续在线。这正是分阶段发版与步进升级设计要反复考量的权衡点。",[14,1114,1116],{"id":1115},"_1011两个小时内的闭环","1.0.11：两个小时内的闭环",[10,1118,1119],{},"1.0.10 发版后的第四个小时，某个客户端用失效的令牌反复击中 WebSocket 4401 拒绝，服务端 30 分钟内记录了 5638 次 reject，来自 2 个孤儿 device，token 前缀稳定但 DB 里查不到。",[10,1121,1122],{},"这是另一个被延期的问题。客户端收到 4401（token 失效）时没有自愈逻辑，只会 exponential backoff 后用同一个失效 token 再试一次，陷入\"僵尸状态\"——在线但永远连不上 WS。与其说这是 1.0.10 的新问题，不如说是 1.0.10 的发版暴露了原本就存在的设计缺陷。",[10,1124,1125],{},"1.0.11 同步推进了三个方向的修复：",[10,1127,1128],{},[27,1129,1130],{},"服务端黑名单",[10,1132,1133],{},"cpa-api 新增一个 TokenBlacklist 类，维护一个 LRU 缓存。收到 4401（token 失效）后，把这个 token 加进黑名单，5 分钟内的重试直接返 4401，不走 DB 查询。这样做的好处是降低 DB 压力，坏处是增加内存占用，但 5 分钟的 TTL 和 LRU 限容使得这个成本可控。",[10,1135,1136],{},[27,1137,1138],{},"客户端自愈",[10,1140,1141,1142,1145,1146,1149],{},"改造 ws.rs 的错误类型，让 4401 close code 能被单独识别为 ",[35,1143,1144],{},"WsError::TokenInvalid","。ws_runner 收到这个错误时，立即清空 in-memory 的 device_token（Mutex 设为 None），emit 一个 ",[35,1147,1148],{},"connector:\u002F\u002Ftoken-invalid"," 事件，不 sleep 直接进入下一轮 loop。frontend 端 listen 这个事件，触发 auto-pair 重新获取 token。这样一来，一次失效就能在 5-15 秒内自愈，不会陷入无限重试。",[10,1151,1152],{},[27,1153,1154],{},"发版脚本全自动化",[10,1156,1157],{},"1.0.10 里 step 4-10 需要手工兜底。1.0.11 把 step 1（版本 bump）改成了在子仓内完成 commit（idempotent skip 检查保证重跑不会失败），step 3 加了 build marker 检查，step 4 也用同样的 exit code 捕获逻辑。最后 step 1-10 全部自动完成。",[86,1159,1161],{"id":1160},"_1011-的数据","1.0.11 的数据",[10,1163,1164,1165,1168],{},"1.0.11 在 5 月 27 日下午 14:30 发版。23 分钟内，那个主要的 zombie token（前缀记录为 ",[35,1166,1167],{},"ct_UAGorMM","）的命中频率从发版前的 47\u002F分钟 降到 0.17\u002F分钟。对比是精确的：TTL 是 5 分钟，日志里看到的恰好是 5 分钟周期的 4 次 first-hit——14:35、14:40、14:45、14:50，说明黑名单在按秒级别的精度工作。",[10,1170,1171,1174],{},[35,1172,1173],{},"invalid or revoked token"," reject 日志整体从 53\u002F分钟 降到 3\u002F分钟，94% 的噪声被消除。",[10,1176,1177],{},"从发版开始到完成的总耗时是 80 分钟（spec 起草 → 发版完成），远快于 1.0.10 的 12 小时——主要是因为没有了\"假版本\"和手工兜底这两个坑。",[10,1179,1180,1181,1184,1185,1188,1189,1192,1193,1196],{},"客户端配置清理的验证是这样的：启动 1.0.11 时，旧配置目录（",[35,1182,1183],{},"%APPDATA%\u002Fcpa\u002Fcpa-connector\u002F","）存在且有历史 session.json，cleanup 把它 rename 成 ",[35,1186,1187],{},".bak.20260527-141556","；第二次启动时我手动造了一个 fake session.json，它又被 rename 成了不同时间戳的 ",[35,1190,1191],{},".bak","；第三次启动时老目录已经不存在，log 输出 ",[35,1194,1195],{},"DEBUG no legacy config dir to clean up","，幂等。软删除策略保留了原文件，方便后续排查。",[14,1198,1199],{"id":1199},"发版复盘该记录什么",[10,1201,1202,1203,1206],{},"这两次发版的共同教训是：",[27,1204,1205],{},"预期效果与实际效果的差异往往来自非功能层面，发版前遗漏的验证会以人工兜底的成本在发版中浮现","。",[10,1208,1209],{},"1.0.10 的预期是一次提交、一次 build、一个发版脚本的无缝运行。实际遇到了\"代码没同步\"、\"metadata 污染\"、\"git 跨边界\"、\"参数漏传\"这四个层次递进的问题，每一个都需要在发版当时即时判断、即时应急、即时修复。如果这些问题在代码审查或发版前的干运行中被抓到，成本会低一个数量级。",[10,1211,1212,1213,1216],{},"1.0.11 的收益是对 1.0.10 的这些漏洞做了结构化的修复，但更重要的是建立了",[27,1214,1215],{},"验收指标清单","。版本号同步检查、发版脚本的每一步都有 success marker、gitlink 操作必须在子仓内完成、参数完整性的代码审查项。这不是一次性的修补，而是对\"发版这件事\"的流程重塑。",[10,1218,1219,1220,1223,1224,1206],{},"回滚判据也值得明确。1.0.10 的触发条件是\"ws rejected 反升或 connector 启动失败率 > 0.1%\"，对应的回滚操作是修改 RELEASE_CHAIN 截断升级路径。但正如前面提到的，这个方案有一个根本限制：",[27,1221,1222],{},"已升级的用户成了污染源，无法通过服务端操作让他们回退","。这意味着一旦发出去的版本有不可接受的 bug，修复也必须通过新版本修补，不能指望用户自动回到前一个版本。这推导出一个硬性要求：",[27,1225,1226],{},"发版前的验证必须足够彻底，因为回滚的代价极高",[10,1228,1229],{},"1.0.10 和 1.0.11 的连续发版正好说明了这一点。1.0.10 解决了一个明确的功能问题，但在过程中埋了四个流程坑；1.0.11 不光修了功能缺陷（4401 自愈），也补上了流程漏洞（发版脚本全自动）。如果 1.0.10 能在发版前避免那些坑，1.0.11 就没必要冲这么紧。如果不能，1.0.11 这一次的\"加固\"就成了对下一轮发版的保险——每一次发版都可能遗留新的坑，但流程上的防御等级在递增。",{"title":51,"searchDepth":136,"depth":136,"links":1231},[1232,1236,1239],{"id":909,"depth":136,"text":910,"children":1233},[1234,1235],{"id":973,"depth":142,"text":974},{"id":1090,"depth":142,"text":1091},{"id":1115,"depth":136,"text":1116,"children":1237},[1238],{"id":1160,"depth":142,"text":1161},{"id":1199,"depth":136,"text":1199},"2026-06-15",{},"\u002F2026-06-15-connector",{"title":901,"description":906},"2026-06-15-Connector两次发版复盘","两天内连发两版解决 WS 端点变更与僵尸令牌问题，从手动兜底到全自动发版的演进。",[1247,1248,1249,896,1250],"Connector","WebSocket","发版","Rust","z88KhEQF4il5fu5KEpSAcE6LACpC1v_CsLysuzrCk3Q",{"id":1253,"title":1254,"body":1255,"column":379,"date":1808,"description":1809,"extension":381,"hero_image":382,"meta":1810,"navigation":384,"path":1811,"seo":1812,"series_id":382,"severity":382,"stem":1813,"summary":1814,"tags":1815,"__hash__":1818},"posts\u002F2026-05-27-步进升级为什么增量包不能跨版本跳.md","跳过一个撤回的版本，客户就少了个 DLL",{"type":7,"value":1256,"toc":1795},[1257,1264,1267,1274,1280,1283,1290,1293,1296,1302,1305,1311,1316,1320,1323,1329,1333,1336,1342,1349,1352,1356,1363,1366,1369,1372,1375,1379,1382,1388,1396,1401,1412,1417,1428,1432,1435,1439,1450,1454,1462,1466,1477,1483,1490,1493,1496,1499,1531,1534,1537,1543,1549,1552,1555,1609,1612,1719,1726,1740,1743,1754,1761,1786,1792],[10,1258,1259,1260,1263],{},"1.0.7 客户升级到 1.0.9 后启动崩溃，报 ",[35,1261,1262],{},"STATUS_DLL_NOT_FOUND","。根因在于增量包的构造逻辑与发版序列的交互：任何\"中间 GA 版本被撤回且后续版本以它作为 baseline\"的组合，都会让跳过撤回版本的客户拿到不完整的增量包。",[14,1265,1266],{"id":1266},"增量包的设计前提",[10,1268,1269,1270,1273],{},"OTA 更新包是二进制差分产物——",[35,1271,1272],{},"release.sh"," 在构造增量包时，只列入「FROM → TO 两个相邻版本之间 sha256 实际不同的文件」。以 1.0.8 → 1.0.9 的增量为例：",[43,1275,1278],{"className":1276,"code":1277,"language":48},[46],"1.0.8 dist ↔ 1.0.9 dist\n  vcruntime140.dll (sha256: abc...)  vcruntime140.dll (sha256: abc...)\n                                    ↑ 一致，不进差分包\n  msvcp140.dll (sha256: def...)     msvcp140.dll (sha256: def...)\n                                    ↑ 一致，不进差分包\n  launcher.exe (sha256: old)        launcher.exe (sha256: new)\n                                    ↑ 不同，进差分包\n",[35,1279,1277],{"__ignoreMap":51},[10,1281,1282],{},"相比全量包（500+ MB），增量包通常只有 100-200 MB。对于国内弱网、公网、海外长途网络来说，包体积直接影响下载时间和失败率。选择差分包的代价是必须确保客户的升级链路完整。",[10,1284,1285,1286,1289],{},"这个设计基于一个隐含的假设：",[27,1287,1288],{},"客户严格按发布顺序逐版升级","（1.0.7 → 1.0.8 → 1.0.9 → ...）。每跳一个版本，就叠加一次「FROM→TO」的相邻增量。所有累积变更都会被依次应用，最终到达完整的目标版本。只要这个假设成立，差分机制就是 sound 的。但一旦中间版本被 recall 且升序逻辑允许跳过，假设就被破坏了。",[14,1291,1292],{"id":1292},"发版序列与撤回的交互",[10,1294,1295],{},"关键的发版序列是这样的：",[43,1297,1300],{"className":1298,"code":1299,"language":48},[46],"1.0.5  GA   无 VCRuntime DLL\n1.0.6  recalled\n1.0.7  GA   无 VCRuntime DLL\n1.0.8  GA   ↓ 首次 Copy-VcRuntime，dist 含 9 个 VC++ Runtime DLL\n       后 recalled（因 OTA_TRASH_CORRUPT 现场卡死）\n1.0.9  GA   继承 1.0.8 build，含 VCRuntime DLL\n       baseline = 1.0.8（diff 算相邻两版）\n       minClient = 1.0.8（改为 1.0.7 后触发此 bug）\n",[35,1301,1299],{"__ignoreMap":51},[10,1303,1304],{},"当 1.0.9 的 minClient 被改为 1.0.7 后，升序逻辑会为 1.0.7 客户返回 1.0.9 的 manifest。minClient 是一个关键的版本约束字段——它表示\"升级到当前版本前，客户必须至少是什么版本\"。修改 minClient 的动机通常是想扩大升级适配范围（比如从 1.0.8 放宽到 1.0.7），但这个改动隐含了一个假设：\"当前版本及其所有依赖都能兼容从 minClient 开始的所有中间版本\"。在这个案例中，这个假设被打破了。",[43,1306,1309],{"className":1307,"code":1308,"language":48},[46],"G26 升序查询（1.0.7 客户，seek current 之后最小 GA）：\n  候选 1.0.8 → recalled，跳过\n  候选 1.0.9 → GA，minClient=1.0.7≤1.0.7 满足 → 命中，返回 1.0.9 manifest\n",[35,1310,1308],{"__ignoreMap":51},[10,1312,1313,1314,1206],{},"1.0.7 客户 apply 1.0.9 的差分包后，系统启动时 launcher.exe 试图加载 VCRUNTIME140.dll，但客户机的 dist 里没有这个文件——1.0.8 引入的 9 个 DLL 永远没有被应用过。Windows 加载器在 LoadLibrary 阶段直接终结进程，报 ",[35,1315,1262],{},[14,1317,1319],{"id":1318},"为什么-107-看起来能启动升级后就崩溃","为什么 1.0.7 看起来能启动，升级后就崩溃",[10,1321,1322],{},"这里需要澄清一个关键点。1.0.7 的 dist 里确实没有 VCRuntime DLL，但 1.0.7 版本的应用仍然能启动。原因在于 launcher.exe 是用 1.0.7 时代的 toolchain build 的，它依赖的 MSVC CRT 版本恰好在干净 Windows 的 System32 里能找到（或者该客户机预装了对应版本的 Visual C++ Redistributable）。这是一个\"侥幸\"——1.0.7 构建时选用的工具链版本较旧，CRT 的 ABI 足够稳定，系统库能满足。",[10,1324,1325,1326,1328],{},"一旦升级到 1.0.9，launcher.exe 是用更新的 toolchain build 的，它依赖的 CRT 版本更新了，链接的 API 集合也有变化。客户机的 System32 里没有这个新版 CRT。Windows PE 加载器会在 LoadLibrary 阶段就找不到 VCRUNTIME140.dll，进程在主函数之前被系统杀掉（",[35,1327,1262],{},"）。应用的错误处理机制根本没机会启动——没有 try-catch，没有日志，只有系统错误弹窗和退出码。",[14,1330,1332],{"id":1331},"为什么会漏-dll","为什么会漏 DLL",[10,1334,1335],{},"这是差分包设计与版本跳过的碰撞。1.0.9 的差分包不包含 vcruntime140.dll，原因看似无害：",[43,1337,1340],{"className":1338,"code":1339,"language":48},[46],"1.0.8 dist                1.0.9 dist\n  vcruntime140.dll          vcruntime140.dll\n  sha256: \u003Chash>            sha256: \u003Chash>（同一个 build 产物，哈希相同）\n  → 两边都有，且相同 → 不进差分包\n",[35,1341,1339],{"__ignoreMap":51},[10,1343,1344,1345,1348],{},"但这个逻辑基于一个前提：",[27,1346,1347],{},"从 1.0.8 升级来","。如果客户是从 1.0.7 升级，那么它的 dist 里根本没有 vcruntime140.dll，差分包也不会帮它补上——因为 1.0.9 diff 算法看的是\"1.0.8 有且 1.0.9 也有\" 的文件。",[10,1350,1351],{},"任何「中间 GA 版本引入新文件 + 该版本被 recall + 后续版本用它作 baseline」的组合，都会造成同样的问题。这不是 DLL 特有的现象，而是增量包机制本身的约束。",[14,1353,1355],{"id":1354},"为什么-108-会引入-dll","为什么 1.0.8 会引入 DLL",[10,1357,1358,1359,1362],{},"顺便说一下 1.0.8 为什么成为\"罪魁祸首\"。1.0.8 是第一个在 build-portable.ps1 中加入 ",[35,1360,1361],{},"Copy-VcRuntime"," 的版本，它主动把整套 MSVC redist DLL（9 个）旁置到 dist，这样便携包就不再依赖系统级安装的 Visual C++ Redistributable。这个改动本身是对的，但由于 1.0.8 随后因 OTA_TRASH_CORRUPT 问题被 recall，它成了\"引入新文件但被撤回\"的典型。",[10,1364,1365],{},"1.0.9 继承了 1.0.8 的 build，自然也含有这 9 个 DLL，但 1.0.9 的差分包相对 1.0.8 计算，所以这些 DLL 不会再被列入。结果就是：1.0.7 客户跳过 1.0.8，直接升到 1.0.9，永远收不到这些 DLL。",[10,1367,1368],{},"同时，还有一个独立的关联问题：修复脚本 Fix-VCRuntime.bat 本身因为编码问题（UTF-8 vs GBK 的冲突）无法正常执行，所以即使用户尝试手动修复，脚本也会报错。这是打包流程和脚本编码的两个独立根因，在同一时间窗口内暴露了出来。",[14,1370,1371],{"id":1371},"修复方案的权衡",[10,1373,1374],{},"面对这个问题，有两条主要路径可选：",[86,1376,1378],{"id":1377},"方案-a全量兜底","方案 A：全量兜底",[10,1380,1381],{},"跨版本升级时直接下发完整 full.zip，而不是差分包。",[10,1383,1384,1387],{},[27,1385,1386],{},"优点","：",[349,1389,1390,1393],{},[352,1391,1392],{},"简单粗暴，一个包包含所有文件，不存在漏 DLL 的可能",[352,1394,1395],{},"不需要调整发版流程",[10,1397,1398,1387],{},[27,1399,1400],{},"缺点",[349,1402,1403,1406,1409],{},[352,1404,1405],{},"包体积大（差分包通常 100-200 MB，全量 500+ MB）",[352,1407,1408],{},"客户网络环境差时升级时间长，失败率高",[352,1410,1411],{},"CDN 带宽成本增加",[10,1413,1414,1387],{},[27,1415,1416],{},"适用场景",[349,1418,1419,1422,1425],{},[352,1420,1421],{},"一次性应急（发 hotfix 回滚）",[352,1423,1424],{},"用户基数小、网络条件好",[352,1426,1427],{},"跨度非常大（比如 1.0.1 → 2.0.0）的少见场景",[86,1429,1431],{"id":1430},"方案-b严格步进","方案 B：严格步进",[10,1433,1434],{},"服务端按发布链下发下一个版本，不允许跨级。即使是 recalled 版本也保留在链上，只是不作为升级终点。",[10,1436,1437,1387],{},[27,1438,1386],{},[349,1440,1441,1444,1447],{},[352,1442,1443],{},"包体积小，增量包通常 100-200 MB",[352,1445,1446],{},"每个客户走相同的升级链路，问题复现容易排查",[352,1448,1449],{},"充分利用增量包设计的初衷",[10,1451,1452,1387],{},[27,1453,1400],{},[349,1455,1456,1459],{},[352,1457,1458],{},"升级链路长（1.0.7 → 1.0.9 时需要先升 1.0.8，即使 1.0.8 被 recalled）",[352,1460,1461],{},"如果某个中间版本有严重问题（比如 OTA 卡死），客户升级时仍会卡在它上面",[10,1463,1464,1387],{},[27,1465,1416],{},[349,1467,1468,1471,1474],{},[352,1469,1470],{},"绝大多数常规发版",[352,1472,1473],{},"网络条件多变的客户群",[352,1475,1476],{},"需要严格控制升级质量的生产环境",[10,1478,1479,1482],{},[27,1480,1481],{},"为什么选择 B","：有几个关键因素。首先，客户群分布广（企业内网、弱网、移动网络混合），300+ MB 的全量包在弱网场景下升级失败率会明显增加，反而增加了支持成本。其次，一旦引入全量兜底，就容易形成\"跨版本时总是下发全量\"的惯性，长期来看放弃了增量包的设计初衷。最后，严格步进虽然升级链路长，但每个版本的 DL + apply 成本都是可预测的，失败时也容易重试。",[10,1484,1485,1486,1489],{},"通过让 recalled 版本保持在链上（只改为 non-GA 状态），升序逻辑会自动跳过它们，下一个 GA 版本会包含完整的累积增量。1.0.9 被 recall 后，G26 升序对 1.0.7 客户会自动推荐 1.0.10；1.0.10 的差分是相对 1.0.7 算的（通过 ",[35,1487,1488],{},"FROM_VERSION=1.0.7"," 显式指定），包含了 1.0.8 引入的 9 个 DLL 以及后续的所有变更。这样既保持了包体积小的优势，又确保了跨越 recalled 版本的客户能拿到完整增量。1.0.10 的差分包最关键的是 Removes 为 0——任何跨版本的客户 apply 都不会留下废文件。",[14,1491,1492],{"id":1492},"为什么撤回版本不能从链上摘掉",[10,1494,1495],{},"这里有一个容易忽视的细节：即使某个版本被 recall，也不能从版本链中物理删除。",[10,1497,1498],{},"如果把 1.0.8 从数据库里删掉，升序逻辑会直接跳过它，导致 1.0.7 客户被推荐到 1.0.9——此时就回到了原来的问题。修复的关键是让升序逻辑仍然\"看到\" 1.0.8 的存在（用于确定差分链路），但把它的 rollout 状态改为 non-GA，使得它永远不会被作为升级终点。",[43,1500,1504],{"className":1501,"code":1502,"language":1503,"meta":51,"style":51},"language-sql shiki shiki-themes github-light github-dark","-- 这样的做法是错的（会复现问题）：\nDELETE FROM releases WHERE version = '1.0.8';\n\n-- 正确的做法：\nUPDATE releases SET rollout_status = 'recalled' WHERE version = '1.0.8';\n","sql",[35,1505,1506,1511,1516,1521,1526],{"__ignoreMap":51},[127,1507,1508],{"class":129,"line":130},[127,1509,1510],{},"-- 这样的做法是错的（会复现问题）：\n",[127,1512,1513],{"class":129,"line":136},[127,1514,1515],{},"DELETE FROM releases WHERE version = '1.0.8';\n",[127,1517,1518],{"class":129,"line":142},[127,1519,1520],{"emptyLinePlaceholder":384},"\n",[127,1522,1523],{"class":129,"line":148},[127,1524,1525],{},"-- 正确的做法：\n",[127,1527,1528],{"class":129,"line":154},[127,1529,1530],{},"UPDATE releases SET rollout_status = 'recalled' WHERE version = '1.0.8';\n",[10,1532,1533],{},"换句话说，版本链的完整性是优于减少数据库记录的。每次新版本发布时，baseline 计算和 diff 生成都要以完整的版本序列为基础。所以即使 1.0.8 和 1.0.9 都被 recall，它们仍然需要在数据库中占据一个位置，作为\"这个版本存在过，但用户不应该升级到它\"的标记。升序逻辑看到 recalled 标记后会直接跳过，继续往后找第一个 GA 版本。",[10,1535,1536],{},"修复后的数据库状态是：",[43,1538,1541],{"className":1539,"code":1540,"language":48},[46],"1.0.7   GA         minClient=1.0.5\n1.0.8   recalled\n1.0.9   recalled   ← 这一步至关重要\n1.0.10  GA         minClient=1.0.7\n",[35,1542,1540],{"__ignoreMap":51},[10,1544,1545,1546,1548],{},"现在 1.0.7 客户升序时会依次检查 1.0.8（recalled，跳）→ 1.0.9（recalled，跳）→ 1.0.10（GA，命中）。1.0.10 的差分包是通过 ",[35,1547,1488],{}," 显式指定的，所以它包含 1.0.8 和 1.0.9 引入的所有文件。",[14,1550,1551],{"id":1551},"预防和后续",[10,1553,1554],{},"立即修复是发 1.0.10 并 recall 1.0.9，已在 2026-05-11 19:42 完成。发布 1.0.10 时的关键操作是显式指定 baseline：",[43,1556,1558],{"className":460,"code":1557,"language":462,"meta":51,"style":51},"FROM_VERSION=1.0.7 \\\nMINISIGN_PASSWORD='...' ADMIN_PASS='...' BUILD_HOST_PASSWORD='...' \\\nROLLOUT_STATUS=whitelist \\\n  bash scripts\u002Frelease.sh 1.0.10\n",[35,1559,1560,1574,1590,1598],{"__ignoreMap":51},[127,1561,1562,1565,1568,1571],{"class":129,"line":130},[127,1563,1564],{"class":538},"FROM_VERSION",[127,1566,1567],{"class":525},"=",[127,1569,1570],{"class":473},"1.0.7",[127,1572,1573],{"class":479}," \\\n",[127,1575,1576,1579,1582,1585,1588],{"class":129,"line":136},[127,1577,1578],{"class":538},"MINISIGN_PASSWORD=",[127,1580,1581],{"class":473},"'...'",[127,1583,1584],{"class":473}," ADMIN_PASS='...'",[127,1586,1587],{"class":473}," BUILD_HOST_PASSWORD='...'",[127,1589,1573],{"class":469},[127,1591,1592,1595],{"class":129,"line":142},[127,1593,1594],{"class":538},"ROLLOUT_STATUS=whitelist ",[127,1596,1597],{"class":469},"\\\n",[127,1599,1600,1603,1606],{"class":129,"line":148},[127,1601,1602],{"class":473},"  bash",[127,1604,1605],{"class":473}," scripts\u002Frelease.sh",[127,1607,1608],{"class":469}," 1.0.10\n",[10,1610,1611],{},"这样 diff 生成引擎会计算 1.0.7 ↔ 1.0.10 的完整差异，而不是默认的\"上一个 GA\"（1.0.9）。然后执行 PATCH 和 recall 操作：",[43,1613,1615],{"className":460,"code":1614,"language":462,"meta":51,"style":51},"# 1. PATCH 1.0.10 → ga\ncurl -X PATCH \"$ADMIN\u002Fadmin\u002Freleases\u002F$ID\u002Frollout\" -d '{\"status\":\"ga\"}'\n\n# 2. recall 1.0.9（防止升序仍然推荐给 1.0.7 客户）\nbash scripts\u002Frelease\u002Frecall.sh \u003Crelease-id>\n\n# 3. 清 redis rollout-wrapper cache（立即生效，不等待 cache 过期）\nredis-cli --scan --pattern 'cache:rollout-wrapper:*' | xargs -r redis-cli DEL\n",[35,1616,1617,1622,1654,1658,1663,1681,1685,1690],{"__ignoreMap":51},[127,1618,1619],{"class":129,"line":130},[127,1620,1621],{"class":489},"# 1. PATCH 1.0.10 → ga\n",[127,1623,1624,1627,1630,1633,1636,1639,1642,1645,1648,1651],{"class":129,"line":136},[127,1625,1626],{"class":479},"curl",[127,1628,1629],{"class":469}," -X",[127,1631,1632],{"class":473}," PATCH",[127,1634,1635],{"class":473}," \"",[127,1637,1638],{"class":538},"$ADMIN",[127,1640,1641],{"class":473},"\u002Fadmin\u002Freleases\u002F",[127,1643,1644],{"class":538},"$ID",[127,1646,1647],{"class":473},"\u002Frollout\"",[127,1649,1650],{"class":469}," -d",[127,1652,1653],{"class":473}," '{\"status\":\"ga\"}'\n",[127,1655,1656],{"class":129,"line":142},[127,1657,1520],{"emptyLinePlaceholder":384},[127,1659,1660],{"class":129,"line":148},[127,1661,1662],{"class":489},"# 2. recall 1.0.9（防止升序仍然推荐给 1.0.7 客户）\n",[127,1664,1665,1667,1670,1672,1675,1678],{"class":129,"line":154},[127,1666,462],{"class":479},[127,1668,1669],{"class":473}," scripts\u002Frelease\u002Frecall.sh",[127,1671,526],{"class":525},[127,1673,1674],{"class":473},"release-i",[127,1676,1677],{"class":538},"d",[127,1679,1680],{"class":525},">\n",[127,1682,1683],{"class":129,"line":246},[127,1684,1520],{"emptyLinePlaceholder":384},[127,1686,1687],{"class":129,"line":252},[127,1688,1689],{"class":489},"# 3. 清 redis rollout-wrapper cache（立即生效，不等待 cache 过期）\n",[127,1691,1692,1695,1698,1701,1704,1707,1710,1713,1716],{"class":129,"line":258},[127,1693,1694],{"class":479},"redis-cli",[127,1696,1697],{"class":469}," --scan",[127,1699,1700],{"class":469}," --pattern",[127,1702,1703],{"class":473}," 'cache:rollout-wrapper:*'",[127,1705,1706],{"class":525}," |",[127,1708,1709],{"class":479}," xargs",[127,1711,1712],{"class":469}," -r",[127,1714,1715],{"class":473}," redis-cli",[127,1717,1718],{"class":473}," DEL\n",[10,1720,1721,1722,1725],{},"预防层面，在 ",[35,1723,1724],{},"wakou-release"," skill 中加了三条新检测项（G27\u002FG28\u002FG29）：",[604,1727,1728,1731,1734],{},[352,1729,1730],{},"检查 releases 表里有没有「ga → recalled 但后续版本 baseline 还指着它」的组合",[352,1732,1733],{},"评估是否存在客户群可能跨过 recalled 版本拿增量",[352,1735,1736,1737],{},"如果是，强制新版本 specify ",[35,1738,1739],{},"FROM_VERSION=\u003C最老活跃 GA 版本>",[10,1741,1742],{},"这三条检测在每次发版前自动跑，有问题的组合会在 release.sh 阶段被拦住，不允许走到\"上传 CDN\"这一步。",[10,1744,1745,1746,1749,1750,1753],{},"后续架构层改进（未实施）：admin server 的 G26 解析逻辑可以加「baseline 链路完整性检查」。具体是：当升序逻辑返回 manifest 给客户时，校验 ",[35,1747,1748],{},"client.version → release.baseline_version"," 之间的所有版本是否都是 GA（无 recalled）。如果链路中存在 recalled 版本，admin 直接返回错误码（比如 ",[35,1751,1752],{},"EXTRACT_FROM_OLDER_BASELINE","）给 OTA worker，让客户 fallback 到全量包请求，而不是无声给一个不完整的差分。",[10,1755,1756,1757,1760],{},"这个改进的收益是\"防御性更强\"——即使人肉操作出了问题（比如遗漏了 ",[35,1758,1759],{},"FROM_VERSION="," 指定），系统也能主动检测并避免分发坏包。但代价是需要修改：",[349,1762,1763,1769,1776,1779],{},[352,1764,1765,1768],{},[35,1766,1767],{},"release-manifest.json"," 的 schema（添加链路检查标记）",[352,1770,1771,1772,1775],{},"admin 的 ",[35,1773,1774],{},"update.ts::resolveBonjourCliPath"," 逻辑",[352,1777,1778],{},"OTA worker 的错误码处理（识别新的 fallback 信号）",[352,1780,1781,1782,1785],{},"CDN 需要同时提供 ",[35,1783,1784],{},"full-from-empty.zip","（或允许 diff 降级到 full）",[10,1787,1788,1789,1791],{},"鉴于目前的发版流程和人肉检测已经能防住这个问题，架构层改进被标记为后续单独需求，暂时先依赖流程检查和 ",[35,1790,1759],{}," 人肉兜底。",[365,1793,1794],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":51,"searchDepth":136,"depth":136,"links":1796},[1797,1798,1799,1800,1801,1802,1806,1807],{"id":1266,"depth":136,"text":1266},{"id":1292,"depth":136,"text":1292},{"id":1318,"depth":136,"text":1319},{"id":1331,"depth":136,"text":1332},{"id":1354,"depth":136,"text":1355},{"id":1371,"depth":136,"text":1371,"children":1803},[1804,1805],{"id":1377,"depth":142,"text":1378},{"id":1430,"depth":142,"text":1431},{"id":1492,"depth":136,"text":1492},{"id":1551,"depth":136,"text":1551},"2026-05-27","1.0.7 客户升级到 1.0.9 后启动崩溃，报 STATUS_DLL_NOT_FOUND。根因在于增量包的构造逻辑与发版序列的交互：任何\"中间 GA 版本被撤回且后续版本以它作为 baseline\"的组合，都会让跳过撤回版本的客户拿到不完整的增量包。",{},"\u002F2026-05-27",{"title":1254,"description":1809},"2026-05-27-步进升级为什么增量包不能跨版本跳","增量包是相邻两版的差分，跳过中间版本会漏掉某版本引入的文件，导致客户启动崩溃。",[390,1816,394,1817,894],"增量更新","二进制差分","mO5egKlEmvxpoLQ8R1GYBlnXzKl1bq1NoHYzI2X5h-0",1785406912447]