主题
专题验证 · 发版链路
常驻清单,不归档。判断层专用——机械层先跑完再看这里。 对象:
scripts/release.sh+.github/workflows/release.yml+scripts/gen-latest-json.mjs
- updater endpoint(
updater-latest的 latest.json)+ 公开分发仓 + landing 下载页 +/releaseskill 体裁与纪律见README.md
bash
./scripts/release-verify.sh # 不下载安装包(R1-R15)
./scripts/release-verify.sh --deep # 额外真的下一个制品验签
./scripts/release-verify.sh --static-only # 只跑 R1-R4(零网络零凭据)—— CI 跑的就是这个0. 这个域为什么危险
它的失效只有一种形状:存量用户停在旧版本上,而你看到的一切正常。
没有报错、没有告警、没有工单。他们的应用照常启动、照常用,只是再也不更新了。 而你这边:Release 页面好好的、下载链点得开、CI 全绿、Sentry 没有异常事件—— 因为根本没有事件可发,那台机器上什么都没发生。
链路有十来跳,任何一跳断掉都是同一个症状:
| 断在哪 | 你会看到什么 |
|---|---|
忘跑 promote-updater | 什么都没有。新版发布了,没人收到 |
| 公开 Release 忘了从草稿转正 | 什么都没有。endpoint 上线了,客户端下载 404 |
| 换版本标签没抬基版本号 | 什么都没有。semver 认为新版更旧 |
| 某个平台的 updater 制品没产出 | 什么都没有。而且只有那个平台,其余平台一切正常 |
| 签名密钥轮换了 | 什么都没有。客户端验签失败,静默拒绝安装,且无法补救 |
| 启动时那句静默自检被删了 | 什么都没有。设置页里手动点还能更新,但没人会去点 |
所以本清单的第一原则:
🔴 「我发出去了」不是证据。 每一格要问的是:如果这一跳断了,会有任何东西变红吗? 没有,那它就是靠运气在绿——而这个域的失效是不可观测的,运气用光了你也不会知道。
⚠️ 元前提:这个域的绿有两种,别混
release-verify.sh 验的是当前已发布的那一版这条链路通不通。它验不了 「下一次发版会不会出问题」——那要等到真的发版。两者的分界:
- 现在能验:endpoint 可取、资产可下、签名密钥对得上、平台齐全、版本可升级、凭据在台账里
- 只有发版当次能验:macOS 公证真的落地(
release.sh verify-notarized)、 构建矩阵三条腿都出了制品、公开 Release 草稿转正之前那个窗口(见 K1)
报告里要写明这轮验的是哪一种。
1. 机械层已覆盖(不要在判断层重复)
🔒 R1-R4 自 2026-08-30 起在 CI 里跑(
.github/workflows/ci-verify.yml,--static-only --max-skip=0),R5 起仍是手工 //release时才跑。 这条分界不是随手划的:整跑时 SKIP 数是网络可达性的函数——同机同码连跑两次 SKIP 分别是 4 和 11(第二次 latest.json 没取到 ⇒ R7-R11 连带全 SKIP), 钉任何基线都会得到一道随网络天气变红的门。拆完实测(代理指向关闭端口): 静态段 0.45s / SKIP=0,全量 20.1s / SKIP=10。 ⇒ 读下表时要分清哪几行是「每次提交都在验」,哪几行是「只有人记得跑时才验」 —— 后者的假绿风险高得多。
| 层 | 位置 | 守什么 |
|---|---|---|
| 源码 | release-verify.sh R1(复用 set-version.mjs --check,CI 同款) | 四处版本号一致(tauri.conf / Cargo.toml / package.json / Cargo.lock 的 app 包)。第四处 2026-09-01 才补进断言 —— 此前它只靠「bump 之后碰巧跑过一次 cargo」,v0.1.0-dev.11 的 tag 里实测就落后一版(失效面 = 从该 tag 跑 cargo build --locked 会失败,而 CI 发版构建不带 --locked 故不会红) |
| 源码 | release-verify.sh R2 | semver 判据自检(换标签陷阱 + 数字段向量的阳性对照) |
| 源码 | release-verify.sh R3 | 正式版 identifier 硬闸(不依赖 /release skill 被走到) |
| 源码 | release-verify.sh R4 | updater 客户端接线五环(conf / rust plugin / capabilities / lib / 启动自检) |
| 源码 | release-verify.sh R5 | release.yml 引用的 secret 都真实存在(名单从 workflow 现推) |
| 部署态 | release-verify.sh R6–R10 | endpoint 匿名可取 · 版本形状 · 平台键 vs 构建矩阵 · 资产匿名可下(含 404 阳性对照)· 签名 keyid vs 客户端 baked 公钥 |
| 部署态 | release-verify.sh R11 | 工作树版本的 semver 可升级性(存量用户认不认) |
| 发版前 | release.sh artifact-check(/release Step 0) | Actions 存储配额两个口径:快照(所有 artifact,含已过期 —— 过期≠已清除,账照算)+ 账单(billing/usage 的 GB-hours ÷ 已过去小时数 vs plan included)。🔒 问句:账单那半这次真的验了吗? 缺 keychain lampio-gh-billing 时它会说「本次没验」并放行 —— 那是刻意的(取不到 ≠ 值不对),但读的人要分得清「过了」和「没验」。假绿风险:① 只看快照 = 重演 dev.11(报 0 MB 却撞墙);② GB-hours 是均值不是快照,刚删完东西它仍偏高(偏保守,可接受),月初 <12h 直接判「太早」 |
| 部署态 | release-verify.sh R12–R14 | 公开 Release 非草稿 + 产物集合 · landing 三条直链可下 · workflow 默认权限 |
| 部署态 | release-verify.sh R15 | 会过期的发版凭据都在 ops_credentials 里有行 |
| 端到端 | release-verify.sh --deep R16 | 真的下载制品并验 minisign(含翻转一字节的阳性对照) |
| 别处 | CI check:schema-frozen | v1 迁移本体冻结(红线 #11)——断链后 pending 迁移永不执行 |
| 别处 | release.sh verify-notarized | macOS 签名 / 公证 / staple(发版当次,需 .dmg 在手) |
| 别处 | admin/lib/ops-external.ts + release-consistency.spec.ts | 「最新 Release / updater 指向 / landing TAG」三路版本字符串比对,含「取不到 ≠ 一致」反向断言,显示在 /admin/ops |
| 别处 | ops_daily_monitor() 的 credential_expiry | 凭据到期告警 |
| 别处 | ops-monitoring.md J12/J13 | 上面那张卡的看板显示侧判断层 |
R1–R16 全部做过注入式反向验证(2026-08-27,见 §3):12 处真实缺陷各注入一次, 全部变红,还原后 git diff 零残留。注入在独立 worktree 里做,没碰共享工作树。
⚠️ 三路字符串比对刻意不在本脚本里重做。 它已经有实现 + 单测 + 看板展示。 本脚本补的是那张卡够不到的一层:字符串对得上,文件不一定在—— TAG 写对、资产名写错,三路比对照样全绿,用户点下去 404(R13 下探到文件本身)。
2. 判断层逐格判据
2.1 更新真的送得到吗
| # | 问句 | 假绿风险 |
|---|---|---|
| T1 | promote-updater 是人工闸门——这次发版跑了吗? | ✅ 机械层 R11/R12 能看出「endpoint 落后于最新 Release」。要人判的是这是不是故意的:护栏 #7 明说「先放着观察一晚」是合法用法。同一个数字既可能是事故也可能是策略,机器分不出来 |
| T2 | 公开仓那个版本的 Release,从草稿转正了吗?在跑 promote 之前? | 🔴 顺序反了会造出最隐蔽的一种断更:promote-updater 用你的 token 从草稿里就能取到 latest.json 并推上线,而客户端匿名去下资产是 404。两边都"成功"了,只有用户那头是坏的。R12 会在事后报出来,但代码里没有前置断言(见 §4 K2) |
| T3 | 三个平台的制品都产出了吗? | 🔴 少一个平台的后果是只有那个平台断更,其余一切正常——最难被发现的一种。upload-artifact 是 if-no-files-found: warn,gen-latest-json.mjs 此前只在零平台时才报错(见 §4 K3)。2026-08-30 起 gen-latest-json.mjs 自己也拿构建矩阵现推期望集合,与 R8 判据同构 —— 分工是它事前拦截(生成这一步就红)、R8 事后复核。两处都在 |
| T4 | 装了上一版的机器,semver 上认不认这一版是更新? | 🔴 这条是本域最贵的坑,且完全静默。prerelease 段里字母段按 ASCII 比:beta < dev < preview < rc。0.1.0-dev.9 → 0.1.0-beta.1 会让所有存量用户永远收不到更新,而 Release 页、下载页、看板三路字符串全部一致。安全姿势是换标签的同时抬基版本号。机械层 R2+R11 钉住这条 |
| T5 | 客户端什么时候检查更新? | 启动时静默自检一次(silentCheckOnce)。⚠️ 阅读器是那种会开着好几天不关的应用——开着不关就永远不再检查。这不算缺陷(下次启动就会检出),但决定了「promote 之后多久能铺开」的真实节奏,别按「一天内全量」估 |
| T6 | 自检失败时,有人会知道吗? | 🔴 silentCheckOnce 把异常吞进 logger.info,刻意不打扰用户——这对用户是对的,对你是盲区:endpoint 挂了、证书过期、DNS 被污染,客户端侧一片安静。所以这个域的可观测性只能靠外部探针(本脚本),不能指望客户端上报 |
2.2 签名与信任
| # | 问句 | 假绿风险 |
|---|---|---|
| T7 | 签名私钥换过吗? | 🔴 这是本域唯一不可补救的操作。 客户端里的 pubkey 是编译期 baked 进二进制的——密钥一换,所有存量用户的客户端都会验签失败并静默拒绝,而你没有任何办法把新公钥推给他们(推更新正是被拒的那件事)。唯一出路是让用户手动重装。R10 比对 keyid,但真正的防线是「不要换」 |
| T8 | mac 的两个 tarball 在签名之后被改了名(gen-latest-json.mjs 插 arch 后缀),这安全吗? | ✅ 2026-08-27 实测坐实:下载 Lampio_aarch64.app.tar.gz 用客户端那把公钥验签通过,而签名的 trusted comment 里写的仍是改名前的 Lampio.app.tar.gz。安全的根据是 minisign 验的是内容不是文件名。⚠️ 这是一条关于 updater 实现的假设,不是契约——升级 tauri-plugin-updater 大版本时要重新确认它没开始校验文件名 |
| T9 | macOS 包真的过了公证?两个 arch 都过了吗? | verify-notarized 只下 aarch64 那个 .dmg 验(Intel 的从未验过,见 §4 K4)。且它必须验 DMG 内部的 .app——票据装订在 .app 上,直接对 DMG 容器跑 spctl 会恒报 Unnotarized(假阴性,2026-08-01 踩过) |
| T10 | Windows 仍未签名——后果讲清楚了吗? | 首次运行 SmartScreen 拦截,用户要点「更多信息 → 仍要运行」。⚠️ 这对更新也成立:updater 装 NSIS 包时同样会被拦。文案在 release.yml 的 releaseBody + landing。见 §4 K5 |
2.3 分发面
| # | 问句 | 假绿风险 |
|---|---|---|
| T11 | 公开仓是公开的、私有镜像仓是私有的吗? | 两个仓角色完全不同(Actions 在私有仓跑并建 draft;publish-public job 把产物发到公开仓)。公开仓一旦转私有,endpoint 与三条下载直链同时静默 404。R6/R9/R13 全部刻意匿名探测正是为此——带 token 验会把这种情况一起验绿 |
| T12 | release.sh publish 到底发布了什么? | 🔴 它发布的是私有镜像仓那个 draft,对外可下载性一点没变。 名字叫 publish,做的事跟用户无关。对外要另外 gh release edit --repo …-releases --draft=false。SKILL.md Step 7 用一句 > 引注提了这件事——散文里的步骤会被跳过,这正是 K2 的成因 |
| T13 | landing 那行 TAG 改了吗?改对了吗? | ✅ 三路字符串比对在 ops 卡(J12/J13),文件可达性在 R13。要人判的是第三层:下载页的文案与包的真实状态一致吗(「已签名+公证」是不是还成立、Windows 那段绕行说明该不该在) |
| T14 | 私有仓的 artifact 配额还够吗? | ⚠️ 这条不在发版链路上,却会从外面把发版打红:2026-08-24 实测 artifact 累到 701MB 撑爆 500MB 免费额度,后果是全仓所有 workflow 的上传步骤失败——CI (admin) 在 typecheck/lint/E2E 全过的情况下照样报红。retention-days: 3 是为此设的,别随手调大 |
2.4 版本真相源与文档
| # | 问句 |
|---|---|
| T15 | 版本真相源仍然只有 src-tauri/tauri.conf.json 一处吗?(package.json 是死字段,长期 0.0.0,别据它判断;R1 复用 CI 那道判据) |
| T16 | 本次 tag 动了 assets/sql/schema.sql 吗?(红线 #11:改它 = 存量库 VersionMismatch 断链,pending 迁移永不执行。CI check:schema-frozen + /release Step 1.5 双守) |
| T17 | /release 的 SKILL.md 里那些写死的数字与产物清单,还对吗?(它是操作真相源,过期会直接把人带偏——见 §4 K6) |
| T18 | 发版依赖的凭据,下一次发版之前会不会有到期的?(R15 只管「有没有登记」,到期告警归 ops_daily_monitor;要人判的是「排期撞不撞」) |
3. 反向验证配方
只验通过路径 = 没验。 以下全部不依赖「记得改回来」。
3.1 🔒 先说不要做的事
- 不要为了验证而打 tag、触发 Actions、publish Release、或跑
promote-updater。 那些是 outward-facing 且部分不可逆(tag 不可复用、已送达的更新撤不回),归/releaseskill 且必须由用户发起。本专题全程只读。 - 不要为了试一把而轮换签名密钥。见 T7——那是本域唯一不可补救的操作。
- 不要在共享工作树里注入。注入必然要改
tauri.conf.json/release.yml这些枢纽文件, 而并行会话的一次checkout .就会连你的注入一起卷走(或者反过来)。
3.2 注入 → 必须红 → 还原 → 复核零残留(在独立 worktree 里)
bash
git worktree add --detach /tmp/relverify-wt HEAD # 隔离,不碰共享工作树十二种值得常备的注入(2026-08-27 全部实测变红):
| 注入 | 该红的检查 |
|---|---|
改 package.json 的 version | R1 三处一致 |
| 把 semver 比较器换成字符串比较 | R2 自检(阳性对照) |
版本去掉 -dev 后缀而 identifier 仍 .dev | R3 正式版硬闸 |
删掉 MainApp.tsx 里那句 silentCheckOnce() | R4 接线(最容易漏的一环) |
release.yml 引用一个不存在的 secret | R5 名单对账 |
| 从构建矩阵里删掉一条 target | R8 平台键 vs 矩阵 |
翻转 tauri.conf 公钥里的一个 keyid 字节 | R10 签名密钥 |
版本从 dev.10 换成 beta.1(真实陷阱) | R11 semver 可升级性 |
| 让 R12 去查一个产物不全的老版本 | R12 产物集合 |
| landing TAG 指向不存在的 tag | R13 下载直链 |
| 往必需凭据清单里加一个台账没有的名字 | R15 台账对账 |
| endpoint 指向不存在的 URL | R6 匿名可取 |
⚠️ 注入必须能通过编译/解析,否则脚本会在到达那条断言之前就挂掉——那既不是红也不是绿, 是没验,而输出里看起来只是一条报错。改 JSON 时用 json.load/json.dump 走一遍,别用裸 sed 碰结构。
3.3 🔴 阳性对照:证明扫描器不是瞎子
这个域有两条断言天生是「没找到问题」形式的,它们最危险的失效不是误判,而是根本没在看:
- 「资产可下」——如果抓取路径本身恒返回 200(代理拦截、缓存、重定向到登录页), 三条「✅ 可下」会一直绿着而什么都没验证。故 R9 先探一个必然不存在的资产名, 它必须返回 404;不是 404 就把下面三条全部作废。
- 「签名通过」——一个恒返回 True 的验签器同样会一直全绿。故
minisign_verify.py selftest在正向验完之后翻转一个字节再验一次,必须变 bad。--deep走的就是这条路径。
3.4 🔴 让字节真的走一趟(--deep)
前面每一条都只是「拿到了一个成功的返回」。--deep 把制品真的下下来, 用客户端那把 baked 公钥做一遍 minisign 验签——客户端装不装得上,靠的正是这一步。
2026-08-27 实测:Windows 制品与 mac aarch64 制品(改名后的那个)都验签通过, 翻转一字节后都变 bad。这同时坐实了 T8 那条「改名安全」的假设。
⚠️ --deep 默认只验体积最小的那个平台。验的是同一把密钥、同一条链路, 不是每个平台各验一遍;要全验就手动逐个跑 minisign_verify.py。
3.5 判据类不需要造故障
与 ops-monitoring.md §2.4 同法。本域唯一的纯判据是 semver 可升级性, 已做成 R2 的向量自检:正向(dev.10 > dev.9)+ 🔴 反向(beta.1 不大于 dev.9, 把陷阱本身写进断言)。比较器一退化成字符串比较,R2 当场红。
4. 已知未修 / 有意接受
不要当成新发现报上来,但每轮仍要独立确认现状(还在?变严重了?条件变了?)。
| # | 事情 | 为什么现在不修 | 什么条件下重新处理 |
|---|---|---|---|
| K1 | R12 的「公开 Release 是草稿」那一支从未被真实草稿触发过。 库里没有草稿态的 release 可供验证,注入只验到了它的另一半(产物集合) | 造一个真草稿 = 真的发一次版,代价与风险都不对等 | 🔴 下次发版时顺手做掉:在把公开仓那个 draft 转正之前跑一次 release-verify.sh,R12 必须报红。不红就说明这条从未工作过。这是免费的——那个窗口本来就会出现 |
| K2 | ✅ 已修(2026-08-30) —— cmd_promote_updater 开头加了 gh release view --json isDraft 前置断言:草稿即拒绝并提示 --draft=false。反向注入实测过(假 gh 顶掉真 gh,isDraft=true → 拒绝且不进入 download;false → 放行)。原文留档:promote-updater 曾没有「目标版本的公开 Release 已 publish」前置断言。 它的注释写着「前置:该版本的公开 Release 已 publish」,但那是散文:脚本用你的 token 从草稿里就能取到 latest.json 并推上线,客户端匿名下载则 404 | 顺序目前由 /release skill 的 Step 7 → 7.5 保证,且 R12 会在事后报出来 | 已进 backlog。散文约束迟早会被跳过——/release 之外任何人手跑一次 promote-updater 就绕过了它。修法是在 cmd_promote_updater 里加一句 isDraft 断言 |
| K3 | ✅ 已修(2026-08-30) —— gen-latest-json.mjs 现从 release.yml 的矩阵 target 现推期望平台集合(判据与 R8 同构:同一张映射表、同一条正则),集合不等即 exit 1,把失败挡在生成这一步而不是发布之后。读不到 workflow / 推不出平台时也 exit 1(fail-closed,不能让它变成恒绿)。反向注入实测:抽掉 windows 那条腿的制品,旧版静默产出 2 平台 latest.json 且 rc=0,新版 rc=1。原文留档:gen-latest-json.mjs 曾只在平台数为 0 时报错,不检查是否等于构建矩阵。配合 upload-artifact 的 if-no-files-found: warn,一个 2 平台的 latest.json 会静默上线 | 当前有 needs: build 兜着(矩阵任一条腿失败就不会走到 publish)。漏网的是「腿成功了但没产出制品」 | 已进 backlog。R8 目前是事后唯一会红的地方——但那是发布之后才发现。修法是在脚本里对矩阵求期望集合 |
| K4 | verify-notarized 只验 aarch64 的 .dmg,Intel 那个从未被验过 | 两个 mac job 用同一套证书与同一条公证路径,一个过了另一个大概率也过 | 出现过一次「只有一个 arch 公证失败」时;或改动 macOS 签名/公证接线时 |
| K5 | Windows 未签名,SmartScreen 首次运行拦截;updater 装 NSIS 包时同样被拦 | 代码签名证书是年费成本,内测期不值得 | 面向真实外部用户放量时(EV 证书或 Azure Trusted Signing)。届时 landing 与 releaseBody 的绕行文案要一起撤 |
| K6 | ✅ 已修(2026-08-30) —— 两处都改了,但改法不同:.msi 那处是事实错误(bundle.targets = nsis/app/dmg),直接改对并补一句为什么没有 msi;「当前 0.1.0-dev.8」那处没有更新成 dev.10,而是删掉了这个数(改成指向 next-version 现推 + 一条读现值的命令)—— 把它更新一次只会再漂一次,正是本条自己总结的教训。顺带把「汇总 4 包」改成 3 个安装包 + updater 制品 + latest.json。原文留档:SKILL.md 曾写着当前版本 0.1.0-dev.8(真相源已是 dev.10)、产物清单仍列 .msi | — | 三处 0.1.0-dev.8 刻意保留(冻结基线 ×2、首个带 updater 的版本 ×1):那些是历史事实,不是会漂的当前值 |
| K7 | 本地 main 常有未推送的 commit(本轮实测十余个) | 单机开发的常态,不是发版链路缺陷——git push <tag> 会连带推送可达 commit | 刻意不做成机械检查:它会近乎恒红,而「恒红的闸门与恒绿的一样坏」。双推备份的纪律归 CLAUDE.md §10 |
⚠️ 这张表本身也会腐烂。 每轮验收后更新它——一条「已知未修」若其实已被修, 会让下一轮跳过一次本该做的检查,那是最贵的一种过期。
5. 验收台账
每轮一行。不新建报告文档——产物按 README.md 的规矩拆散。
| 日期 | 基线 | 机械层 | 判断层结论 | 留痕 |
|---|---|---|---|---|
| 2026-08-01 | 0.1.0-dev.10 | 尚无 | 发版当次的真机验证:updater dev.8/9 → dev.10 检出+下载+应用 ✅;干净 Mac 首开无 Gatekeeper 拦截 ✅ | CHANGELOG;backlog §macOS CI 签名 |
| 2026-08-27 | 0.1.0-dev.10 | PASS=17 FAIL=0 SKIP=0(--deep 时 18,首次) | 建成本专题。新增部署态脚本(R1–R16)+ 自带纯标准库 minisign 验签器。12 条守卫全部注入验证过鉴别力(独立 worktree,零残留),其中 dev.10 → beta.1 那条注入复现的是本域最贵的真实陷阱。--deep 让字节真的走一趟:Windows 与 mac aarch64 制品都验签通过、翻转一字节都变 bad——顺带坐实了 T8(mac tarball 在签名后被改名,安全性依赖「minisign 验内容不验文件名」,此前从未验证过)。新发现 K2(promote-updater 缺前置断言)+ K3(gen-latest-json 不核平台数)+ K6(SKILL.md 数字过期);K1 记为「下次发版顺手验」,K4/K5/K7 记为有意接受。本轮验的是「已发布那一版的链路通不通」,不是「下次发版会不会出问题」(见 §0 元前提) | 本目录;K2/K3 进 backlog |