四条 P1 + 四条 P2 一并处置(不变的是判据,变的是「谁在承重」)。
P1-1 `.github/workflows/release-build.yml` 的 sed 替换串是裸 0x01(本意 `\1`):GitHub 解析该文件会
失败 ⇒ 整个 run 起不来;即便容忍,版本号也会被求值成单字节,PE 判据报「与 Cargo.toml 不同源?」把
排障引向错方向。修成字面 `\1`,并新增 `packaging/check_workflows_yaml.py`(真 YAML 解析器 parse
全部 workflow + 裸 C0 控制字符字节判据;带 --self-test 注入 0x01/坏结构断言必红),接进
`release-guard.yml` 两步。文本扫描型守卫看不见这类字节,这道门禁专门挡它。
P1-2 `release-publish.yml` 没在 `on.workflow_call.secrets` 里声明 `ULL_MANIFEST_SIGNING_KEY`,调用方
却传了:可复用工作流只拿得到自己声明过的 secret ⇒ 用户配好钥也会在签名步红,而错误文案反过来指控
Secret 没配。补声明(required: false,fail-closed 由步内 ::error:: + exit 1 承担,同
ULL_MSA_CLIENT_ID 的模式)。
P1-3 两个测试支撑文件越过 400 行守卫:签名夹具拆到 `tests/support_sign.rs`(support.rs 458→392),
CLI e2e 的签名部分拆到 `support/update_e2e_sign.rs`(update_e2e.rs 406→348)。纯搬家。
P2-2 「唯一收口点」改成结构事实:从 `UpdateSource` 删掉 `fetch_manifest` 默认实现,调用方只能拿
`verified_manifest(&source, channel)`(源只交原文,想绕过验签就得自己重写解析与渠道判据)。
P2-3 文档写的第 ④ 条命令补进流水线:`sign_manifest.py --verify-pinned` 用 trust.rs 里钉住的公钥
回验刚落盘的 .sig(不碰私钥),发布侧因此验证的是「客户端的判据」而不是「签名脚本的自我声明」。
P2-4 随自写实现一起删除(`Fe` 的错误 PartialEq 不再存在)。
P2-5 app-iced/build.rs 注释里那个 windows_subsystem 属性本分支不存在(M196 才有),改为说明
「本文件只写版本资源,子系统由 rustc 属性决定,check_pe_version.py 会把 subsystem 打出来」。
P2-6 坏清单不再带 error.path 是有意降级(来源 id 在 message 里),保持。
裁定项:自写 Ed25519 换成 `ed25519-dalek::verify_strict`(信任锚不许自写密码学;此刻信任表还是空表,
零兼容负担)。删除 `update/ed25519/{field,group,mod}.rs`(含只服务它们的单测),新 `update/ed25519.rs`
只剩一个薄封装;拒绝类用例(可锻 S+L / 小阶公钥 / 非规范 R / 空签名 / 未知 key)**一条没删**,
改成钉 dalek 的 strict 语义——实测 dalek 对全部五类的结论与旧实现一致(非规范 R 的拒绝理由不同:
dalek 走「小阶点」而非「编码非规范」,用例注释里写明)。签名侧留在 `packaging/ed25519_ref.py`
(纯 Python、零 pip 依赖),`--self-test` 用 RFC 8032 同一组向量钉两边。
打包与发布工程(packaging/)
单一真相源:单文件产物矩阵 + 发布契约。.github/workflows/release.yml 按本文实现,
packaging/make_artifacts.py 是本地与 CI 共用的打包脚本(同一份代码 → 同一套目录布局)。
本目录只放发布工程资产(脚本 / 说明 / 发布说明模板),不参与任何 crate 的编译。
索引:§1–§12 各在哪份文件(M145 拆分)
拆分原因:本文件与 .github/workflows/release.yml 都已是 400 行整,下一处改动必撞全仓行数红线(guards.yml 的
file-size,单文件 ≤400 行)。按主题分开后每份都留出余量,内容一字未改、只搬家;节号沿用原编号,
所以全仓那些「packaging/README.md §4 / §7.4 / §9」式引用不必改动 —— 按下表找承载文件即可。
| 节 | 主题 | 承载文件 |
|---|---|---|
| §1 | 产物矩阵 | packaging/docs/artifacts-and-version.md |
| §2 | 版本来源(唯一真相源) | packaging/docs/artifacts-and-version.md |
| §3 | 怎么构建(本地 / CI) | packaging/docs/build-and-ci.md |
| §4 | 每平台运行时依赖 | packaging/docs/runtime-deps.md |
| §5 | 软渲染兜底 | packaging/docs/runtime-deps.md |
| §6 | Cargo 构建档位 | packaging/docs/profiles-and-measurements.md |
| §7 | 实测(本机,不是估算) | packaging/docs/profiles-and-measurements.md |
| §8 | 发行合规 | packaging/docs/compliance-support-boundaries.md |
| §9 | 支持矩阵(对外口径) | packaging/docs/compliance-support-boundaries.md |
| §10 | 已知边界 / 后续 | packaging/docs/compliance-support-boundaries.md |
| §11 | CI 运行时冒烟(Linux 真渲染一帧) | 本文下方(细节在 packaging/ci-runtime-smoke.md) |
| §12 | 文件清单 | 本文下方 |
本文件仍是契约的唯一真相源入口:.github/workflows/release.yml(及拆出的 release-build.yml /
release-publish.yml)的注释、make_artifacts.py 的说明、根 README.md / README.zh-CN.md 与
packaging/release-notes.md 都按「packaging/README.md §N」引用。改契约时先改上表对应的承载文件,
再看一眼 §12 的行数预算 —— 任何一份超过 400 行都要继续按主题拆。
11. CI 运行时冒烟(Linux 真渲染一帧)
细节与依据在 packaging/ci-runtime-smoke.md(本节再增长也往那里写,两个文件都必须 ≤400 行)。
ci.yml 的 runtime-smoke job 在 ubuntu-latest 上补第一份真实图形环境证据:装 xvfb + 软渲染依赖 +
字体,用 xvfb-run 起 GUI 进程渲出一帧 PNG,再对像素本身断言(退出码 0 / 尺寸 / 非空白,artifact
runtime-smoke-screenshot)。
要点三条:
- 只看退出码不够:「合法 PNG + 退出码 0」不等于画面有内容;「完全没字体」与「非软渲染」都是硬失败(panic / 报错,不产生 PNG;review 实测)。
- 闸门下限 ≈「至少画出一条标题栏」(review 实测切片仍 pass):证明「场景被真实渲染过」,不证明界面元素齐全, 更不证明视觉正确——视觉靠人眼看 artifact。
- 只覆盖 Linux x64;macOS 是 dispatch-only 探针(无实测证据),Windows 已有本机人眼验收。
- (M184)
linux-x64已进发布名单,所以这条同类判据也常驻在发布流水线里:release-build.yml的Linux x64 运行时冒烟步用同一个check_screenshot.py判 release 产物的首帧,外加[theme]/[backend] final=… result=started两行日志断言。证据账(含真机 WSLg 实测输出)在packaging/docs/linux-evidence.md。
本地复跑:bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke——脚本逐步复刻这个 job(同样的 apt 依赖、
--bin unlucky-launcher-iced、同样的 xvfb-run … --software-renderer --screenshot --data-dir);输出目录别写 /tmp,
Git Bash 下会静默落进 Docker VM(脚本内部用 cygpath -m 归一)。
M131 真跑过一次(本机 Docker / Debian 12 容器 / 2026-11-05):exit=0,截图 1180×760、[ok] 截图非空白。
12. 文件清单
| 文件 | 作用 | 行数上限 |
|---|---|---|
packaging/README.md |
本文:索引 + §11 / §12 / §13(契约入口;§1–§10 见 docs/) |
≤400 |
packaging/docs/artifacts-and-version.md |
§1 产物矩阵 + §2 版本来源 | ≤400 |
packaging/docs/build-and-ci.md |
§3 怎么构建(本地 / CI) | ≤400 |
packaging/docs/runtime-deps.md |
§4 每平台运行时依赖 + §5 软渲染兜底 | ≤400 |
packaging/docs/profiles-and-measurements.md |
§6 Cargo 构建档位 + §7 实测 | ≤400 |
packaging/docs/compliance-support-boundaries.md |
§8 发行合规 + §9 支持矩阵 + §10 已知边界 | ≤400 |
packaging/make_artifacts.py |
打包与校验(本地 + CI 共用) | ≤400 |
packaging/release-notes.md |
GitHub Release 说明模板({{VERSION}} / {{SUMS}} 占位) |
≤400 |
packaging/check_screenshot.py |
截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
packaging/check_publish_slots.py |
发布名单守卫(M164/M184):名单 ⇄ 产物两个方向 + mode: ci 槽位无产物的警告;--self-test 注入 11 条回归 |
≤400 |
packaging/check_test_source_off.py |
测试通道守门(M169):workflow / 打包脚本 / Cargo.toml default 都不许打开 update-test-source;归属按 jobs.*.steps[] 列表项判、默认拒绝;--self-test 按 tests/guard_vectors.json 注入 |
≤400 |
packaging/tests/guard_vectors.json |
守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 |
packaging/ci-runtime-smoke.md |
§11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
packaging/docs/linux-evidence.md |
Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤400 |
packaging/linux-smoke-docker.sh |
本地复跑 runtime-smoke(Docker + xvfb) |
≤400 |
packaging/linux-check-docker.sh |
本地复跑 ci.yml 的两条 ubuntu 门禁(clippy / test,Docker) |
≤400 |
.github/workflows/release.yml |
tag 触发 → 选槽位(plan)→ 调下面两个可复用工作流 |
≤400 |
.github/workflows/release-build.yml |
workflow_call:单槽位构建(体检 / cargo / 打包 / 上传 artifact) |
≤400 |
packaging/make_manifest.py |
更新清单 manifest.json 的生成与自验(D50,见 §13) |
≤400 |
packaging/tests/manifest.golden.json |
清单 golden 样例:make_manifest.py --self-test 逐字节对拍 |
≤400 |
.github/workflows/release-publish.yml |
workflow_call:发布(PUBLISH_SLOTS 裁剪 / SHA256SUMS / 清单 / Release) |
≤400 |
13. 更新清单 manifest.json(D50)
清单作为固定文件名的 Release 资产挂着;客户端按 releases/latest/download/manifest.json 取(D50)。
字段形状的唯一权威是 crates/core/src/update/manifest.rs 的 ReleaseManifest(deny_unknown_fields +
逐字段校验:semver、size > 0、摘要长度/字符集、target/format 取值表)——生成器只负责把那套形状填对。
- 生成器:
packaging/make_manifest.py(零第三方依赖)。CI 在release-publish.yml里重算SHA256SUMS.txt之后调它,所以清单描述的是「真正要发的资产」,不是「构建出来的全部」。 - 同源,不双写:每个
sha256直接读SHA256SUMS.txt,size取自同一批文件的stat();产物名里的版本段 必须等于--version(tag 与Cargo.toml漂了就没有产物可对,当场失败)。--self-test用packaging/tests/manifest.golden.json逐字节对拍,并跑 14 条负例证明严格解析不是摆设。 - 只放启动器产物:
ull不进清单。DTO 的Artifact没有角色字段,Release::artifact_for()取「第一个 target 命中的产物」,把 CLI 并列写进去会让「该覆盖哪个文件」取决于数组顺序(ull照旧进SHA256SUMS.txt)。 url两种形态:默认裸文件名(相对源根,DirSource口径,本地/内网用);CI 传--asset-base-url拼绝对地址,且钉 tag 不钉 latest——清单里每条都指向与它自报版本相符的字节。min_supported/notes只是可填而不默认填:前者是「收回老版本支持」的发布策略(未拍板,写错会强制升级), 后者在 Release 正文里已有,两处各写一份迟早漂。- 本地复跑:
python3 packaging/make_artifacts.py --manifest→ 与 CI 同一段代码,产出dist/manifest.json。
D51 定案(2026-11-06,方案甲)+ M163 语义修正(2026-11-07):GitHub 的 releases/latest/download/*
只指向非 prerelease 的最新 Release。而自更新清单固定取 releases/latest/download/manifest.json,所以
「新版本必须被标成稳定版,latest 才会指过去」——M163 之前 push tag 默认打 --prerelease,v0.2.2 因此至今
releases/latest 404、更新通道取不到清单。现在的语义(唯一真相源 packaging/release_marker.py):
| 触发 | create(新建 Release) | edit(Release 已存在) |
|---|---|---|
push tag v*(含其 Re-run) |
稳定版(不带标记) | 不碰标记 |
workflow_dispatch + prerelease=true |
预发布 | 显式改成预发布 |
workflow_dispatch + prerelease=false(默认) |
稳定版 | 显式改成稳定版 |
v0.2.2 那个 Release 保留为历史预发布不动,稳定通道以 v0.2.3 为起点。发版 checklist 第六件 =
确认 prerelease 标记与通道一致(gh release view <tag> --json isPrerelease),守门是
packaging/check_release_semantics.py(CI:.github/workflows/release-guard.yml)。
14. 更新链路的测试通道(update-test-source,M169)
「发现新版本 → 下载 → 长度 + SHA256 双检 → 就位」这条链要能在发布档(release)上验收,就得让 二进制能指向本地更新源;而这条能力同时是「离任意本地目录最近的一条路」(终点是替换用户盘上的 exe), 所以它必须是编译期的,不是运行期设置项:
- 默认关:
update-test-source是crates/core/cli/presenter/app-iced各一条 feature, 默认不启用。官方发布构建不带它 ⇒core::update::job::default_source里那段分支根本编不进来 (README 的「release builds do not contain that path at all」就是这么成立的)。 - 验收构建:
cargo build --release --features update-test-source,再配ULL_UPDATE_DIR=<root>+ULL_UPDATE_TEST_SOURCE=1(两档缺一不可)。ull update check --json会 如实写data.source: "file:<root>"、data.test_source: true,人读输出另有一行点名。 - 守门:
packaging/check_test_source_off.py(CI:.github/workflows/release-guard.yml,两步:--self-test+ 正式扫描)扫.github/workflows/**(含复合 action)、packaging/**的递归脚本、Makefile/justfile/Taskfile与各Cargo.toml的default。唯一允许出现该 feature 的地方是.github/workflows/ci.yml里名为update-e2e的那一步,且该步必须带--test update_e2e_test(文件名 / 步骤名 / 参数任一不符即红,见脚本的ALLOWED_STEP)。 归属按jobs.*.steps[]的列表项判(不是「最近一行- name:」):落不到具名步骤上的行——jobs:之前的孤儿行、项外键、没有name:的步骤、flow 风格步骤——一律判红(默认拒绝)。自测向量在packaging/tests/guard_vectors.json(32 条向量:26 条注入必须红 + 6 条合法写法必须绿,另加基线必须绿; 条数以--self-test输出为准)。.cargo/config*.toml也在扫描面里:--cfg feature="…"与rustc-wrapper都能绕过--features(后者一律判红,合法用途要 review 明确同意)。 抓不住(如实记):变量间接拼接(FEAT="update-""test-source")、两步$GITHUB_ENV交接 (第一步把 feature 写进 env、第二步只用--features "$FEAT"——第二步没有字面量,任何静态扫描都 看不见,这是本守门的固有边界,不是漏判)、feature 改名后接回发布、build.rs里 spawn cargo、以及 「把别的步骤改名成update-e2e并带--test update_e2e_test却干别的事」。 - 验收本体:
cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test --test update_e2e_tamper_test(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。CI 里同一个命令挂在ci.yml的update-e2ejob(只在 Windows:夹具的「真形状」是 Windows 裸.exe+portable-exe;Linux/macOS 的真实产物是.tar.gz+portable-zip,在本模块会被直接拒)。 不验真 GitHub 下载、GUI 点击与重启替换(需人工)。
14. 产物署名与清单签名(M190)
这一节只放「去哪看」的三句话,手册本体在 packaging/docs/signing.md:
- PE 版本资源(Windows「属性 → 详细信息」里的发布者/产品/版权/四段式版本):口径唯一来源
packaging/pe_version.rs,被crates/cli/build.rs与crates/app-iced/build.rs各include!一次; 判据是读产物字节的packaging/check_pe_version.py(CI:release-build.yml的 Windows 槽位)。 - 清单签名:
manifest.json.sig与manifest.json并列上传,是分离的 Ed25519 签名文件(签的是清单 的原始字节,不是某个规范化后的 JSON)。信任锚是crates/core/src/update/trust.rs里的 编译期公钥数组(keyring);源里没有.sig、钥不在表里、签名对不上,一律signature_*分类拒装, 不可重试。签名脚本packaging/sign_manifest.py(零第三方依赖,--self-test跑 RFC 8032 向量)。 - 不得混淆:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,不解决 SmartScreen ——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。