一个 PATH 编辑器,和它亮着红灯的质量门
你有没有见过这样的项目:构建能过、测试能跑,可你一旦去查质量门,发现它本身是红的?
PathEditor 5.1.1 就是从这个状态开始的。它是一个 Windows PATH 环境变量编辑器。PATH 是系统里决定「你在命令行敲一个名字,系统去哪儿找它」的环境变量,改错了。轻则命令找不到,重则整套工具链一起瘫。项目是双入口设计:Tauri GUI 做桌面窗口,Rust CLI 做命令行工具,两套入口共用同一套核心逻辑。仓库在 https://github.com/LHY0125/PathEditor ,版本 5.1.1。
我做的第一件事,是把仓库拉下来,从头完整跑一遍质量门。
先看能过的一半:npm run build 通过,Vite 主 JS 约 319.08 kB,gzip 约 99.06 kB;npm run lint 通过,0 error、2 个警告;cargo test --workspace 通过,57 个 Rust 测试全绿;npm run format:check 通过。这几项看着都挺体面。
红灯在另一半。npm run test:coverage 失败:105 个前端测试全通过了,但 Lines 66.83%,低于 80% 的门槛。cargo fmt --check 失败,core/src/backup.rs、core/src/fs.rs 有格式差异。Clippy 失败,core/src/system.rs:132 的布尔比较触发了 bool_comparison。npm run test:e2e 则根本没跑起来,本机缺 Playwright Chromium。
第一轮审核一共列了 13 项问题,编号 F-01 到 F-13。其中三个被我定为 P0。
F-01 是 CI 门禁不是绿色。覆盖率、格式、Clippy 都在失败,那「测试通过」这句话本身就站不住,更没法拿它当重构的保护网。动手改代码之前,得先把门禁修到可执行、可复现。
F-02 更贴近真实用户。原实现用一个全局 isAdmin 去推导两个 PATH 能不能写,可 Windows 的权限模型不是这样分的。系统 PATH 存在 HKLM,通常需要管理员权限;用户 PATH 存在 HKCU,普通用户本来就能改。HKLM 和 HKCU 这两个 hive(注册表的顶层分支)必须分别探测。后果不是界面难看,而是普通用户明明能改自己的 PATH,却被应用直接锁死。
F-03 是数据丢失。导入配置和「应用配置」时只搬了路径字符串,enabled=false 这个禁用状态就丢了。一个「禁用但保留」的路径,导入之后可能悄悄变回启用,甚至直接消失。后面统一成 PathEntry { path, enabled } 这个契约,导入、注册表、撤销重做、侧车快照(sidecar,配在主数据旁边的辅助文件)都围着它工作。
13 项里剩下十项是 P1 到 P3,暂且不提。真正让我在意的是这三个 P0 暴露出的东西:** 测试通过,不等于质量门是绿的。** 前者是一堆数字,后者是数字背后的语义。
所以这轮工作从一开始就不是「加功能」,而是先修质量门和状态模型。
修完 13 个问题,复审还是打了回来
第一轮整改做完,我把 13 项问题一条条勾掉:覆盖率补上,Clippy 清干净,格式也统一了。本地这几道质量门都亮回了绿灯,我以为接下来就是合并、发版、收工。然后我打开复审报告,第一行写着 Request Changes,文档在 docs/审核和开发/2026.09.14/PathEditor-复审报告.md。那一刻我以为自己看错了,又把 13 条挨个对了一遍,逐条看都动过代码。
复审窗口没看我的说明,自己把验证重跑了一遍:前端 124 passed,Rust 58 passed,覆盖率 Lines 87.09%,E2E 13 passed,构建和 Clippy 通过。这组数字比第一轮基线的 105 / 57 / 66.83% 好看太多。数据变漂亮了,结论却是打回。
问题不在「修没修」,而在「修了之后,真的对了吗」。复审一共甩回来四个反转,每一个都长在我觉得「已经好了」的地方,而且它们有个共同特征:测试全是绿的。
第一个反转在 PathCapabilities 上。Rust 结构体加了 Serialize 之后,序列化出来的字段仍然是这个形状:
can_read_system
can_write_system
can_read_user
can_write_user
前端读的却是驼峰命名的另外四个名字:
canReadSystem
canWriteSystem
canReadUser
canWriteUser
单元测试和 E2E 里的 mock 恰好返回了 camelCase,两边都读得到,所以测试全绿。真实 Tauri IPC 返回的是 snake_case,前端一个字段都取不到,普通用户权限判断在真机上仍然可能失效。IPC 就是前后端之间那条通信通道,mock 只是我手写的替身,它返回什么,全凭我当时的想象。权限判断这一块我在第一轮专门改过,结果字段名没对齐,改动在真实环境里落不了地。
单元测试绿了,只能证明 mock 是对的。
修复方向有四步:Rust 侧加 #[serde(rename_all = "camelCase")];增加一份共享 fixture tests/fixtures/path-capabilities.json,让前后端读同一份数据;前端 backend.ts 对能力对象做运行时形状校验;不再只依赖 TypeScript 类型断言。契约这件事,光靠类型文件和 mock 是管不住的,得让两边吃同一口饭。
第二个反转藏在 pending 队列里。列表一 rerender,正在跑的验证批次会被取消;新批次启动后,又跳过还在 in-flight 的项。两头一夹,一部分路径就再也没人处理,永久停在 pending。这个 bug 难就难在它是时序问题,手动点几下界面,大概率碰不到。
修复用了共享 in-flight Promise:rerender 之后的新批次不再自己开一轮,而是复用已经跑起来的那次验证,路径不会再掉在批次交接的缝里。另外补了一个 deferred rerender 回归测试,专门盯这个时序。
第三个反转是禁用路径跨重启丢失。disabled.json 是个 sidecar,也就是和注册表分开存放的辅助文件,注册表才是主数据。禁用一项之后,路径会从注册表里移除,记录留在 sidecar 里;重启的时候,disabled.json 里的孤儿记录不会被重新合并回去。当前会话看着一切正常,在界面里怎么点都挑不出毛病,重启之后禁用项就消失了。CLI 的 disable 只写 sidecar,语义和 GUI 也对不上。
修复的思路是先把「谁说了算」定清楚:disabled.json 保存完整的 systemSnapshot 和 userSnapshot;启动时以注册表作为「启用路径真相来源」,再用快照恢复禁用项和顺序;旧格式的禁用字符串数组继续兼容;CLI disable 也换到完整快照语义。
第四个反转是保存成功状态不真实。原来的逻辑里,注册表写入成功、disabled.json 写入失败,Store 仍然把最新快照标记成已保存。用户看到「已保存」,其实侧车根本没写进去,下一次保存也不会补写。这种 bug 最坏的地方在于它骗人:界面说成功了,数据却只写了一半边。
修复引入了 _pendingSys / _pendingUser 两个待办标记:注册表成功但快照失败时,保留 isModified = true;下次保存只补写侧车,不重复覆盖注册表;system 和 user 两个 hive 分别报告成功或失败。hive 是注册表里的顶层配置单元,system 和 user 各算一个,分开报告才不会一边成功一边失败却显示成同一个结果。
四个反转翻完,我发现它们其实是同一类问题。功能都写了,代码都提交了,测试都是绿的,可真实运行路径从来没有被验证过。数据好看只是一层皮,复审真正在问的不是「你改了吗」,而是「改完的东西在真实环境里成立吗」。问题不在你改没改,而在改完的东西在真实环境里成立吗。
把发布搬进 CI:127 / 63 / 86.33%,和第一次挂掉的 workflow
修完那四个反转,我学乖了,没再拍胸脯说「好了」。上一轮的教训还热着:测试绿,只证明 mock 是对的。这一次我先让 npm run verify 完整跑一遍,把结论交给输出,而不是交给我的感觉。
第二轮的结果是:Prettier 通过;ESLint 0 error,2 个非阻断 warning;前端单元测试 127 passed;覆盖率 Lines 86.33%;生产构建通过;cargo fmt --check 通过;Clippy 通过;Rust 测试 63 passed。另外单独跑 npm run test:e2e,是 13 passed。那两个 warning 是非阻断项,不拦流水线,我没去动它们。
数字比第一轮好看太多,但有个边界得说清楚:E2E 用的是 mock IPC,不写真实注册表。它能证明前端流程和逻辑是通的,替不了我去证明真实 Tauri 环境下编辑 PATH 一定成功。所以这组数字只能读成「逻辑侧通关」,真机里的普通用户 PATH 编辑,当时还挂着没验。复审窗口的验证跑在隔离环境里,我用的是本机,两边不能直接对等。
把这组数字和第一轮基线摆一起,落差很直观:前端从 105 涨到 127,Rust 从 57 涨到 63,覆盖率 Lines 从 66.83% 抬到 86.33%,cargo fmt --check 和 Clippy 也从红转绿。新增的用例大多跟着前面那几个反转走,盯的是契约、时序和状态一致性这些真会出问题的地方。
数字稳了,才轮到发版。版本号看着只有一个 5.1.1,真要动,得同时盯住一长串位置。package.json 的 version、package-lock.json 的顶层和依赖版本信息、Cargo.toml 的 [workspace.package] version、Cargo.lock 里 workspace crate 的版本、gui/tauri.conf.json 的 version 和窗口标题、gui/Cargo.lock 的 GUI crate 版本、README.md 的版本徽章和安装说明、index.html 的页面版本信息、tests/unit/import-export.test.ts 的版本断言,还有 CLAUDE.md / AGENTS.md 的开发指南版本说明。
Rust workspace 里那三个 crate 也一起对齐到 5.1.1:path-editor-core 5.1.1、patheditor 5.1.1、patheditor-cli 5.1.1。漏掉任何一个,tag、构建产物和文档就会各说各话。
以前我是靠记性发版的:建 tag、打包、改 CHANGELOG.md、传资产、写 release notes,一步没落下算运气好,落下一步就得从头再来。这套流程每一步都能出错,而且错了不会有人拦我。所以我更想把检查前移到流水线里,让机器替我兜住,而不是指望我每次记得住。
发布这件事不该靠我记性,得让 tag 和 workflow 把该做的检查钉死在流水线里。
于是我把发布整个搬进了 CI。原来的 .github/workflows/ci.yml 早就不再承担发布职责,后来删掉,只留下 .github/workflows/release.yml。日常 push 和 PR 的质量门交给本地 npm run verify、Husky、lint-staged 这些,发布则完全由 tag 触发,省得每次 push 都跑一遍安装包构建。
Husky 和 lint-staged 管的是日常提交,发版这一步它们碰不上。用 tag 触发,说白了就是把「发哪个版本」变成一个明确动作:你打上 v5.1.1,流水线就按 5.1.1 走;你不打 tag,它就不碰发布。规则都写在 workflow 里,谁来看都一样。如果你也在维护一个靠 tag 发布的仓库,这套结构值得参考。
release.yml 的骨架不复杂。触发条件是 on: push: tags: ['v*'];checkout 用 fetch-depth: 0 保留完整历史,生成 release notes 时要用;先校验 tag 格式是不是 vMAJOR.MINOR.PATCH,允许带预发布后缀;再看 Release 是否已经存在,返回 200 就跳过构建,返回 404 才继续。
工具链这一段是重点:Node 用 20 并带 npm cache;Rust 覆盖本机的 GNU 配置,换成 stable-x86_64-pc-windows-msvc;再强校验 package.json、gui/tauri.conf.json、Cargo.toml workspace version 这三处版本一致。任何一处对不上,构建就不该往下走。
版本对齐后才是 npm ci、npx tauri build、cargo build --release -p patheditor-cli。产物整理成两个资产,PathEditor_<version>_x64-setup.exe 和 patheditor-cli_<version>_x64.exe。release notes 优先读 CHANGELOG.md 里对应版本的段落,读不到再退回上一个 tag 到当前 tag 的 git log。收尾是 gh release create 建 Release、传资产。整条链路没有手工步骤,唯一的开关就是那个 tag。
但第一次跑,workflow 连构建都没进就挂了。原因说出来有点好笑:PowerShell 在 $ErrorActionPreference = 'Stop' 下,把 gh release view 在 Release 不存在时返回的非零退出码当成了致命错误。我本来只是拿它探一下 Release 在不在,结果它先把整条流水线终止了。
修复提交是 58c7a80 fix: 修复 Release 存在性检查,思路换成不走命令退出码,而是用 Invoke-WebRequest -SkipHttpErrorCheck 把 HTTP 状态码直接读出来:
$response = Invoke-WebRequest -Uri $uri -Headers $headers -SkipHttpErrorCheck
这次踩坑也提醒我,别拿命令的退出码当控制流。退出码本来是给人看的信号,我拿它去判断「Release 在不在」,语义就拧了。换成 Invoke-WebRequest -SkipHttpErrorCheck 之后,HTTP 状态码是明确的数据,200 和 404 各自对应一个分支,逻辑一下就直了。一个小教训:在脚本里,区分「不存在」和「报错」靠的是状态码,不是退出码。
v5.1.1 那时候还没发出去,所以我把这个尚未发布的 tag force-update 到 58c7a80,再跑一次。Release workflow run 34852968548 成功,Release 页面落在 https://github.com/LHY0125/PathEditor/releases/tag/v5.1.1。
PathEditor_5.1.1_x64-setup.exe 2,251,586 bytes
patheditor-cli_5.1.1_x64.exe 1,273,344 bytes
这里还有个细节值得先埋一笔。本地 CLI 走 GNU 工具链,SHA-256 是 4de0b898981c7f532d918fe917d0fae9dd2f0b855970130f66a9ce7360cffec2;Release 上的 CLI 走 MSVC,SHA-256 是 9622b1d04d7d5022e2fcc75acd41a1e1dbd0ae69580ecf122cc226bff3d2c3c7。两个 hash 不一样很正常,同一份源码换一套工具链,产物本来就不会一模一样。这个坑会在写 Scoop manifest 时变成一条硬约束:manifest 必须引用 Release 资产的 hash,不能拿本地构建的凑数。
到这里,「能不能发」总算从我的记性里挪进了流水线。但资产发出去只是第一步,用户装上之后真正跑起来的到底是哪个可执行文件,里头还有讲究。
上架 Scoop:先解决重名,再解决网络
Release 资产挂上去之后,我盯着的是另一个问题:别人怎么把它装上。前面几步都在自己的仓库里打转,从这一步起,代码要走到别人的机器上了。Windows 上这个入口是 Scoop,它像手机的应用商店:从 bucket(仓库)里读一份 manifest,也就是一份声明「装哪个文件、放在哪、命令叫什么」的清单,然后负责下载、校验和建 shim。
我原来对自己这套流程的检验标准其实很低:本机能编译、Release 页面上有资产、我这台电脑能跑。换成别人的视角,标准只剩一条——他敲一条命令,能不能用上。所以我先跑了一次搜索,看看名字有没有被人占。
scoop search patheditor
结果直接撞车,Scoop Extras 里早有一个同名应用:
Name : patheditor
Version : 1.0
Description : A convenient GUI for editing the PATH environment variable
Website : https://archive.codeplex.com/?p=patheditor2
Binaries : PathEditor.exe
那是个 2020 年左右的旧 GUI,跟我这轮写的 Rust CLI 没有任何关系,只是名字撞上了。冲突却有两层。一层是名称:官方 Extras 里已经有一个 patheditor,我不可能往里面再塞一个同名的条目。另一层更隐蔽,是 shim 的争夺——旧应用的可执行名是 PathEditor.exe,新 CLI 是 patheditor.exe,Windows 文件系统大小写不敏感,这两个同名 shim 装在一起会互相覆盖或抢占。shim 你可以先理解成快捷方式,它只是个启动器,真正跑起来的是它指向的那个 exe。
同一个名字,在 manifest 层面是一个注册问题,在文件系统层面是一个覆盖问题。这两件事得分开解。
所以我把 manifest 名和命令名拆开:manifest 叫 patheditor-cli,避开官方仓库里那个旧 GUI;实际命令名仍是 patheditor,你敲的还是短的那个;发布位置也先不去碰官方仓库,放在个人 bucket LHY0125/scoop-bucket。名字和命令本来就不是绑死的,这算是我这轮补上的一个认知。要说明的是,这个包目前只上了个人 bucket,还没提交到官方 Scoop Main / Extras,官方那条路是另一套流程。
bucket 不是从零搭的,我基于官方模板 ScoopInstaller/BucketTemplate 建了 https://github.com/LHY0125/scoop-bucket。默认分支 master,topic 打上 scoop-bucket,Actions 允许全部,workflow 默认权限设成 read。模板自带的四个 workflow 我一个没删:CI 在 .github/workflows/ci.yml,Excavator 在 .github/workflows/excavator.yml,还有 issues.yml 和 pull_request.yml。前两个正好是我要的——CI 管每次提交的校验,Excavator 每 4 小时检查一次 manifest 更新,新版本出来它会自己提。
包定义只有一个文件,bucket/patheditor-cli.json:
{
"version": "5.1.1",
"license": "MIT",
"homepage": "https://github.com/LHY0125/PathEditor",
"architecture": {
"64bit": {
"url": "https://github.com/LHY0125/PathEditor/releases/download/v5.1.1/patheditor-cli_5.1.1_x64.exe#/patheditor.exe",
"hash": "sha256:9622b1d04d7d5022e2fcc75acd41a1e1dbd0ae69580ecf122cc226bff3d2c3c7"
}
},
"bin": "patheditor.exe",
"checkver": {
"url": "https://api.github.com/repos/LHY0125/PathEditor/releases/latest",
"jsonpath": "$.tag_name",
"regex": "v?([\\d.]+(?:-[0-9A-Za-z.-]+)?)"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/LHY0125/PathEditor/releases/download/v$version/patheditor-cli_$version_x64.exe#/patheditor.exe",
"hash": {
"url": "https://api.github.com/repos/LHY0125/PathEditor/releases/tags/v$version",
"jsonpath": "$.assets[?(@.name == 'patheditor-cli_$version_x64.exe')].digest"
}
}
}
}
}
字段逐个说。"version": "5.1.1" 是包的版本;"license": "MIT" 和 "homepage": "https://github.com/LHY0125/PathEditor" 交代许可和出处;"url" 指向 Release 里那个 CLI 资产,"hash" 是它的 sha256,Scoop 下载完必须对得上才安装;"bin": "patheditor.exe" 决定建出来的 shim 叫什么。
几个知识点值得单独记一下。URL 末尾的 #/patheditor.exe 是 Scoop 的下载重命名语法,不写这行,装出来的文件名会带上 patheditor-cli_5.1.1_x64 那一长串;bin 决定 shim 的名字,所以 manifest 叫 patheditor-cli,你敲的却是 patheditor;autoupdate.hash.jsonpath 直接读 Release API 的 digest 字段,版本更新时不用我手工贴 hash;checkver 用 api.github.com 而不是 github.com/.../releases/latest,这一步的原因下一段说。
checkver 我最初写成 "github": "https://github.com/LHY0125/PathEditor",本机跑起来直接报错:
The SSL connection could not be established
URL https://github.com/LHY0125/PathEditor/releases/latest is not valid
这两行看着像 manifest 写错了,其实是本地到 GitHub 主站的连接不稳定。换成 API 之后,Excavator 在 GitHub Actions 的 Windows runner 上跑通了。同一份 manifest,在我这台机器上验不了版本,在 runner 上一点问题没有。
bucket 推上去之后,自动化也跟着过了:CI run 34913998372 成功,Excavator run 34914410121 成功,关键提交是 4670c4f feat: add patheditor-cli manifest。两个 run 都绿,说明这份 manifest 的字段和自更新逻辑是站得住的。
本地安装倒是又踩了一路。HTTPS 添加 bucket 时,本机访问 github.com:443 超时:
Checking repo... ERROR 'https://github.com/LHY0125/scoop-bucket' doesn't look like a valid git repository
fatal: unable to access 'https://github.com/LHY0125/scoop-bucket/': Failed to connect to github.com:443
改走 SSH 就成功了:
scoop bucket add lhy git@github.com:LHY0125/scoop-bucket.git
接着 scoop install lhy/patheditor-cli 的 aria2 下载卡在 0 B/s,循环几次都没动。因为之前已经用 curl 下过 Release 资产并验证过 hash,我索性把这份文件放进 Scoop 缓存,再用 scoop install lhy/patheditor-cli -u 就过了:
Checking hash of patheditor-cli_5.1.1_x64.exe ... ok.
Creating shim for 'patheditor'.
'patheditor-cli' (5.1.1) was installed successfully!
scoop info lhy/patheditor-cli 显示 Name patheditor-cli、Version 5.1.1、Source lhy、Binaries patheditor.exe。包定义没错,hash 也没错,卡住的是我自己这条网线。manifest 和 hash 都对,失败也可能发生在你自己这条网线上。 这一步做完,别人已经能通过 scoop install lhy/patheditor-cli 装上它了——从自己的仓库,第一次走到了别人的机器上。
跑起来才算数:PATH 上的最后一个坑
Scoop 的 CI 跑绿了,manifest 也合了,我按 scoop install lhy/patheditor-cli 装到本机,敲 patheditor --version,输出 patheditor 5.1.1。我以为这就对了。可 PATH 这种东西,装上了和用上了,经常是两回事。
我把三条命令都跑了一遍:
Get-Command patheditor
→ D:\settings\Language\Rust\.cargo\bin\patheditor.exe
where.exe patheditor
→ D:\settings\Language\Rust\.cargo\bin\patheditor.exe
→ D:\settings\settings\Scoop\shims\patheditor.exe
scoop which patheditor
→ D:\settings\Language\Rust\.cargo\bin\patheditor.exe
Get-Command 只看当前会执行哪个,where.exe 按 PATH 顺序列出所有同名候选,scoop which 按理该指向 Scoop 管理的那份。结果全都命中了 Cargo bin 下的手工 GNU 构建。它排在 PATH 前面,把 Scoop shims 整个挡住了。
为了确认跑的不是同一个文件,我对了三个 hash:
Cargo 手工版 GNU 构建:
4de0b898981c7f532d918fe917d0fae9dd2f0b855970130f66a9ce7360cffec2
Scoop 应用目录里的 MSVC 构建:
9622b1d04d7d5022e2fcc75acd41a1e1dbd0ae69580ecf122cc226bff3d2c3c7
Scoop shim:
140e3801d8adeda639a21b14e62b93a4c7d26b7a758421f43c82be59753be49b
前两个不同,因为工具链不同(GNU vs MSVC)。shim 的 hash 又和它指向的真实 exe 不同,shim 的 hash 和真实 exe 的 hash 不同——指向的二进制才是真正的 D:\settings\settings\Scoop\apps\patheditor-cli\current\patheditor.exe。所以 shim 和 binary 的 hash 不一样,不是坏了,是正常现象。
按用户要求,我删了 D:\settings\Language\Rust\.cargo\bin\patheditor.exe 和备份 D:\settings\Language\Rust\.cargo\bin\patheditor.exe.5.1.0.20260914-213526.bak。再验一遍:
Get-Command patheditor → D:\settings\settings\Scoop\shims\patheditor.exe
where.exe patheditor → D:\settings\settings\Scoop\shims\patheditor.exe
patheditor --version → patheditor 5.1.1
scoop which patheditor → D:\settings\settings\Scoop\apps\patheditor-cli\current\patheditor.exe
全对上了,CLI 现在完全由 Scoop 管。
最扎心的是 mock 通过不等于契约正确:PathCapabilities 在 mock 里返回 camelCase,真实 Rust 序列化返回 snake_case,测试全绿,真实环境却失效。状态保存也一样,注册表写入成功和侧车快照写入成功是两个步骤,任何一步失败都要能表达成部分成功,界面说「已保存」时数据其实只写了一半。质量门本身也要被验证,覆盖率阈值、Clippy、fmt、E2E 不是装饰,门是红的就先修门。hash 不能混用,Scoop manifest 必须用 Release 资产 hash,本地 GNU 构建和 Release MSVC 构建的 hash 本来就不同。后来我养成了一个习惯:跑完安装不只是看版本号,而是拿 Get-Command、where.exe、scoop which 三条命令各验一遍。
说完这些,还有几件事我没法说已经搞定。真实 Tauri 环境里的普通用户 PATH 编辑还没验,E2E 用的是 mock IPC,不写真实注册表。禁用路径「保存→退出→重启→重新启用」在注册表上的完整往返还没跑过。分发目前只上了个人 bucket LHY0125/scoop-bucket,没有提交到官方 Scoop Main / Extras。
这次发布真正修掉的,不只是几个失败测试,而是“测试通过、构建成功、安装成功、实际运行路径生效”之间的断点。
那天我盯着那三条命令的输出,直到它们全部指向 Scoop 那个路径,才觉得这次发布真的走完了。
所以我不太敢把「装上了」当成终点——至少要到那几条命令全部指向同一个位置。
评论交流
欢迎留下你的想法